pi-dynamic-help 0.1.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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/extensions/help.ts +845 -0
- package/package.json +48 -0
- package/prompts/help-brief.md +15 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikumiiku
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# pi-dynamic-help
|
|
2
|
+
|
|
3
|
+
Dynamic `/help` dashboard for [Pi](https://pi.dev). It summarizes loaded slash commands, tools, MCP servers, and installed Pi packages, then shows them in a concise TUI markdown panel.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Local development install:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install /absolute/path/to/pi-dynamic-help
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Temporary test run without adding it to settings:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi -e /absolute/path/to/pi-dynamic-help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
NPM install:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pi install npm:pi-dynamic-help
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
After installing, run `/reload` or restart Pi if the command is not visible immediately.
|
|
26
|
+
|
|
27
|
+
## Commands
|
|
28
|
+
|
|
29
|
+
- `/help` — open the dynamic help dashboard.
|
|
30
|
+
- `/help refresh` — refresh the resource index and usage counters.
|
|
31
|
+
- `/help search <term>` — filter the dashboard by a term.
|
|
32
|
+
- `/help pin <term>` — pin the best matching resource.
|
|
33
|
+
- `/help unpin <term>` — unpin the best matching resource.
|
|
34
|
+
|
|
35
|
+
## What it indexes
|
|
36
|
+
|
|
37
|
+
- Slash commands registered by Pi, packages, prompt templates, and extensions.
|
|
38
|
+
- Active tools exposed to the agent.
|
|
39
|
+
- MCP gateway and configured MCP servers.
|
|
40
|
+
- Packages listed in user and project Pi settings.
|
|
41
|
+
|
|
42
|
+
The dashboard keeps command/tool/package/MCP sections separate so the resource type is clear.
|
|
43
|
+
|
|
44
|
+
## State and migration
|
|
45
|
+
|
|
46
|
+
Runtime metadata is stored under the user's Pi agent state directory:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
~/.pi/agent/state/pi-dynamic-help/state.json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The old local-extension state file, if present, is read as a migration source:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
~/.pi/agent/state/dynamic-help.json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The old file is not deleted. Migration preserves usage counts, pinned items, and timestamps where possible. The package normalizes source keys idempotently to avoid repeated `local:local:...` prefixes.
|
|
59
|
+
|
|
60
|
+
Runtime state is not part of the npm package and should not be committed or published.
|
|
61
|
+
|
|
62
|
+
## Privacy and safety
|
|
63
|
+
|
|
64
|
+
This extension reads local Pi settings and MCP configuration files to build the dashboard. It does not upload this information. Like all Pi extensions, it runs locally with the permissions of your Pi process, so install only from sources you trust.
|
|
65
|
+
|
|
66
|
+
## Development
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm install
|
|
70
|
+
npm run check
|
|
71
|
+
npm run test
|
|
72
|
+
npm run pack:dry
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Useful local smoke test:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
TMP_AGENT_DIR=$(mktemp -d /tmp/pi-dynamic-help-agent-XXXXXX)
|
|
79
|
+
PI_CODING_AGENT_DIR="$TMP_AGENT_DIR" pi install /absolute/path/to/pi-dynamic-help
|
|
80
|
+
PI_CODING_AGENT_DIR="$TMP_AGENT_DIR" pi --no-session -p "/help"
|
|
81
|
+
PI_CODING_AGENT_DIR="$TMP_AGENT_DIR" pi --no-session -p "/help refresh"
|
|
82
|
+
PI_CODING_AGENT_DIR="$TMP_AGENT_DIR" pi --no-session -p "/help search mcp"
|
|
83
|
+
PI_CODING_AGENT_DIR="$TMP_AGENT_DIR" pi --no-session -p "/help pin bash"
|
|
84
|
+
PI_CODING_AGENT_DIR="$TMP_AGENT_DIR" pi --no-session -p "/help unpin bash"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Troubleshooting
|
|
88
|
+
|
|
89
|
+
- `/help` does not appear: run `/reload` or restart Pi.
|
|
90
|
+
- State looks stale: run `/help refresh`.
|
|
91
|
+
- A pin targets the wrong resource: use a more specific search term with `/help pin <term>` or undo it with `/help unpin <term>`.
|
|
92
|
+
- Installed package is not shown: confirm it is listed in `~/.pi/agent/settings.json` or project `.pi/settings.json`, then run `/help refresh`.
|
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, isAbsolute, join, relative } from "node:path";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { DynamicBorder, getAgentDir, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Container, Markdown, matchesKey, Text } from "@earendil-works/pi-tui";
|
|
7
|
+
|
|
8
|
+
export type ResourceKind = "command" | "tool" | "package" | "mcp";
|
|
9
|
+
|
|
10
|
+
export type ResourceScope = "project" | "user" | "temporary" | "builtin";
|
|
11
|
+
|
|
12
|
+
export interface IndexedItem {
|
|
13
|
+
key: string;
|
|
14
|
+
kind: ResourceKind;
|
|
15
|
+
name: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
sourceLabel: string;
|
|
18
|
+
sourceRef?: string;
|
|
19
|
+
scope?: ResourceScope;
|
|
20
|
+
packageName?: string;
|
|
21
|
+
useCount: number;
|
|
22
|
+
lastUsedAt?: number;
|
|
23
|
+
lastSeenAt?: number;
|
|
24
|
+
pinned?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface HelpState {
|
|
28
|
+
version: 2;
|
|
29
|
+
items: Record<string, IndexedItem>;
|
|
30
|
+
lastRefresh?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface LegacyHelpState {
|
|
34
|
+
version?: number;
|
|
35
|
+
items?: Record<string, Partial<IndexedItem> & { key?: string; kind?: ResourceKind; name?: string; source?: string; origin?: string }>;
|
|
36
|
+
lastRefresh?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface McpServerEntry {
|
|
40
|
+
name: string;
|
|
41
|
+
mode: string;
|
|
42
|
+
lifecycle?: string;
|
|
43
|
+
command?: string;
|
|
44
|
+
url?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface PackageEntry {
|
|
48
|
+
name: string;
|
|
49
|
+
scope: ResourceScope;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PACKAGE_STATE_DIR = join(getAgentDir(), "state", "pi-dynamic-help");
|
|
53
|
+
const STATE_FILE = join(PACKAGE_STATE_DIR, "state.json");
|
|
54
|
+
const LEGACY_STATE_FILE = join(getAgentDir(), "state", "dynamic-help.json");
|
|
55
|
+
const MAX_ITEMS = 250;
|
|
56
|
+
const MAX_COMMON = 8;
|
|
57
|
+
const MAX_SECTION = 16;
|
|
58
|
+
const COMPACT_PREFIXES = ["local:", "project:", "pkg:", "mcp:", "npm:", "git:", "http://", "https://"];
|
|
59
|
+
|
|
60
|
+
const BUILTIN_COMMANDS: Array<{ name: string; description: string }> = [
|
|
61
|
+
{ name: "model", description: "Switch models" },
|
|
62
|
+
{ name: "scoped-models", description: "Enable or disable models for cycling" },
|
|
63
|
+
{ name: "settings", description: "Open interactive settings" },
|
|
64
|
+
{ name: "resume", description: "Pick from previous sessions" },
|
|
65
|
+
{ name: "new", description: "Start a new session" },
|
|
66
|
+
{ name: "name", description: "Set session display name" },
|
|
67
|
+
{ name: "session", description: "Show session file, messages, tokens, and cost" },
|
|
68
|
+
{ name: "tree", description: "Navigate the current session tree" },
|
|
69
|
+
{ name: "fork", description: "Create a new session from an earlier user message" },
|
|
70
|
+
{ name: "clone", description: "Duplicate the current active branch" },
|
|
71
|
+
{ name: "compact", description: "Summarize older context" },
|
|
72
|
+
{ name: "copy", description: "Copy the last assistant message" },
|
|
73
|
+
{ name: "export", description: "Export the session to HTML" },
|
|
74
|
+
{ name: "share", description: "Upload the session as a private gist" },
|
|
75
|
+
{ name: "reload", description: "Reload extensions, skills, prompts, and context files" },
|
|
76
|
+
{ name: "hotkeys", description: "Show keyboard shortcuts" },
|
|
77
|
+
{ name: "changelog", description: "Display version history" },
|
|
78
|
+
{ name: "quit", description: "Quit Pi" },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const MCP_CONFIG_FILES = [
|
|
82
|
+
join(homedir(), ".config", "mcp", "mcp.json"),
|
|
83
|
+
join(getAgentDir(), "mcp.json"),
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
function ensurePackageStateDir(): void {
|
|
87
|
+
if (!existsSync(PACKAGE_STATE_DIR)) {
|
|
88
|
+
mkdirSync(PACKAGE_STATE_DIR, { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function loadJson<T>(path: string, fallback: T): T {
|
|
93
|
+
if (!existsSync(path)) {
|
|
94
|
+
return fallback;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(readFileSync(path, "utf-8")) as T;
|
|
99
|
+
} catch {
|
|
100
|
+
return fallback;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function saveJson(path: string, data: unknown): void {
|
|
105
|
+
ensurePackageStateDir();
|
|
106
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function emptyState(): HelpState {
|
|
110
|
+
return { version: 2, items: {} };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function loadState(): HelpState {
|
|
114
|
+
const current = loadJson<LegacyHelpState | HelpState | undefined>(STATE_FILE, undefined);
|
|
115
|
+
if (current?.items) {
|
|
116
|
+
return migrateState(current);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const legacy = loadJson<LegacyHelpState | undefined>(LEGACY_STATE_FILE, undefined);
|
|
120
|
+
const migrated = migrateState(legacy ?? emptyState());
|
|
121
|
+
if (legacy?.items) {
|
|
122
|
+
saveJson(STATE_FILE, migrated);
|
|
123
|
+
}
|
|
124
|
+
return migrated;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function legacySourceFromKey(key: string | undefined): string | undefined {
|
|
128
|
+
if (!key) return undefined;
|
|
129
|
+
const first = key.indexOf(":");
|
|
130
|
+
const last = key.lastIndexOf(":");
|
|
131
|
+
if (first < 0 || last <= first) return undefined;
|
|
132
|
+
return key.slice(first + 1, last);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sourceLabelForLegacy(kind: ResourceKind, source?: string, scope?: ResourceScope): string {
|
|
136
|
+
if (kind === "package") return sourceLabelForPackage(source ?? "package");
|
|
137
|
+
if (kind === "mcp") return source === "mcp" ? "内置网关" : "MCP 服务器";
|
|
138
|
+
if (kind === "tool") return sourceLabelForTool({ source, scope });
|
|
139
|
+
return sourceLabelForCommand({ source, scope });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function mergeMigratedItem(existing: IndexedItem | undefined, next: IndexedItem): IndexedItem {
|
|
143
|
+
if (!existing) return next;
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
...existing,
|
|
147
|
+
...next,
|
|
148
|
+
useCount: Math.max(existing.useCount ?? 0, next.useCount ?? 0),
|
|
149
|
+
lastUsedAt: Math.max(existing.lastUsedAt ?? 0, next.lastUsedAt ?? 0) || undefined,
|
|
150
|
+
lastSeenAt: Math.max(existing.lastSeenAt ?? 0, next.lastSeenAt ?? 0) || undefined,
|
|
151
|
+
pinned: Boolean(existing.pinned || next.pinned),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function migrateState(state: LegacyHelpState | HelpState): HelpState {
|
|
156
|
+
const items: Record<string, IndexedItem> = {};
|
|
157
|
+
|
|
158
|
+
for (const raw of Object.values(state.items ?? {})) {
|
|
159
|
+
if (!raw.kind || !raw.name) continue;
|
|
160
|
+
const rawSource = raw.sourceRef ?? raw.source ?? legacySourceFromKey(raw.key) ?? raw.packageName ?? raw.sourceLabel;
|
|
161
|
+
const sourceRef = compactSourceRef(rawSource);
|
|
162
|
+
const sourceLabel = raw.sourceLabel && !isAbsolute(raw.sourceLabel) ? raw.sourceLabel : sourceLabelForLegacy(raw.kind, rawSource, raw.scope);
|
|
163
|
+
const next: IndexedItem = {
|
|
164
|
+
key: makeKey(raw.kind, raw.name, sourceRef),
|
|
165
|
+
kind: raw.kind,
|
|
166
|
+
name: raw.name,
|
|
167
|
+
description: raw.description,
|
|
168
|
+
sourceLabel,
|
|
169
|
+
sourceRef,
|
|
170
|
+
scope: raw.scope,
|
|
171
|
+
packageName: raw.packageName,
|
|
172
|
+
useCount: raw.useCount ?? 0,
|
|
173
|
+
lastUsedAt: raw.lastUsedAt,
|
|
174
|
+
lastSeenAt: raw.lastSeenAt,
|
|
175
|
+
pinned: raw.pinned,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
items[next.key] = mergeMigratedItem(items[next.key], next);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
version: 2,
|
|
183
|
+
items,
|
|
184
|
+
lastRefresh: state.lastRefresh,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function normalize(value: string | undefined): string {
|
|
189
|
+
return (value ?? "")
|
|
190
|
+
.toLowerCase()
|
|
191
|
+
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, " ")
|
|
192
|
+
.trim();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function extractPackageNameFromPath(source: string): string | undefined {
|
|
196
|
+
const match = source.replace(/\\/g, "/").match(/\/node_modules\/(.*)$/);
|
|
197
|
+
if (!match) return undefined;
|
|
198
|
+
|
|
199
|
+
const parts = match[1].split("/").filter(Boolean);
|
|
200
|
+
if (parts.length === 0) return undefined;
|
|
201
|
+
if (parts[0].startsWith("@")) {
|
|
202
|
+
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return parts[0];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function stripRepeatedLocalPrefixes(value: string): string {
|
|
209
|
+
let next = value;
|
|
210
|
+
while (next.startsWith("local:local:")) {
|
|
211
|
+
next = next.slice("local:".length);
|
|
212
|
+
}
|
|
213
|
+
return next;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function unwrapLocalWrappedCompactRef(value: string): string {
|
|
217
|
+
if (!value.startsWith("local:")) return value;
|
|
218
|
+
|
|
219
|
+
const inner = value.slice("local:".length);
|
|
220
|
+
if (inner === "builtin" || inner === "sdk" || inner === "unknown") return inner;
|
|
221
|
+
if (COMPACT_PREFIXES.filter((prefix) => prefix !== "local:").some((prefix) => inner.startsWith(prefix))) {
|
|
222
|
+
return inner;
|
|
223
|
+
}
|
|
224
|
+
return value;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function compactSourceRef(source?: string): string {
|
|
228
|
+
if (!source) return "unknown";
|
|
229
|
+
|
|
230
|
+
const normalized = unwrapLocalWrappedCompactRef(stripRepeatedLocalPrefixes(source.replace(/\\/g, "/")));
|
|
231
|
+
if (normalized === "builtin" || normalized === "sdk" || normalized === "unknown") return normalized;
|
|
232
|
+
if (COMPACT_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return normalized;
|
|
233
|
+
|
|
234
|
+
const packageName = extractPackageNameFromPath(normalized);
|
|
235
|
+
if (packageName) return `pkg:${packageName}`;
|
|
236
|
+
|
|
237
|
+
if (isAbsolute(normalized)) {
|
|
238
|
+
const homeRelative = relative(homedir(), normalized).replace(/\\/g, "/");
|
|
239
|
+
if (homeRelative && !homeRelative.startsWith("..")) {
|
|
240
|
+
return `local:${homeRelative}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return `local:${basename(normalized)}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return `local:${normalized}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function makeKey(kind: ResourceKind, name: string, sourceRef: string): string {
|
|
250
|
+
return `${kind}:${sourceRef}:${name}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function getProjectSettingsPath(cwd: string): string {
|
|
254
|
+
return join(cwd, ".pi", "settings.json");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function getProjectMcpPath(cwd: string): string {
|
|
258
|
+
return join(cwd, ".mcp.json");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function getProjectPiMcpPath(cwd: string): string {
|
|
262
|
+
return join(cwd, ".pi", "mcp.json");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function readPackagesFromSettings(path: string, scope: ResourceScope): PackageEntry[] {
|
|
266
|
+
const settings = loadJson<Record<string, unknown>>(path, {});
|
|
267
|
+
const raw = Array.isArray(settings.packages) ? settings.packages : [];
|
|
268
|
+
const values: PackageEntry[] = [];
|
|
269
|
+
|
|
270
|
+
for (const entry of raw) {
|
|
271
|
+
if (typeof entry === "string") {
|
|
272
|
+
values.push({ name: entry, scope });
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!entry || typeof entry !== "object") {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const obj = entry as Record<string, unknown>;
|
|
281
|
+
if (typeof obj.source === "string") {
|
|
282
|
+
values.push({ name: obj.source, scope });
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (typeof obj.package === "string") {
|
|
287
|
+
values.push({ name: obj.package, scope });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return values;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function mergePackageEntries(entries: PackageEntry[]): PackageEntry[] {
|
|
295
|
+
const byName = new Map<string, PackageEntry>();
|
|
296
|
+
for (const entry of entries) {
|
|
297
|
+
const existing = byName.get(entry.name);
|
|
298
|
+
if (!existing || existing.scope !== "project") {
|
|
299
|
+
byName.set(entry.name, entry);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return [...byName.values()];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function readMcpServersFromConfig(path: string): McpServerEntry[] {
|
|
306
|
+
const config = loadJson<Record<string, unknown>>(path, {});
|
|
307
|
+
const servers = config.mcpServers;
|
|
308
|
+
if (!servers || typeof servers !== "object") {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const entries: McpServerEntry[] = [];
|
|
313
|
+
for (const [name, value] of Object.entries(servers as Record<string, unknown>)) {
|
|
314
|
+
if (!value || typeof value !== "object") {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const server = value as Record<string, unknown>;
|
|
319
|
+
const command = typeof server.command === "string" ? server.command : undefined;
|
|
320
|
+
const url = typeof server.url === "string" ? server.url : undefined;
|
|
321
|
+
const lifecycle = typeof server.lifecycle === "string" ? server.lifecycle : undefined;
|
|
322
|
+
const mode = url ? "http" : "stdio";
|
|
323
|
+
|
|
324
|
+
entries.push({ name, mode, lifecycle, command, url });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return entries;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function sourceLabelForCommand(sourceInfo: { source?: string; scope?: ResourceScope; origin?: string }): string {
|
|
331
|
+
const source = sourceInfo.source?.replace(/\\/g, "/");
|
|
332
|
+
if (!source) return "未知";
|
|
333
|
+
if (source === "builtin") return "内置";
|
|
334
|
+
if (source === "sdk") return "SDK";
|
|
335
|
+
if (source === "local") return sourceInfo.scope === "project" ? "项目本地" : "全局本地";
|
|
336
|
+
if (sourceInfo.origin === "package") {
|
|
337
|
+
const pkg = extractPackageNameFromPath(source) ?? (source.startsWith("npm:") ? source.slice("npm:".length) : undefined);
|
|
338
|
+
return pkg ? `npm:${pkg}` : sourceLabelForPackage(source);
|
|
339
|
+
}
|
|
340
|
+
if (source === "extension") return "扩展";
|
|
341
|
+
if (source === "prompt") return "模板";
|
|
342
|
+
if (source === "skill") return "技能";
|
|
343
|
+
if (source.startsWith("npm:") || source.startsWith("git:") || source.startsWith("http://") || source.startsWith("https://")) return source;
|
|
344
|
+
if (isAbsolute(source)) {
|
|
345
|
+
const pkg = extractPackageNameFromPath(source);
|
|
346
|
+
if (pkg) return `npm:${pkg}`;
|
|
347
|
+
return sourceInfo.scope === "project" ? "项目本地" : "全局本地";
|
|
348
|
+
}
|
|
349
|
+
return source;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function sourceLabelForTool(sourceInfo: { source?: string; scope?: ResourceScope; origin?: string }): string {
|
|
353
|
+
const source = sourceInfo.source?.replace(/\\/g, "/");
|
|
354
|
+
if (!source) return "未知工具";
|
|
355
|
+
if (source === "builtin") return "内置工具";
|
|
356
|
+
if (source === "sdk") return "SDK 工具";
|
|
357
|
+
if (source === "local") return sourceInfo.scope === "project" ? "项目扩展" : "全局扩展";
|
|
358
|
+
if (sourceInfo.origin === "package") {
|
|
359
|
+
const pkg = extractPackageNameFromPath(source) ?? (source.startsWith("npm:") ? source.slice("npm:".length) : undefined);
|
|
360
|
+
return pkg ? `npm:${pkg}` : sourceLabelForPackage(source);
|
|
361
|
+
}
|
|
362
|
+
if (source.startsWith("npm:") || source.startsWith("git:") || source.startsWith("http://") || source.startsWith("https://")) return source;
|
|
363
|
+
if (isAbsolute(source)) {
|
|
364
|
+
const pkg = extractPackageNameFromPath(source);
|
|
365
|
+
if (pkg) return `npm:${pkg}`;
|
|
366
|
+
return sourceInfo.scope === "project" ? "项目扩展" : "全局扩展";
|
|
367
|
+
}
|
|
368
|
+
return source;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function sourceLabelForPackage(pkg: string): string {
|
|
372
|
+
if (pkg.startsWith("npm:")) return pkg;
|
|
373
|
+
if (pkg.startsWith("git:")) return "git";
|
|
374
|
+
if (pkg.startsWith("http://") || pkg.startsWith("https://")) return "git";
|
|
375
|
+
if (pkg.startsWith("./") || pkg.startsWith("../") || isAbsolute(pkg)) return "本地包";
|
|
376
|
+
return `npm:${pkg}`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function sourceLabelForMcp(server: McpServerEntry): string {
|
|
380
|
+
const bits = [server.mode === "http" ? "HTTP" : "stdio"];
|
|
381
|
+
if (server.lifecycle) bits.push(server.lifecycle);
|
|
382
|
+
return bits.join(" · ");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function upsertItem(state: HelpState, item: IndexedItem): IndexedItem {
|
|
386
|
+
const existing = state.items[item.key];
|
|
387
|
+
const merged: IndexedItem = {
|
|
388
|
+
...existing,
|
|
389
|
+
...item,
|
|
390
|
+
useCount: existing?.useCount ?? item.useCount ?? 0,
|
|
391
|
+
lastUsedAt: existing?.lastUsedAt ?? item.lastUsedAt,
|
|
392
|
+
lastSeenAt: existing?.lastSeenAt ?? item.lastSeenAt,
|
|
393
|
+
pinned: existing?.pinned ?? item.pinned ?? false,
|
|
394
|
+
};
|
|
395
|
+
state.items[item.key] = merged;
|
|
396
|
+
return merged;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function recordUsage(state: HelpState, key: string): void {
|
|
400
|
+
const item = state.items[key];
|
|
401
|
+
if (!item) return;
|
|
402
|
+
item.useCount = (item.useCount ?? 0) + 1;
|
|
403
|
+
item.lastUsedAt = Date.now();
|
|
404
|
+
item.lastSeenAt = Date.now();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function recordUsageByName(state: HelpState, kind: ResourceKind, name: string): void {
|
|
408
|
+
const matches = Object.values(state.items).filter((item) => item.kind === kind && item.name === name);
|
|
409
|
+
if (matches.length === 0) return;
|
|
410
|
+
const target = [...matches].sort(sortItems)[0];
|
|
411
|
+
if (!target) return;
|
|
412
|
+
recordUsage(state, target.key);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function syncSessionUsage(ctx: ExtensionContext, state: HelpState): void {
|
|
416
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
417
|
+
const entryType = (entry as { type?: string }).type;
|
|
418
|
+
if (entryType === "bashExecution") {
|
|
419
|
+
recordUsageByName(state, "tool", "bash");
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (entryType !== "message") continue;
|
|
424
|
+
const message = (entry as { message?: unknown }).message as {
|
|
425
|
+
role?: string;
|
|
426
|
+
toolName?: string;
|
|
427
|
+
content?: unknown;
|
|
428
|
+
} | undefined;
|
|
429
|
+
if (!message) continue;
|
|
430
|
+
|
|
431
|
+
if (message.role === "toolResult" && typeof message.toolName === "string") {
|
|
432
|
+
recordUsageByName(state, "tool", message.toolName);
|
|
433
|
+
if (message.toolName === "mcp") {
|
|
434
|
+
recordUsageByName(state, "mcp", "mcp");
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (message.role === "assistant" && Array.isArray(message.content)) {
|
|
439
|
+
for (const block of message.content) {
|
|
440
|
+
if (!block || typeof block !== "object") continue;
|
|
441
|
+
const toolCall = block as { type?: string; name?: string };
|
|
442
|
+
if (toolCall.type === "toolCall" && typeof toolCall.name === "string") {
|
|
443
|
+
recordUsageByName(state, "tool", toolCall.name);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function syncMcpUsageFromResult(state: HelpState, event: { toolName: string; input?: { server?: string } | undefined }): void {
|
|
451
|
+
if (event.toolName !== "mcp") return;
|
|
452
|
+
recordUsageByName(state, "mcp", "mcp");
|
|
453
|
+
if (event.input?.server) {
|
|
454
|
+
recordUsageByName(state, "mcp", event.input.server);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function pruneState(state: HelpState): void {
|
|
459
|
+
const items = Object.values(state.items);
|
|
460
|
+
if (items.length <= MAX_ITEMS) return;
|
|
461
|
+
|
|
462
|
+
const removable = items
|
|
463
|
+
.filter((item) => !item.pinned)
|
|
464
|
+
.sort((a, b) => {
|
|
465
|
+
if ((a.useCount ?? 0) !== (b.useCount ?? 0)) {
|
|
466
|
+
return (a.useCount ?? 0) - (b.useCount ?? 0);
|
|
467
|
+
}
|
|
468
|
+
return (a.lastUsedAt ?? 0) - (b.lastUsedAt ?? 0);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
while (Object.keys(state.items).length > MAX_ITEMS && removable.length > 0) {
|
|
472
|
+
const victim = removable.shift();
|
|
473
|
+
if (victim) {
|
|
474
|
+
delete state.items[victim.key];
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function sortItems(a: IndexedItem, b: IndexedItem): number {
|
|
480
|
+
if ((a.pinned ? 1 : 0) !== (b.pinned ? 1 : 0)) return (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0);
|
|
481
|
+
if ((a.useCount ?? 0) !== (b.useCount ?? 0)) return (b.useCount ?? 0) - (a.useCount ?? 0);
|
|
482
|
+
if ((a.lastUsedAt ?? 0) !== (b.lastUsedAt ?? 0)) return (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0);
|
|
483
|
+
return a.name.localeCompare(b.name);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function formatItem(item: IndexedItem): string {
|
|
487
|
+
const meta = item.sourceLabel && !isAbsolute(item.sourceLabel) ? [item.sourceLabel] : [];
|
|
488
|
+
if (item.scope && item.scope !== "builtin") {
|
|
489
|
+
meta.push(item.scope === "project" ? "项目" : item.scope === "user" ? "全局" : item.scope);
|
|
490
|
+
}
|
|
491
|
+
if ((item.useCount ?? 0) > 0) {
|
|
492
|
+
meta.push(`使用 ${item.useCount} 次`);
|
|
493
|
+
}
|
|
494
|
+
if (item.pinned) {
|
|
495
|
+
meta.push("已固定");
|
|
496
|
+
}
|
|
497
|
+
const suffix = meta.length > 0 ? ` · ${meta.join(" · ")}` : "";
|
|
498
|
+
const desc = item.description ? ` — ${item.description}` : "";
|
|
499
|
+
return `- **${item.name}**${desc}${suffix}`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function normalizeResourceIndex(pi: ExtensionAPI, ctx: ExtensionContext, state: HelpState): { commands: IndexedItem[]; tools: IndexedItem[]; mcp: IndexedItem[]; packages: IndexedItem[] } {
|
|
503
|
+
const commands = pi.getCommands();
|
|
504
|
+
const tools = pi.getAllTools();
|
|
505
|
+
const packages = mergePackageEntries([
|
|
506
|
+
...readPackagesFromSettings(join(getAgentDir(), "settings.json"), "user"),
|
|
507
|
+
...readPackagesFromSettings(getProjectSettingsPath(ctx.cwd), "project"),
|
|
508
|
+
]);
|
|
509
|
+
const mcpServers = [
|
|
510
|
+
...readMcpServersFromConfig(MCP_CONFIG_FILES[0]),
|
|
511
|
+
...readMcpServersFromConfig(MCP_CONFIG_FILES[1]),
|
|
512
|
+
...readMcpServersFromConfig(getProjectMcpPath(ctx.cwd)),
|
|
513
|
+
...readMcpServersFromConfig(getProjectPiMcpPath(ctx.cwd)),
|
|
514
|
+
];
|
|
515
|
+
|
|
516
|
+
const mergedMcp = new Map<string, McpServerEntry>();
|
|
517
|
+
for (const server of mcpServers) {
|
|
518
|
+
mergedMcp.set(server.name, server);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const commandItems: IndexedItem[] = [];
|
|
522
|
+
for (const command of commands) {
|
|
523
|
+
const sourceRef = compactSourceRef(command.sourceInfo.source ?? command.sourceInfo.origin ?? command.sourceInfo.scope);
|
|
524
|
+
const sourceLabel = sourceLabelForCommand(command.sourceInfo);
|
|
525
|
+
const key = makeKey("command", command.name, sourceRef);
|
|
526
|
+
commandItems.push(upsertItem(state, {
|
|
527
|
+
key,
|
|
528
|
+
kind: "command",
|
|
529
|
+
name: `/${command.name}`,
|
|
530
|
+
description: command.description,
|
|
531
|
+
sourceLabel,
|
|
532
|
+
sourceRef,
|
|
533
|
+
scope: command.sourceInfo.scope,
|
|
534
|
+
packageName: command.sourceInfo.source,
|
|
535
|
+
useCount: state.items[key]?.useCount ?? 0,
|
|
536
|
+
lastUsedAt: state.items[key]?.lastUsedAt,
|
|
537
|
+
lastSeenAt: state.items[key]?.lastSeenAt,
|
|
538
|
+
pinned: state.items[key]?.pinned,
|
|
539
|
+
}));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
for (const builtin of BUILTIN_COMMANDS) {
|
|
543
|
+
const sourceRef = "builtin";
|
|
544
|
+
const key = makeKey("command", builtin.name, sourceRef);
|
|
545
|
+
commandItems.push(upsertItem(state, {
|
|
546
|
+
key,
|
|
547
|
+
kind: "command",
|
|
548
|
+
name: `/${builtin.name}`,
|
|
549
|
+
description: builtin.description,
|
|
550
|
+
sourceLabel: "内置",
|
|
551
|
+
sourceRef,
|
|
552
|
+
scope: "builtin",
|
|
553
|
+
useCount: state.items[key]?.useCount ?? 0,
|
|
554
|
+
lastUsedAt: state.items[key]?.lastUsedAt,
|
|
555
|
+
lastSeenAt: state.items[key]?.lastSeenAt,
|
|
556
|
+
pinned: state.items[key]?.pinned,
|
|
557
|
+
}));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const toolItems: IndexedItem[] = [];
|
|
561
|
+
for (const tool of tools) {
|
|
562
|
+
if (tool.name === "mcp") continue;
|
|
563
|
+
const sourceRef = compactSourceRef(tool.sourceInfo.source ?? tool.sourceInfo.origin ?? tool.sourceInfo.scope);
|
|
564
|
+
const sourceLabel = sourceLabelForTool(tool.sourceInfo);
|
|
565
|
+
const key = makeKey("tool", tool.name, sourceRef);
|
|
566
|
+
toolItems.push(upsertItem(state, {
|
|
567
|
+
key,
|
|
568
|
+
kind: "tool",
|
|
569
|
+
name: tool.name,
|
|
570
|
+
description: tool.description,
|
|
571
|
+
sourceLabel,
|
|
572
|
+
sourceRef,
|
|
573
|
+
scope: tool.sourceInfo.scope,
|
|
574
|
+
packageName: tool.sourceInfo.source,
|
|
575
|
+
useCount: state.items[key]?.useCount ?? 0,
|
|
576
|
+
lastUsedAt: state.items[key]?.lastUsedAt,
|
|
577
|
+
lastSeenAt: state.items[key]?.lastSeenAt,
|
|
578
|
+
pinned: state.items[key]?.pinned,
|
|
579
|
+
}));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const packageItems: IndexedItem[] = [];
|
|
583
|
+
for (const pkg of packages) {
|
|
584
|
+
const sourceRef = compactSourceRef(pkg.name);
|
|
585
|
+
const sourceLabel = sourceLabelForPackage(pkg.name);
|
|
586
|
+
const key = makeKey("package", pkg.name, sourceRef);
|
|
587
|
+
packageItems.push(upsertItem(state, {
|
|
588
|
+
key,
|
|
589
|
+
kind: "package",
|
|
590
|
+
name: pkg.name,
|
|
591
|
+
description: "已安装包",
|
|
592
|
+
sourceLabel,
|
|
593
|
+
sourceRef,
|
|
594
|
+
scope: pkg.scope,
|
|
595
|
+
packageName: pkg.name,
|
|
596
|
+
useCount: state.items[key]?.useCount ?? 0,
|
|
597
|
+
lastUsedAt: state.items[key]?.lastUsedAt,
|
|
598
|
+
lastSeenAt: state.items[key]?.lastSeenAt,
|
|
599
|
+
pinned: state.items[key]?.pinned,
|
|
600
|
+
}));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const mcpItems: IndexedItem[] = [];
|
|
604
|
+
const gatewaySourceRef = "mcp:gateway";
|
|
605
|
+
const gatewayKey = makeKey("mcp", "mcp", gatewaySourceRef);
|
|
606
|
+
mcpItems.push(upsertItem(state, {
|
|
607
|
+
key: gatewayKey,
|
|
608
|
+
kind: "mcp",
|
|
609
|
+
name: "mcp",
|
|
610
|
+
description: "显示 MCP 服务器状态",
|
|
611
|
+
sourceLabel: "内置网关",
|
|
612
|
+
sourceRef: gatewaySourceRef,
|
|
613
|
+
scope: "builtin",
|
|
614
|
+
useCount: state.items[gatewayKey]?.useCount ?? 0,
|
|
615
|
+
lastUsedAt: state.items[gatewayKey]?.lastUsedAt,
|
|
616
|
+
lastSeenAt: state.items[gatewayKey]?.lastSeenAt,
|
|
617
|
+
pinned: state.items[gatewayKey]?.pinned,
|
|
618
|
+
}));
|
|
619
|
+
for (const server of mergedMcp.values()) {
|
|
620
|
+
const sourceRef = `mcp:${server.name}`;
|
|
621
|
+
const sourceLabel = sourceLabelForMcp(server);
|
|
622
|
+
const key = makeKey("mcp", server.name, sourceRef);
|
|
623
|
+
mcpItems.push(upsertItem(state, {
|
|
624
|
+
key,
|
|
625
|
+
kind: "mcp",
|
|
626
|
+
name: server.name,
|
|
627
|
+
description: server.url ? server.url : server.command ? server.command : "MCP 服务器",
|
|
628
|
+
sourceLabel,
|
|
629
|
+
sourceRef,
|
|
630
|
+
scope: "user",
|
|
631
|
+
packageName: server.command ?? server.url,
|
|
632
|
+
useCount: state.items[key]?.useCount ?? 0,
|
|
633
|
+
lastUsedAt: state.items[key]?.lastUsedAt,
|
|
634
|
+
lastSeenAt: state.items[key]?.lastSeenAt,
|
|
635
|
+
pinned: state.items[key]?.pinned,
|
|
636
|
+
}));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
pruneState(state);
|
|
640
|
+
state.lastRefresh = Date.now();
|
|
641
|
+
saveJson(STATE_FILE, state);
|
|
642
|
+
|
|
643
|
+
return { commands: commandItems, tools: toolItems, mcp: mcpItems, packages: packageItems };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function selectTop(items: IndexedItem[], count: number): IndexedItem[] {
|
|
647
|
+
return [...items].sort(sortItems).slice(0, count);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function renderSection(title: string, items: IndexedItem[], emptyText: string): string {
|
|
651
|
+
if (items.length === 0) {
|
|
652
|
+
return [`## ${title}`, `- ${emptyText}`].join("\n");
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return [`## ${title}`, items.map(formatItem).join("\n")].join("\n");
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export function buildMarkdown(pi: ExtensionAPI, ctx: ExtensionContext, state: HelpState, query?: string): string {
|
|
659
|
+
const index = normalizeResourceIndex(pi, ctx, state);
|
|
660
|
+
const normalizedQuery = normalize(query);
|
|
661
|
+
|
|
662
|
+
const allVisible = [
|
|
663
|
+
...index.commands,
|
|
664
|
+
...index.tools,
|
|
665
|
+
...index.mcp,
|
|
666
|
+
...index.packages,
|
|
667
|
+
].filter((item) => {
|
|
668
|
+
if (!normalizedQuery) return true;
|
|
669
|
+
return normalize([item.name, item.description, item.sourceLabel, item.packageName, item.sourceRef, item.kind].join(" ")).includes(normalizedQuery);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
const commonCommands = selectTop(allVisible.filter((item) => item.kind === "command"), MAX_COMMON);
|
|
673
|
+
const commonTools = selectTop(allVisible.filter((item) => item.kind === "tool"), MAX_COMMON);
|
|
674
|
+
const commonMcp = selectTop(allVisible.filter((item) => item.kind === "mcp"), MAX_COMMON);
|
|
675
|
+
const commonPackages = selectTop(allVisible.filter((item) => item.kind === "package"), MAX_COMMON);
|
|
676
|
+
|
|
677
|
+
const commandItems = selectTop(allVisible.filter((item) => item.kind === "command"), MAX_SECTION);
|
|
678
|
+
const toolItems = selectTop(allVisible.filter((item) => item.kind === "tool"), MAX_SECTION);
|
|
679
|
+
const mcpItems = selectTop(allVisible.filter((item) => item.kind === "mcp"), MAX_SECTION);
|
|
680
|
+
const packageItems = selectTop(allVisible.filter((item) => item.kind === "package"), MAX_SECTION);
|
|
681
|
+
|
|
682
|
+
const gatewayItems = mcpItems.filter((item) => item.name === "mcp");
|
|
683
|
+
const serverItems = mcpItems.filter((item) => item.name !== "mcp");
|
|
684
|
+
|
|
685
|
+
const updatedAt = state.lastRefresh ? new Date(state.lastRefresh).toLocaleString() : new Date().toLocaleString();
|
|
686
|
+
const title = normalizedQuery ? `动态帮助 · 搜索:${query?.trim()}` : "动态帮助";
|
|
687
|
+
|
|
688
|
+
const mcpUsageLines = [
|
|
689
|
+
"### 用法",
|
|
690
|
+
"- `mcp({})` 查看状态",
|
|
691
|
+
"- `mcp({ server: \"chrome-devtools\" })` 列出服务器工具",
|
|
692
|
+
"- `mcp({ search: \"screenshot\" })` 搜索工具",
|
|
693
|
+
"- `mcp({ tool: \"...\", args: '{\"...\"}' })` 调用工具",
|
|
694
|
+
].join("\n");
|
|
695
|
+
|
|
696
|
+
return [
|
|
697
|
+
`# ${title}`,
|
|
698
|
+
`_更新时间:${updatedAt}_`,
|
|
699
|
+
"",
|
|
700
|
+
`> 已加载:${index.commands.length} 条命令 · ${index.tools.length} 个工具 · ${index.mcp.length} 个 MCP 服务器 · ${index.packages.length} 个包`,
|
|
701
|
+
"",
|
|
702
|
+
renderSection("常用命令", commonCommands, "暂无"),
|
|
703
|
+
"",
|
|
704
|
+
renderSection("常用工具", commonTools, "暂无"),
|
|
705
|
+
"",
|
|
706
|
+
renderSection("常用 MCP 服务器", commonMcp, "暂无"),
|
|
707
|
+
"",
|
|
708
|
+
renderSection("常用包", commonPackages, "暂无"),
|
|
709
|
+
"",
|
|
710
|
+
"## 命令",
|
|
711
|
+
commandItems.length > 0 ? commandItems.map(formatItem).join("\n") : "- 暂无",
|
|
712
|
+
"",
|
|
713
|
+
"## 工具",
|
|
714
|
+
toolItems.length > 0 ? toolItems.map(formatItem).join("\n") : "- 暂无",
|
|
715
|
+
"",
|
|
716
|
+
"## MCP 服务器",
|
|
717
|
+
"### 网关",
|
|
718
|
+
gatewayItems.length > 0 ? gatewayItems.map(formatItem).join("\n") : "- 暂无",
|
|
719
|
+
"",
|
|
720
|
+
mcpUsageLines,
|
|
721
|
+
"",
|
|
722
|
+
"### 服务器",
|
|
723
|
+
serverItems.length > 0 ? serverItems.map(formatItem).join("\n") : "- 暂无",
|
|
724
|
+
"",
|
|
725
|
+
"## 包",
|
|
726
|
+
packageItems.length > 0 ? packageItems.map(formatItem).join("\n") : "- 暂无",
|
|
727
|
+
"",
|
|
728
|
+
"## 操作",
|
|
729
|
+
"- `/help search <词>` 搜索当前索引",
|
|
730
|
+
"- `/help pin <词>` 固定匹配项",
|
|
731
|
+
"- `/help unpin <词>` 取消固定",
|
|
732
|
+
"- `/help refresh` 重新扫描并刷新使用统计",
|
|
733
|
+
"",
|
|
734
|
+
"## 示例",
|
|
735
|
+
"- `需求分析:...`",
|
|
736
|
+
"- `系统设计:...`",
|
|
737
|
+
"- `实现:...`",
|
|
738
|
+
"- `审查:...`",
|
|
739
|
+
"- `/goal ...`",
|
|
740
|
+
"- `/brainstorm ...`",
|
|
741
|
+
].join("\n");
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function showMarkdownPanel(ctx: ExtensionContext, markdown: string): Promise<void> {
|
|
745
|
+
if (!ctx.hasUI) {
|
|
746
|
+
console.log(markdown);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
await ctx.ui.custom((_tui, theme, _kb, done) => {
|
|
751
|
+
const container = new Container();
|
|
752
|
+
const border = new DynamicBorder((s: string) => theme.fg("accent", s));
|
|
753
|
+
const mdTheme = getMarkdownTheme();
|
|
754
|
+
|
|
755
|
+
container.addChild(border);
|
|
756
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Pi 帮助")), 1, 0));
|
|
757
|
+
container.addChild(new Markdown(markdown, 1, 1, mdTheme));
|
|
758
|
+
container.addChild(new Text(theme.fg("dim", "按 Enter 或 Esc 关闭"), 1, 0));
|
|
759
|
+
container.addChild(border);
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
render: (width: number) => container.render(width),
|
|
763
|
+
invalidate: () => container.invalidate(),
|
|
764
|
+
handleInput: (data: string) => {
|
|
765
|
+
if (matchesKey(data, "enter") || matchesKey(data, "escape")) {
|
|
766
|
+
done(undefined);
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
};
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function findMatches(state: HelpState, query: string): IndexedItem[] {
|
|
774
|
+
const normalizedQuery = normalize(query);
|
|
775
|
+
if (!normalizedQuery) return [];
|
|
776
|
+
return Object.values(state.items)
|
|
777
|
+
.filter((item) => normalize([item.name, item.description, item.sourceLabel, item.packageName, item.sourceRef, item.kind].join(" ")).includes(normalizedQuery))
|
|
778
|
+
.sort(sortItems);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function refreshState(pi: ExtensionAPI, ctx: ExtensionContext, state: HelpState): void {
|
|
782
|
+
syncSessionUsage(ctx, state);
|
|
783
|
+
// Make sure MCP/package caches are always rebuilt on refresh.
|
|
784
|
+
normalizeResourceIndex(pi, ctx, state);
|
|
785
|
+
saveJson(STATE_FILE, state);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export default function (pi: ExtensionAPI) {
|
|
789
|
+
const state = loadState();
|
|
790
|
+
|
|
791
|
+
pi.registerCommand("help", {
|
|
792
|
+
description: "Show a dynamic help dashboard for installed Pi resources",
|
|
793
|
+
handler: async (args, ctx) => {
|
|
794
|
+
const trimmed = args.trim();
|
|
795
|
+
const [subcommand, ...rest] = trimmed.length > 0 ? trimmed.split(/\s+/) : [""];
|
|
796
|
+
const query = rest.join(" ").trim();
|
|
797
|
+
|
|
798
|
+
if (subcommand === "refresh") {
|
|
799
|
+
refreshState(pi, ctx, state);
|
|
800
|
+
ctx.ui.notify("帮助索引已刷新", "info");
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (subcommand === "pin" || subcommand === "unpin") {
|
|
805
|
+
refreshState(pi, ctx, state);
|
|
806
|
+
const matches = findMatches(state, query);
|
|
807
|
+
if (matches.length === 0) {
|
|
808
|
+
ctx.ui.notify(`未找到:${query || "<空>"}`, "warning");
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const target = matches[0];
|
|
813
|
+
target.pinned = subcommand === "pin";
|
|
814
|
+
target.lastSeenAt = Date.now();
|
|
815
|
+
saveJson(STATE_FILE, state);
|
|
816
|
+
ctx.ui.notify(`${subcommand === "pin" ? "已固定" : "已取消固定"}:${target.name} (${target.kind} · ${target.sourceLabel})`, "info");
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
refreshState(pi, ctx, state);
|
|
821
|
+
const markdown = buildMarkdown(pi, ctx, state, subcommand === "search" ? query : trimmed);
|
|
822
|
+
await showMarkdownPanel(ctx, markdown);
|
|
823
|
+
},
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
827
|
+
refreshState(pi, ctx, state);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
pi.on("tool_result", async (event) => {
|
|
831
|
+
if (event.isError) {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
recordUsageByName(state, "tool", event.toolName);
|
|
836
|
+
syncMcpUsageFromResult(state, event as { toolName: string; input?: { server?: string } });
|
|
837
|
+
pruneState(state);
|
|
838
|
+
saveJson(STATE_FILE, state);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
pi.on("session_shutdown", async () => {
|
|
842
|
+
pruneState(state);
|
|
843
|
+
saveJson(STATE_FILE, state);
|
|
844
|
+
});
|
|
845
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-dynamic-help",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Dynamic /help dashboard for Pi commands, tools, MCP servers, and packages.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi",
|
|
9
|
+
"help",
|
|
10
|
+
"extension"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "Mikumiiku",
|
|
14
|
+
"files": [
|
|
15
|
+
"extensions",
|
|
16
|
+
"prompts",
|
|
17
|
+
"README.md",
|
|
18
|
+
"CHANGELOG.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"check": "tsc --noEmit",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"pack:dry": "npm pack --dry-run"
|
|
25
|
+
},
|
|
26
|
+
"pi": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./extensions/help.ts"
|
|
29
|
+
],
|
|
30
|
+
"prompts": [
|
|
31
|
+
"./prompts/help-brief.md"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
36
|
+
"@earendil-works/pi-tui": "*"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": "^0.78.0",
|
|
40
|
+
"@earendil-works/pi-tui": "^0.78.0",
|
|
41
|
+
"@types/node": "^24.12.4",
|
|
42
|
+
"typescript": "^5.9.3",
|
|
43
|
+
"vitest": "^3.2.4"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22.19.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Brief fallback help for Pi
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Give a concise overview of the current Pi setup.
|
|
6
|
+
|
|
7
|
+
Prioritize:
|
|
8
|
+
- the most relevant slash commands
|
|
9
|
+
- core built-in tools that are likely useful right now
|
|
10
|
+
- MCP servers and their usage patterns
|
|
11
|
+
- installed packages that are likely useful right now
|
|
12
|
+
- the shortest examples for asking for brainstorming, planning, implementation, review, and goal-driven work
|
|
13
|
+
|
|
14
|
+
Use Chinese section headings with English aliases in parentheses only when helpful.
|
|
15
|
+
Keep it short, direct, and organized under technical headings: Commands, Tools, MCP Servers, Packages.
|