pi-dynamic-help 0.1.0 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-dynamic-help",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Dynamic /help dashboard for Pi commands, tools, MCP servers, and packages.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -13,6 +13,7 @@
13
13
  "author": "Mikumiiku",
14
14
  "files": [
15
15
  "extensions",
16
+ "src",
16
17
  "prompts",
17
18
  "README.md",
18
19
  "CHANGELOG.md",
@@ -32,8 +33,8 @@
32
33
  ]
33
34
  },
34
35
  "peerDependencies": {
35
- "@earendil-works/pi-coding-agent": "*",
36
- "@earendil-works/pi-tui": "*"
36
+ "@earendil-works/pi-coding-agent": ">=0.78.0 <1",
37
+ "@earendil-works/pi-tui": ">=0.78.0 <1"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@earendil-works/pi-coding-agent": "^0.78.0",
@@ -0,0 +1,26 @@
1
+ export const PACKAGE_NAME = "pi-dynamic-help";
2
+ export const STATE_VERSION = 3;
3
+ export const MAX_ITEMS = 250;
4
+ export const MAX_COMMON = 8;
5
+ export const MAX_SECTION = 16;
6
+
7
+ export const BUILTIN_COMMANDS: Array<{ name: string; description: string }> = [
8
+ { name: "model", description: "Switch models" },
9
+ { name: "scoped-models", description: "Enable or disable models for cycling" },
10
+ { name: "settings", description: "Open interactive settings" },
11
+ { name: "resume", description: "Pick from previous sessions" },
12
+ { name: "new", description: "Start a new session" },
13
+ { name: "name", description: "Set session display name" },
14
+ { name: "session", description: "Show session file, messages, tokens, and cost" },
15
+ { name: "tree", description: "Navigate the current session tree" },
16
+ { name: "fork", description: "Create a new session from an earlier user message" },
17
+ { name: "clone", description: "Duplicate the current active branch" },
18
+ { name: "compact", description: "Summarize older context" },
19
+ { name: "copy", description: "Copy the last assistant message" },
20
+ { name: "export", description: "Export the session to HTML" },
21
+ { name: "share", description: "Upload the session as a private gist" },
22
+ { name: "reload", description: "Reload extensions, skills, prompts, and context files" },
23
+ { name: "hotkeys", description: "Show keyboard shortcuts" },
24
+ { name: "changelog", description: "Display version history" },
25
+ { name: "quit", description: "Quit Pi" },
26
+ ];
@@ -0,0 +1,154 @@
1
+ import { homedir } from "node:os";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { DynamicBorder, getAgentDir, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
4
+ import { Container, Markdown, matchesKey, Text } from "@earendil-works/pi-tui";
5
+ import { buildResourceIndex, readRuntimeMcpServers, readRuntimePackages } from "./resources/index.js";
6
+ import { buildMarkdown } from "./render/markdown.js";
7
+ import { createStatePaths, loadState, saveState } from "./state/store.js";
8
+ import { findMatches, pruneState, recordUsageByName } from "./state/usage.js";
9
+ import type { HelpState, ResourceIndex } from "./types.js";
10
+
11
+ async function showMarkdownPanel(ctx: ExtensionContext, markdown: string): Promise<void> {
12
+ if (!ctx.hasUI) {
13
+ console.log(markdown);
14
+ return;
15
+ }
16
+
17
+ await ctx.ui.custom((_tui, theme, _kb, done) => {
18
+ const container = new Container();
19
+ const border = new DynamicBorder((s: string) => theme.fg("accent", s));
20
+ const mdTheme = getMarkdownTheme();
21
+
22
+ container.addChild(border);
23
+ container.addChild(new Text(theme.fg("accent", theme.bold("Pi 帮助")), 1, 0));
24
+ container.addChild(new Markdown(markdown, 1, 1, mdTheme));
25
+ container.addChild(new Text(theme.fg("dim", "按 Enter 或 Esc 关闭"), 1, 0));
26
+ container.addChild(border);
27
+
28
+ return {
29
+ render: (width: number) => container.render(width),
30
+ invalidate: () => container.invalidate(),
31
+ handleInput: (data: string) => {
32
+ if (matchesKey(data, "enter") || matchesKey(data, "escape")) done(undefined);
33
+ },
34
+ };
35
+ });
36
+ }
37
+
38
+ function syncSessionUsage(ctx: ExtensionContext, state: HelpState): void {
39
+ for (const entry of ctx.sessionManager.getBranch()) {
40
+ const entryType = (entry as { type?: string }).type;
41
+ if (entryType === "bashExecution") {
42
+ recordUsageByName(state, "tool", "bash");
43
+ continue;
44
+ }
45
+
46
+ if (entryType !== "message") continue;
47
+ const message = (entry as { message?: unknown }).message as { role?: string; toolName?: string; content?: unknown } | undefined;
48
+ if (!message) continue;
49
+
50
+ if (message.role === "toolResult" && typeof message.toolName === "string") {
51
+ recordUsageByName(state, "tool", message.toolName);
52
+ if (message.toolName === "mcp") recordUsageByName(state, "mcp", "mcp");
53
+ }
54
+
55
+ if (message.role === "assistant" && Array.isArray(message.content)) {
56
+ for (const block of message.content) {
57
+ if (!block || typeof block !== "object") continue;
58
+ const toolCall = block as { type?: string; name?: string };
59
+ if (toolCall.type === "toolCall" && typeof toolCall.name === "string") recordUsageByName(state, "tool", toolCall.name);
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ function notifySaveWarning(ctx: ExtensionContext, warning: string | undefined): void {
66
+ if (warning) ctx.ui.notify(warning, "warning");
67
+ }
68
+
69
+ export function registerHelpExtension(pi: ExtensionAPI): void {
70
+ const paths = createStatePaths(getAgentDir());
71
+ const loaded = loadState(paths);
72
+ const state = loaded.state;
73
+
74
+ function rebuildIndex(ctx: ExtensionContext): ResourceIndex {
75
+ syncSessionUsage(ctx, state);
76
+ const index = buildResourceIndex({
77
+ commands: pi.getCommands(),
78
+ tools: pi.getAllTools(),
79
+ packages: readRuntimePackages(getAgentDir(), ctx.cwd),
80
+ mcpServers: readRuntimeMcpServers(getAgentDir(), homedir(), ctx.cwd),
81
+ state,
82
+ });
83
+ pruneState(state);
84
+ return index;
85
+ }
86
+
87
+ function persist(ctx: ExtensionContext): void {
88
+ const result = saveState(paths, state);
89
+ notifySaveWarning(ctx, result.warning);
90
+ }
91
+
92
+ pi.registerCommand("help", {
93
+ description: "Show a dynamic help dashboard for installed Pi resources",
94
+ handler: async (args, ctx) => {
95
+ for (const warning of loaded.warnings) ctx.ui.notify(warning, "warning");
96
+ loaded.warnings.length = 0;
97
+
98
+ const trimmed = args.trim();
99
+ const [subcommand, ...rest] = trimmed.length > 0 ? trimmed.split(/\s+/) : [""];
100
+ const query = rest.join(" ").trim();
101
+
102
+ if (subcommand === "refresh") {
103
+ rebuildIndex(ctx);
104
+ persist(ctx);
105
+ ctx.ui.notify("帮助索引已刷新", "info");
106
+ return;
107
+ }
108
+
109
+ if (subcommand === "pin" || subcommand === "unpin") {
110
+ rebuildIndex(ctx);
111
+ const matches = findMatches(state, query);
112
+ if (matches.length === 0) {
113
+ ctx.ui.notify(`未找到:${query || "<空>"}`, "warning");
114
+ persist(ctx);
115
+ return;
116
+ }
117
+
118
+ const target = matches[0];
119
+ target.pinned = subcommand === "pin";
120
+ target.lastSeenAt = Date.now();
121
+ persist(ctx);
122
+ ctx.ui.notify(`${subcommand === "pin" ? "已固定" : "已取消固定"}:${target.displayName ?? target.name} (${target.kind} · ${target.sourceLabel})`, "info");
123
+ return;
124
+ }
125
+
126
+ const index = rebuildIndex(ctx);
127
+ persist(ctx);
128
+ const markdown = buildMarkdown(index, state.lastRefresh, subcommand === "search" ? query : trimmed);
129
+ await showMarkdownPanel(ctx, markdown);
130
+ },
131
+ });
132
+
133
+ pi.on("session_start", async (_event, ctx) => {
134
+ rebuildIndex(ctx);
135
+ persist(ctx);
136
+ });
137
+
138
+ pi.on("tool_result", async (event, ctx) => {
139
+ if (event.isError) return;
140
+ recordUsageByName(state, "tool", event.toolName);
141
+ if (event.toolName === "mcp") {
142
+ recordUsageByName(state, "mcp", "mcp");
143
+ const maybeInput = event as { input?: { server?: string } };
144
+ if (maybeInput.input?.server) recordUsageByName(state, "mcp", maybeInput.input.server);
145
+ }
146
+ pruneState(state);
147
+ persist(ctx);
148
+ });
149
+
150
+ pi.on("session_shutdown", async (_event, ctx) => {
151
+ pruneState(state);
152
+ persist(ctx);
153
+ });
154
+ }
@@ -0,0 +1,92 @@
1
+ import { homedir } from "node:os";
2
+ import { basename, isAbsolute, relative } from "node:path";
3
+ import { PACKAGE_NAME } from "./constants.js";
4
+ import type { ResourceKind } from "./types.js";
5
+
6
+ const COMPACT_PREFIXES = ["local:", "project:", "pkg:", "mcp:", "npm:", "git:", "http://", "https://"];
7
+
8
+ export function normalizeSearch(value: string | undefined): string {
9
+ return (value ?? "")
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9\u4e00-\u9fff]+/g, " ")
12
+ .trim();
13
+ }
14
+
15
+ export function normalizeCommandName(name: string): string {
16
+ return name.replace(/^\/+/, "");
17
+ }
18
+
19
+ export function displayCommandName(name: string): string {
20
+ return `/${normalizeCommandName(name)}`;
21
+ }
22
+
23
+ export function canonicalName(kind: ResourceKind, name: string): string {
24
+ return kind === "command" ? normalizeCommandName(name) : name;
25
+ }
26
+
27
+ export function displayName(kind: ResourceKind, name: string): string {
28
+ return kind === "command" ? displayCommandName(name) : name;
29
+ }
30
+
31
+ export function extractPackageNameFromPath(source: string): string | undefined {
32
+ const match = source.replace(/\\/g, "/").match(/\/node_modules\/(.*)$/);
33
+ if (!match) return undefined;
34
+
35
+ const parts = match[1].split("/").filter(Boolean);
36
+ if (parts.length === 0) return undefined;
37
+ if (parts[0].startsWith("@")) {
38
+ return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0];
39
+ }
40
+
41
+ return parts[0];
42
+ }
43
+
44
+ function stripRepeatedLocalPrefixes(value: string): string {
45
+ let next = value;
46
+ while (next.startsWith("local:local:")) {
47
+ next = next.slice("local:".length);
48
+ }
49
+ return next;
50
+ }
51
+
52
+ function unwrapLocalWrappedCompactRef(value: string): string {
53
+ if (!value.startsWith("local:")) return value;
54
+
55
+ const inner = value.slice("local:".length);
56
+ if (inner === "builtin" || inner === "sdk" || inner === "unknown") return inner;
57
+ if (COMPACT_PREFIXES.filter((prefix) => prefix !== "local:").some((prefix) => inner.startsWith(prefix))) {
58
+ return inner;
59
+ }
60
+ return value;
61
+ }
62
+
63
+ function isOwnPackageSource(source: string): boolean {
64
+ return source.replace(/\\/g, "/").includes(PACKAGE_NAME);
65
+ }
66
+
67
+ export function compactSourceRef(source?: string): string {
68
+ if (!source) return "unknown";
69
+
70
+ const normalized = unwrapLocalWrappedCompactRef(stripRepeatedLocalPrefixes(source.replace(/\\/g, "/")));
71
+ if (isOwnPackageSource(normalized)) return `pkg:${PACKAGE_NAME}`;
72
+ if (normalized === "builtin" || normalized === "sdk" || normalized === "unknown") return normalized;
73
+ if (COMPACT_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return normalized;
74
+
75
+ const packageName = extractPackageNameFromPath(normalized);
76
+ if (packageName) return `pkg:${packageName}`;
77
+
78
+ if (isAbsolute(normalized)) {
79
+ const homeRelative = relative(homedir(), normalized).replace(/\\/g, "/");
80
+ if (homeRelative && !homeRelative.startsWith("..")) {
81
+ return `local:${homeRelative}`;
82
+ }
83
+
84
+ return `local:${basename(normalized)}`;
85
+ }
86
+
87
+ return `local:${normalized}`;
88
+ }
89
+
90
+ export function makeItemKey(kind: ResourceKind, name: string, sourceRef: string): string {
91
+ return `${kind}:${sourceRef}:${canonicalName(kind, name)}`;
92
+ }
@@ -0,0 +1,97 @@
1
+ import { MAX_COMMON, MAX_SECTION } from "../constants.js";
2
+ import { normalizeSearch } from "../identity.js";
3
+ import { sortItems } from "../state/usage.js";
4
+ import type { IndexedItem, ResourceIndex } from "../types.js";
5
+
6
+ export function escapeMarkdownInline(value: string | undefined): string {
7
+ return (value ?? "").replace(/([\\`*_{}\[\]()#+\-.!|>])/g, "\\$1");
8
+ }
9
+
10
+ function itemTitle(item: IndexedItem): string {
11
+ return escapeMarkdownInline(item.displayName ?? item.name);
12
+ }
13
+
14
+ function formatItem(item: IndexedItem): string {
15
+ const meta = item.sourceLabel ? [escapeMarkdownInline(item.sourceLabel)] : [];
16
+ if (item.scope && item.scope !== "builtin") meta.push(escapeMarkdownInline(item.scope === "project" ? "项目" : item.scope === "user" ? "全局" : item.scope));
17
+ if ((item.useCount ?? 0) > 0) meta.push(`使用 ${item.useCount} 次`);
18
+ if (item.pinned) meta.push("已固定");
19
+ const suffix = meta.length > 0 ? ` · ${meta.join(" · ")}` : "";
20
+ const desc = item.description ? ` — ${escapeMarkdownInline(item.description)}` : "";
21
+ return `- **${itemTitle(item)}**${desc}${suffix}`;
22
+ }
23
+
24
+ function selectTop(items: IndexedItem[], count: number): IndexedItem[] {
25
+ return [...items].sort(sortItems).slice(0, count);
26
+ }
27
+
28
+ function renderSection(title: string, items: IndexedItem[], emptyText: string): string {
29
+ if (items.length === 0) return [`## ${title}`, `- ${emptyText}`].join("\n");
30
+ return [`## ${title}`, items.map(formatItem).join("\n")].join("\n");
31
+ }
32
+
33
+ export function filterIndex(index: ResourceIndex, query?: string): IndexedItem[] {
34
+ const normalizedQuery = normalizeSearch(query);
35
+ return [...index.commands, ...index.tools, ...index.mcp, ...index.packages].filter((item) => {
36
+ if (!normalizedQuery) return true;
37
+ return normalizeSearch([item.name, item.displayName, item.description, item.sourceLabel, item.packageName, item.sourceRef, item.kind].join(" ")).includes(normalizedQuery);
38
+ });
39
+ }
40
+
41
+ export function buildMarkdown(index: ResourceIndex, updatedAt: number | undefined, query?: string): string {
42
+ const normalizedQuery = normalizeSearch(query);
43
+ const allVisible = filterIndex(index, query);
44
+
45
+ const commonCommands = selectTop(allVisible.filter((item) => item.kind === "command"), MAX_COMMON);
46
+ const commonTools = selectTop(allVisible.filter((item) => item.kind === "tool"), MAX_COMMON);
47
+ const commonMcp = selectTop(allVisible.filter((item) => item.kind === "mcp"), MAX_COMMON);
48
+ const commonPackages = selectTop(allVisible.filter((item) => item.kind === "package"), MAX_COMMON);
49
+
50
+ const commandItems = selectTop(allVisible.filter((item) => item.kind === "command"), MAX_SECTION);
51
+ const toolItems = selectTop(allVisible.filter((item) => item.kind === "tool"), MAX_SECTION);
52
+ const mcpItems = selectTop(allVisible.filter((item) => item.kind === "mcp"), MAX_SECTION);
53
+ const packageItems = selectTop(allVisible.filter((item) => item.kind === "package"), MAX_SECTION);
54
+
55
+ const gatewayItems = mcpItems.filter((item) => item.name === "mcp");
56
+ const serverItems = mcpItems.filter((item) => item.name !== "mcp");
57
+
58
+ const updated = updatedAt ? new Date(updatedAt).toLocaleString() : new Date().toLocaleString();
59
+ const title = normalizedQuery ? `动态帮助 · 搜索:${escapeMarkdownInline(query?.trim())}` : "动态帮助";
60
+
61
+ return [
62
+ `# ${title}`,
63
+ `_更新时间:${updated}_`,
64
+ "",
65
+ `> 已加载:${index.commands.length} 条命令 · ${index.tools.length} 个工具 · ${index.mcp.length} 个 MCP 服务器 · ${index.packages.length} 个包`,
66
+ "",
67
+ renderSection("常用命令", commonCommands, "暂无"),
68
+ "",
69
+ renderSection("常用工具", commonTools, "暂无"),
70
+ "",
71
+ renderSection("常用 MCP 服务器", commonMcp, "暂无"),
72
+ "",
73
+ renderSection("常用包", commonPackages, "暂无"),
74
+ "",
75
+ "## 命令",
76
+ commandItems.length > 0 ? commandItems.map(formatItem).join("\n") : "- 暂无",
77
+ "",
78
+ "## 工具",
79
+ toolItems.length > 0 ? toolItems.map(formatItem).join("\n") : "- 暂无",
80
+ "",
81
+ "## MCP 服务器",
82
+ "### 网关",
83
+ gatewayItems.length > 0 ? gatewayItems.map(formatItem).join("\n") : "- 暂无",
84
+ "",
85
+ "### 服务器",
86
+ serverItems.length > 0 ? serverItems.map(formatItem).join("\n") : "- 暂无",
87
+ "",
88
+ "## 包",
89
+ packageItems.length > 0 ? packageItems.map(formatItem).join("\n") : "- 暂无",
90
+ "",
91
+ "## 操作",
92
+ "- `/help search <词>` 搜索当前索引",
93
+ "- `/help pin <词>` 固定匹配项",
94
+ "- `/help unpin <词>` 取消固定",
95
+ "- `/help refresh` 重新扫描并刷新使用统计",
96
+ ].join("\n");
97
+ }
@@ -0,0 +1,201 @@
1
+ import { join } from "node:path";
2
+ import { BUILTIN_COMMANDS } from "../constants.js";
3
+ import { canonicalName, compactSourceRef, displayName, extractPackageNameFromPath, makeItemKey } from "../identity.js";
4
+ import type { HelpState, IndexedItem, McpServerEntry, PackageEntry, ResourceIndex, ResourceScope } from "../types.js";
5
+ import { upsertItem } from "../state/usage.js";
6
+ import { mergePackageEntries, readPackagesFromSettings } from "./settings.js";
7
+ import { readMcpServersFromConfig } from "./mcp.js";
8
+
9
+ export interface CommandLike {
10
+ name: string;
11
+ description?: string;
12
+ sourceInfo: { source?: string; scope?: ResourceScope; origin?: string };
13
+ }
14
+
15
+ export interface ToolLike {
16
+ name: string;
17
+ description?: string;
18
+ sourceInfo: { source?: string; scope?: ResourceScope; origin?: string };
19
+ }
20
+
21
+ export interface ResourceRuntimeInput {
22
+ commands: CommandLike[];
23
+ tools: ToolLike[];
24
+ packages: PackageEntry[];
25
+ mcpServers: McpServerEntry[];
26
+ state: HelpState;
27
+ now?: number;
28
+ }
29
+
30
+ function sourceLabelForPackage(pkg: string): string {
31
+ if (pkg.startsWith("npm:")) return pkg;
32
+ if (pkg.startsWith("git:")) return "git";
33
+ if (pkg.startsWith("http://") || pkg.startsWith("https://")) return "git";
34
+ if (pkg.startsWith("./") || pkg.startsWith("../") || pkg.startsWith("/")) return "本地包";
35
+ return `npm:${pkg}`;
36
+ }
37
+
38
+ function packageLabelFromSource(source: string): string {
39
+ const pkg = extractPackageNameFromPath(source) ?? (source.startsWith("npm:") ? source.slice("npm:".length) : undefined);
40
+ return pkg ? `npm:${pkg}` : sourceLabelForPackage(source);
41
+ }
42
+
43
+ function sourceLabelForCommand(sourceInfo: { source?: string; scope?: ResourceScope; origin?: string }): string {
44
+ const source = sourceInfo.source?.replace(/\\/g, "/");
45
+ if (!source) return "未知";
46
+ if (source === "builtin") return "内置";
47
+ if (source === "sdk") return "SDK";
48
+ if (source === "local") return sourceInfo.scope === "project" ? "项目本地" : "全局本地";
49
+ if (sourceInfo.origin === "package") return packageLabelFromSource(source);
50
+ if (source === "extension") return "扩展";
51
+ if (source === "prompt") return "模板";
52
+ if (source === "skill") return "技能";
53
+ if (source.startsWith("npm:") || source.startsWith("git:") || source.startsWith("http://") || source.startsWith("https://")) return source;
54
+ if (source.startsWith("./") || source.startsWith("../")) return sourceInfo.scope === "project" ? "项目本地" : "本地包";
55
+ if (source.startsWith("/")) {
56
+ const pkg = extractPackageNameFromPath(source);
57
+ if (pkg) return `npm:${pkg}`;
58
+ return sourceInfo.scope === "project" ? "项目本地" : "全局本地";
59
+ }
60
+ return source;
61
+ }
62
+
63
+ function sourceLabelForTool(sourceInfo: { source?: string; scope?: ResourceScope; origin?: string }): string {
64
+ const source = sourceInfo.source?.replace(/\\/g, "/");
65
+ if (!source) return "未知工具";
66
+ if (source === "builtin") return "内置工具";
67
+ if (source === "sdk") return "SDK 工具";
68
+ if (source === "local") return sourceInfo.scope === "project" ? "项目扩展" : "全局扩展";
69
+ if (sourceInfo.origin === "package") return packageLabelFromSource(source);
70
+ if (source.startsWith("npm:") || source.startsWith("git:") || source.startsWith("http://") || source.startsWith("https://")) return source;
71
+ if (source.startsWith("/")) {
72
+ const pkg = extractPackageNameFromPath(source);
73
+ if (pkg) return `npm:${pkg}`;
74
+ return sourceInfo.scope === "project" ? "项目扩展" : "全局扩展";
75
+ }
76
+ return source;
77
+ }
78
+
79
+ function sourceLabelForMcp(server: McpServerEntry): string {
80
+ const bits = [server.mode === "http" ? "HTTP" : "stdio"];
81
+ if (server.lifecycle) bits.push(server.lifecycle);
82
+ return bits.join(" · ");
83
+ }
84
+
85
+ function itemBase(kind: IndexedItem["kind"], name: string, sourceRef: string): Pick<IndexedItem, "key" | "kind" | "name" | "displayName" | "sourceRef" | "useCount"> {
86
+ const canonical = canonicalName(kind, name);
87
+ return {
88
+ key: makeItemKey(kind, canonical, sourceRef),
89
+ kind,
90
+ name: canonical,
91
+ displayName: displayName(kind, canonical),
92
+ sourceRef,
93
+ useCount: 0,
94
+ };
95
+ }
96
+
97
+ export function buildResourceIndex(input: ResourceRuntimeInput): ResourceIndex {
98
+ const { commands, tools, state } = input;
99
+ const packages = mergePackageEntries(input.packages);
100
+ const now = input.now ?? Date.now();
101
+
102
+ const mergedMcp = new Map<string, McpServerEntry>();
103
+ for (const server of input.mcpServers) mergedMcp.set(server.name, server);
104
+
105
+ const commandItems: IndexedItem[] = [];
106
+ for (const command of commands) {
107
+ const sourceRef = compactSourceRef(command.sourceInfo.source ?? command.sourceInfo.origin ?? command.sourceInfo.scope);
108
+ const base = itemBase("command", command.name, sourceRef);
109
+ commandItems.push(upsertItem(state, {
110
+ ...base,
111
+ description: command.description,
112
+ sourceLabel: sourceLabelForCommand(command.sourceInfo),
113
+ scope: command.sourceInfo.scope,
114
+ packageName: command.sourceInfo.source,
115
+ lastSeenAt: now,
116
+ }));
117
+ }
118
+
119
+ for (const builtin of BUILTIN_COMMANDS) {
120
+ const sourceRef = "builtin";
121
+ const base = itemBase("command", builtin.name, sourceRef);
122
+ commandItems.push(upsertItem(state, {
123
+ ...base,
124
+ description: builtin.description,
125
+ sourceLabel: "内置",
126
+ scope: "builtin",
127
+ lastSeenAt: now,
128
+ }));
129
+ }
130
+
131
+ const toolItems: IndexedItem[] = [];
132
+ for (const tool of tools) {
133
+ if (tool.name === "mcp") continue;
134
+ const sourceRef = compactSourceRef(tool.sourceInfo.source ?? tool.sourceInfo.origin ?? tool.sourceInfo.scope);
135
+ const base = itemBase("tool", tool.name, sourceRef);
136
+ toolItems.push(upsertItem(state, {
137
+ ...base,
138
+ description: tool.description,
139
+ sourceLabel: sourceLabelForTool(tool.sourceInfo),
140
+ scope: tool.sourceInfo.scope,
141
+ packageName: tool.sourceInfo.source,
142
+ lastSeenAt: now,
143
+ }));
144
+ }
145
+
146
+ const packageItems: IndexedItem[] = [];
147
+ for (const pkg of packages) {
148
+ const sourceRef = compactSourceRef(pkg.name);
149
+ const base = itemBase("package", pkg.name, sourceRef);
150
+ packageItems.push(upsertItem(state, {
151
+ ...base,
152
+ description: "已安装包",
153
+ sourceLabel: sourceLabelForPackage(pkg.name),
154
+ scope: pkg.scope,
155
+ packageName: pkg.name,
156
+ lastSeenAt: now,
157
+ }));
158
+ }
159
+
160
+ const mcpItems: IndexedItem[] = [];
161
+ const gatewayBase = itemBase("mcp", "mcp", "mcp:gateway");
162
+ mcpItems.push(upsertItem(state, {
163
+ ...gatewayBase,
164
+ description: "显示 MCP 服务器状态",
165
+ sourceLabel: "内置网关",
166
+ scope: "builtin",
167
+ lastSeenAt: now,
168
+ }));
169
+
170
+ for (const server of mergedMcp.values()) {
171
+ const sourceRef = `mcp:${server.name}`;
172
+ const base = itemBase("mcp", server.name, sourceRef);
173
+ mcpItems.push(upsertItem(state, {
174
+ ...base,
175
+ description: server.url ? server.url : server.command ? server.command : "MCP 服务器",
176
+ sourceLabel: sourceLabelForMcp(server),
177
+ scope: "user",
178
+ packageName: server.command ?? server.url,
179
+ lastSeenAt: now,
180
+ }));
181
+ }
182
+
183
+ state.lastRefresh = now;
184
+ return { commands: commandItems, tools: toolItems, mcp: mcpItems, packages: packageItems };
185
+ }
186
+
187
+ export function readRuntimePackages(agentDir: string, cwd: string): PackageEntry[] {
188
+ return [
189
+ ...readPackagesFromSettings(join(agentDir, "settings.json"), "user"),
190
+ ...readPackagesFromSettings(join(cwd, ".pi", "settings.json"), "project"),
191
+ ];
192
+ }
193
+
194
+ export function readRuntimeMcpServers(agentDir: string, homeDir: string, cwd: string): McpServerEntry[] {
195
+ return [
196
+ ...readMcpServersFromConfig(join(homeDir, ".config", "mcp", "mcp.json")),
197
+ ...readMcpServersFromConfig(join(agentDir, "mcp.json")),
198
+ ...readMcpServersFromConfig(join(cwd, ".mcp.json")),
199
+ ...readMcpServersFromConfig(join(cwd, ".pi", "mcp.json")),
200
+ ];
201
+ }
@@ -0,0 +1,31 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import type { McpServerEntry } from "../types.js";
3
+
4
+ function loadJson(path: string): Record<string, unknown> {
5
+ if (!existsSync(path)) return {};
6
+ try {
7
+ return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
8
+ } catch {
9
+ return {};
10
+ }
11
+ }
12
+
13
+ export function readMcpServersFromConfig(path: string): McpServerEntry[] {
14
+ const config = loadJson(path);
15
+ const servers = config.mcpServers;
16
+ if (!servers || typeof servers !== "object") return [];
17
+
18
+ const entries: McpServerEntry[] = [];
19
+ for (const [name, value] of Object.entries(servers as Record<string, unknown>)) {
20
+ if (!value || typeof value !== "object") continue;
21
+
22
+ const server = value as Record<string, unknown>;
23
+ const command = typeof server.command === "string" ? server.command : undefined;
24
+ const url = typeof server.url === "string" ? server.url : undefined;
25
+ const lifecycle = typeof server.lifecycle === "string" ? server.lifecycle : undefined;
26
+ const mode = url ? "http" : "stdio";
27
+ entries.push({ name, mode, lifecycle, command, url });
28
+ }
29
+
30
+ return entries;
31
+ }
@@ -0,0 +1,45 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import type { PackageEntry, ResourceScope } from "../types.js";
3
+
4
+ function loadJson(path: string): Record<string, unknown> {
5
+ if (!existsSync(path)) return {};
6
+ try {
7
+ return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
8
+ } catch {
9
+ return {};
10
+ }
11
+ }
12
+
13
+ export function readPackagesFromSettings(path: string, scope: ResourceScope): PackageEntry[] {
14
+ const settings = loadJson(path);
15
+ const raw = Array.isArray(settings.packages) ? settings.packages : [];
16
+ const values: PackageEntry[] = [];
17
+
18
+ for (const entry of raw) {
19
+ if (typeof entry === "string") {
20
+ values.push({ name: entry, scope });
21
+ continue;
22
+ }
23
+
24
+ if (!entry || typeof entry !== "object") continue;
25
+
26
+ const obj = entry as Record<string, unknown>;
27
+ if (typeof obj.source === "string") {
28
+ values.push({ name: obj.source, scope });
29
+ continue;
30
+ }
31
+
32
+ if (typeof obj.package === "string") values.push({ name: obj.package, scope });
33
+ }
34
+
35
+ return values;
36
+ }
37
+
38
+ export function mergePackageEntries(entries: PackageEntry[]): PackageEntry[] {
39
+ const byName = new Map<string, PackageEntry>();
40
+ for (const entry of entries) {
41
+ const existing = byName.get(entry.name);
42
+ if (!existing || existing.scope !== "project") byName.set(entry.name, entry);
43
+ }
44
+ return [...byName.values()];
45
+ }