feat(skills): merge unavailable tools into footnote, bind cache to token, prune stale caches, warn on remote plain HTTP

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-16 23:37:16 +08:00
parent 056f2d4ea3
commit 05bfd97c6e
19 changed files with 1100 additions and 496 deletions

View file

@ -1398,6 +1398,7 @@ import {
} from "node:fs";
import { homedir as homedir3 } from "node:os";
import { dirname as dirname2, join as join3 } from "node:path";
var STALE_CACHE_MS = 30 * 24 * 60 * 60 * 1000;
function dshHome() {
const fromEnv = process.env.DSH_HOME?.trim();
return fromEnv || join3(homedir3(), ".dsh");
@ -1410,8 +1411,65 @@ function skillsCacheKey(apiUrl) {
const base = normalizeSkillsApiUrl(apiUrl);
return createHash2("sha256").update(base).digest("hex").slice(0, 16);
}
function cacheParent() {
return join3(dshHome(), "netxops-mcp-skills");
}
function skillsCacheDir(apiUrl) {
return join3(dshHome(), "netxops-mcp-skills", skillsCacheKey(apiUrl));
return join3(cacheParent(), skillsCacheKey(apiUrl));
}
function tokenFingerprint(token) {
const raw = token.trim();
return raw ? createHash2("sha256").update(raw).digest("hex").slice(0, 16) : "";
}
function readMeta(root) {
try {
const meta = JSON.parse(readFileSync3(join3(root, ".meta.json"), "utf8"));
return meta && typeof meta === "object" && !Array.isArray(meta) ? meta : null;
} catch {
return null;
}
}
function usableSkillsCacheDir(apiUrl, token) {
if (!apiUrl.trim())
return "";
const root = skillsCacheDir(apiUrl);
const pulledWith = readMeta(root)?.tokenFp;
if (pulledWith && pulledWith !== tokenFingerprint(token))
return "";
return root;
}
function cachedUnavailableTools(root) {
const raw = readMeta(root)?.unavailableTools;
const out = {};
if (!raw || typeof raw !== "object")
return out;
for (const group of CAPABILITY_GROUP_IDS) {
const names = raw[group];
if (Array.isArray(names))
out[group] = names.filter((n) => typeof n === "string");
}
return out;
}
function pruneStaleCaches(keep) {
const parent = cacheParent();
let entries;
try {
entries = readdirSync(parent);
} catch {
return;
}
const now = Date.now();
for (const name of entries) {
const dir = join3(parent, name);
if (dir === keep)
continue;
try {
const listedAt = Date.parse(readMeta(dir)?.listedAt ?? "");
const age = now - (Number.isFinite(listedAt) ? listedAt : statSync2(dir).mtimeMs);
if (age > STALE_CACHE_MS)
rmSync(dir, { recursive: true, force: true });
} catch {}
}
}
function isSafeSegment(value) {
return Boolean(value) && !value.includes("..") && !value.includes("/") && !value.includes("\\") && !value.includes("\x00");
@ -1422,12 +1480,17 @@ function isSafeRelPath(relPath) {
const parts = relPath.replace(/\\/g, "/").split("/");
return parts.every((part) => part.length > 0 && part !== "." && part !== "..");
}
function replaceSkillsCache(apiUrl, files) {
function replaceSkillsCache(apiUrl, files, options = {}) {
const root = skillsCacheDir(apiUrl);
rmSync(root, { recursive: true, force: true });
mkdirSync2(root, { recursive: true });
let written = 0;
const unavailableTools = {};
for (const file of files) {
if (file.unavailableTools?.length && SKILL_DIR_BY_GROUP[file.group]) {
const merged = new Set([...unavailableTools[file.group] ?? [], ...file.unavailableTools]);
unavailableTools[file.group] = [...merged].sort();
}
const groupDir = SKILL_DIR_BY_GROUP[file.group];
if (!groupDir || !isSafeSegment(file.skill) || !isSafeRelPath(file.relPath))
continue;
@ -1445,10 +1508,14 @@ function replaceSkillsCache(apiUrl, files) {
apiUrl: normalized,
mcpUrl: normalized ? `${normalized}/mcp` : "",
listedAt: new Date().toISOString(),
fileCount: written
fileCount: written,
...options.token !== undefined ? { tokenFp: tokenFingerprint(options.token) } : {},
unavailableTools
};
writeFileSync3(join3(root, ".meta.json"), `${JSON.stringify(meta, null, 2)}
`, "utf8");
summaryMemo.delete(root);
pruneStaleCaches(root);
return root;
}
function countFiles(dir) {
@ -1459,17 +1526,17 @@ function countFiles(dir) {
}
return n;
}
function listCachedSkills(apiUrl) {
const root = skillsCacheDir(apiUrl);
var summaryMemo = new Map;
function listCachedSkills(apiUrl, token = "") {
const root = usableSkillsCacheDir(apiUrl, token);
if (!root || !existsSync3(root))
return { listedAt: null, skills: [] };
const metaListedAt = readMeta(root)?.listedAt;
const listedAt = typeof metaListedAt === "string" ? metaListedAt : null;
const memo = summaryMemo.get(root);
if (memo && memo.listedAt === listedAt)
return memo;
const skills = [];
let listedAt = null;
if (!apiUrl.trim() || !existsSync3(root))
return { listedAt, skills };
try {
const meta = JSON.parse(readFileSync3(join3(root, ".meta.json"), "utf8"));
if (typeof meta.listedAt === "string")
listedAt = meta.listedAt;
} catch {}
for (const group of CAPABILITY_GROUP_IDS) {
const groupDir = join3(root, SKILL_DIR_BY_GROUP[group]);
if (!existsSync3(groupDir))
@ -1483,7 +1550,15 @@ function listCachedSkills(apiUrl) {
}
} catch {}
}
return { listedAt, skills };
const listing = { listedAt, skills };
summaryMemo.set(root, listing);
return listing;
}
// src/netx/mcp-types.ts
var NETX_TOOL_PREFIX = "netx__";
function publicToolName(serverName) {
return `${NETX_TOOL_PREFIX}${serverName}`;
}
// src/netx/group-skills.ts
@ -1614,12 +1689,12 @@ function disabledFootnote(names, lang) {
const en = (lang ?? "").trim().toLowerCase().startsWith("en");
return en ? `
## Disabled tools
The following tools are disabled: ${list}. Do not call them.
## Unavailable tools
The following tools are disabled or not granted to this token: ${list}. Do not call them; skip steps that need them.
` : `
## 已禁用工具
以下工具已禁用:${list},不要调用。
## 不可用工具
以下工具已禁用或当前 token 无权使用:${list},不要调用,涉及它们的步骤请跳过。
`;
}
function resolveSkillsRoot(options) {
@ -1629,7 +1704,7 @@ function resolveSkillsRoot(options) {
const connection = getNetxConnection();
if (!connection?.apiUrl?.trim())
return "";
return skillsCacheDir(connection.apiUrl);
return usableSkillsCacheDir(connection.apiUrl, connection.token ?? "");
}
async function registerGroupSkills(ctx, groupIds, providerLabel, signalOrOptions) {
const options = signalOrOptions instanceof AbortSignal || signalOrOptions === undefined ? { signal: signalOrOptions } : signalOrOptions;
@ -1648,6 +1723,7 @@ async function registerGroupSkills(ctx, groupIds, providerLabel, signalOrOptions
}
return () => {};
}
const unavailable = cachedUnavailableTools(root);
try {
for (const groupId of CAPABILITY_GROUP_IDS) {
if (!enabled.has(groupId))
@ -1661,7 +1737,11 @@ async function registerGroupSkills(ctx, groupIds, providerLabel, signalOrOptions
if (skills.length === 0) {
log?.warn?.("%s: no skills loaded for group=%s from %s", providerLabel, groupId, join4(root, SKILL_DIR_BY_GROUP[groupId]));
}
const footnote = disabledFootnote(options.disabledByGroup?.[groupId] ?? [], options.lang);
const offNames = new Set([
...options.disabledByGroup?.[groupId] ?? [],
...(unavailable[groupId] ?? []).map(publicToolName)
]);
const footnote = disabledFootnote([...offNames].sort(), options.lang);
for (const skill of skills) {
const base = groupId === "topology" ? `${skill.content}
@ -4581,12 +4661,6 @@ import { dirname as dirname6, join as join12 } from "node:path";
import { fileURLToPath as fileURLToPath2 } from "node:url";
import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools";
// src/netx/mcp-types.ts
var NETX_TOOL_PREFIX = "netx__";
function publicToolName(serverName) {
return `${NETX_TOOL_PREFIX}${serverName}`;
}
// src/netx/mcp-catalog.ts
function disabledSet(disabledTools) {
const out = new Set;
@ -4720,7 +4794,14 @@ function parseSkillResource(resource, text) {
}
if (!relPath || relPath.includes(".."))
return null;
return { group, skill, relPath, text };
const unavailableTools = Array.isArray(netx.unavailableTools) ? netx.unavailableTools.filter((n) => typeof n === "string" && n.length > 0) : [];
return {
group,
skill,
relPath,
text,
...netx.entry === true && unavailableTools.length > 0 ? { unavailableTools } : {}
};
}
function resourcesCapability(capabilities) {
if (!capabilities || typeof capabilities !== "object" || Array.isArray(capabilities))
@ -5086,7 +5167,7 @@ async function refreshMcpCatalog(input) {
if (gen !== store5().generation)
return store5().state;
if (fetched.skills !== undefined) {
replaceSkillsCache(input.apiUrl, fetched.skills);
replaceSkillsCache(input.apiUrl, fetched.skills, { token: input.token });
} else {
console.warn("netxops: mcp skills pull failed, keeping cache — %s", fetched.skillsError);
}
@ -5513,7 +5594,8 @@ function installPresetPublication(ctx) {
// src/index.ts
function mcpCatalogPayload(catalog) {
const cached = listCachedSkills(getNetxConnection()?.apiUrl ?? "");
const connection = getNetxConnection();
const cached = listCachedSkills(connection?.apiUrl ?? "", connection?.token ?? "");
return {
phase: catalog.phase,
mcpUrl: catalog.mcpUrl,