mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-20 13:30:46 +08:00
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:
parent
056f2d4ea3
commit
05bfd97c6e
19 changed files with 1100 additions and 496 deletions
140
lib/index.js
140
lib/index.js
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue