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

@ -1,5 +1,5 @@
// src/netx/group-tools-plugin.ts
import { readFileSync as readFileSync6 } from "node:fs";
import { readFileSync as readFileSync7 } from "node:fs";
import { dirname as dirname6, join as join11 } from "node:path";
import { fileURLToPath as fileURLToPath2 } from "node:url";
@ -267,6 +267,15 @@ function mcpContentText(content, fallback = "") {
// src/netx/skills-cache.ts
import { createHash } from "node:crypto";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
@ -293,6 +302,7 @@ function normalizeApiUrl(raw) {
}
// src/netx/skills-cache.ts
var STALE_CACHE_MS = 30 * 24 * 60 * 60 * 1000;
function dshHome() {
const fromEnv = process.env.DSH_HOME?.trim();
return fromEnv || join(homedir(), ".dsh");
@ -305,9 +315,46 @@ function skillsCacheKey(apiUrl) {
const base = normalizeSkillsApiUrl(apiUrl);
return createHash("sha256").update(base).digest("hex").slice(0, 16);
}
function skillsCacheDir(apiUrl) {
return join(dshHome(), "netxops-mcp-skills", skillsCacheKey(apiUrl));
function cacheParent() {
return join(dshHome(), "netxops-mcp-skills");
}
function skillsCacheDir(apiUrl) {
return join(cacheParent(), skillsCacheKey(apiUrl));
}
function tokenFingerprint(token) {
const raw = token.trim();
return raw ? createHash("sha256").update(raw).digest("hex").slice(0, 16) : "";
}
function readMeta(root) {
try {
const meta = JSON.parse(readFileSync(join(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;
}
var summaryMemo = new Map;
// src/netx/mcp-store.ts
var STORE_KEY = Symbol.for("dsh-netxops.mcp-catalog-store");
@ -512,12 +559,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) {
@ -527,7 +574,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;
@ -546,6 +593,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))
@ -559,7 +607,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, join2(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}
@ -593,11 +645,11 @@ Use only tools present in this session. Dual-unit/orbit/polish recipes require s
}
// src/netx/kb-context-skill.ts
import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync3 } from "node:fs";
import { readdirSync as readdirSync3, readFileSync as readFileSync3, statSync as statSync4 } from "node:fs";
import { join as join5 } from "node:path";
// src/netx/kb-manifest.ts
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync2 } from "node:fs";
import { basename, dirname as dirname2, join as join3, resolve } from "node:path";
var EMPTY_CONTENT = {
hasRegions: false,
@ -624,7 +676,7 @@ function resolveKbPaths(snapshot) {
const paths = { skills: "_skills", ...snapshot.paths };
return Object.entries(paths).filter((entry) => typeof entry[1] === "string" && entry[1].trim() !== "").map(([key, rel]) => {
const absolute = resolve(snapshot.realRoot, rel);
return { key, relative: rel, absolute, exists: existsSync(absolute) };
return { key, relative: rel, absolute, exists: existsSync2(absolute) };
});
}
function renderKbPathTable(snapshot) {
@ -640,7 +692,7 @@ function renderKbPathTable(snapshot) {
}
// src/netx/kb-local-path.ts
import { existsSync as existsSync2, realpathSync, statSync as statSync2 } from "node:fs";
import { existsSync as existsSync3, realpathSync, statSync as statSync3 } from "node:fs";
import { join as join4, relative, resolve as resolve2, sep } from "node:path";
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
"memories",
@ -749,7 +801,7 @@ function resolveExistingWritableFile(localRoot, pathArg) {
}
const checked = resolveWritableLocalPath(localRoot, ...relative(localRoot, absolutePath).split(/[/\\]/).filter(Boolean));
try {
if (existsSync2(checked.absolutePath)) {
if (existsSync3(checked.absolutePath)) {
const realFile = realpathSync(checked.absolutePath);
const realLocal = realpathSync(localRoot);
const realRel = relative(realLocal, realFile);
@ -760,7 +812,7 @@ function resolveExistingWritableFile(localRoot, pathArg) {
if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) {
throw new Error(`path not under writable roots: ${realRel}`);
}
if (!statSync2(realFile).isFile()) {
if (!statSync3(realFile).isFile()) {
throw new Error(`not a file: ${realFile}`);
}
return {
@ -813,7 +865,7 @@ function readKbIdentity(snapshot) {
const dir = join5(localRoot, "identity");
let names;
try {
names = readdirSync2(dir).filter((name) => /\.md$/i.test(name) && !/^readme\.md$/i.test(name)).sort();
names = readdirSync3(dir).filter((name) => /\.md$/i.test(name) && !/^readme\.md$/i.test(name)).sort();
} catch {
return [];
}
@ -825,9 +877,9 @@ function readKbIdentity(snapshot) {
const full = join5(dir, name);
let text;
try {
if (!statSync3(full).isFile())
if (!statSync4(full).isFile())
continue;
text = readFileSync2(full, "utf8").replace(/^\uFEFF/, "").trim();
text = readFileSync3(full, "utf8").replace(/^\uFEFF/, "").trim();
} catch {
continue;
}
@ -950,13 +1002,13 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
// src/netx/kb-local-ops.ts
import {
existsSync as existsSync3,
mkdirSync,
readdirSync as readdirSync3,
readFileSync as readFileSync3,
rmSync,
statSync as statSync4,
writeFileSync
existsSync as existsSync4,
mkdirSync as mkdirSync2,
readdirSync as readdirSync4,
readFileSync as readFileSync4,
rmSync as rmSync2,
statSync as statSync5,
writeFileSync as writeFileSync2
} from "node:fs";
import { dirname as dirname3, join as join6 } from "node:path";
function requireLocalRoot(snapshot) {
@ -1004,12 +1056,12 @@ function createMemory(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const name = memoryFileName({ date: args.date, slug: args.slug });
const target = resolveWritableLocalPath(localRoot, memoryDir(args.bucket), name);
if (existsSync3(target.absolutePath) && !args.overwrite) {
if (existsSync4(target.absolutePath) && !args.overwrite) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
}
mkdirSync(dirname3(target.absolutePath), { recursive: true });
const existed = existsSync3(target.absolutePath);
writeFileSync(target.absolutePath, args.body.endsWith(`
mkdirSync2(dirname3(target.absolutePath), { recursive: true });
const existed = existsSync4(target.absolutePath);
writeFileSync2(target.absolutePath, args.body.endsWith(`
`) ? args.body : `${args.body}
`, "utf8");
return {
@ -1023,12 +1075,12 @@ function createDraft(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const name = draftFileName({ date: args.date, slug: args.slug });
const target = resolveWritableLocalPath(localRoot, draftDir(args.domain), name);
if (existsSync3(target.absolutePath) && !args.overwrite) {
if (existsSync4(target.absolutePath) && !args.overwrite) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
}
mkdirSync(dirname3(target.absolutePath), { recursive: true });
const existed = existsSync3(target.absolutePath);
writeFileSync(target.absolutePath, ensureDraftBody(args.body), "utf8");
mkdirSync2(dirname3(target.absolutePath), { recursive: true });
const existed = existsSync4(target.absolutePath);
writeFileSync2(target.absolutePath, ensureDraftBody(args.body), "utf8");
return {
ok: true,
action: existed ? "updated" : "created",
@ -1040,12 +1092,12 @@ function createSuggestion(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const name = suggestionFileName({ date: args.date, slug: args.slug });
const target = resolveWritableLocalPath(localRoot, suggestionDir(args.kind), name);
if (existsSync3(target.absolutePath) && !args.overwrite) {
if (existsSync4(target.absolutePath) && !args.overwrite) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
}
mkdirSync(dirname3(target.absolutePath), { recursive: true });
const existed = existsSync3(target.absolutePath);
writeFileSync(target.absolutePath, args.body.endsWith(`
mkdirSync2(dirname3(target.absolutePath), { recursive: true });
const existed = existsSync4(target.absolutePath);
writeFileSync2(target.absolutePath, args.body.endsWith(`
`) ? args.body : `${args.body}
`, "utf8");
return {
@ -1059,12 +1111,12 @@ function createRef(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const name = refFileName(args.slug);
const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name);
if (existsSync3(target.absolutePath) && !args.overwrite) {
if (existsSync4(target.absolutePath) && !args.overwrite) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
}
mkdirSync(dirname3(target.absolutePath), { recursive: true });
const existed = existsSync3(target.absolutePath);
writeFileSync(target.absolutePath, args.body.endsWith(`
mkdirSync2(dirname3(target.absolutePath), { recursive: true });
const existed = existsSync4(target.absolutePath);
writeFileSync2(target.absolutePath, args.body.endsWith(`
`) ? args.body : `${args.body}
`, "utf8");
return {
@ -1077,13 +1129,13 @@ function createRef(snapshot, args) {
function updateLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path);
if (!existsSync3(target.absolutePath)) {
if (!existsSync4(target.absolutePath)) {
throw new Error(`file not found: ${target.relativePath}`);
}
const body = target.writableRoot === "drafts" ? ensureDraftBody(args.body) : args.body.endsWith(`
`) ? args.body : `${args.body}
`;
writeFileSync(target.absolutePath, body, "utf8");
writeFileSync2(target.absolutePath, body, "utf8");
return {
ok: true,
action: "updated",
@ -1094,10 +1146,10 @@ function updateLocalFile(snapshot, args) {
function deleteLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path);
if (!existsSync3(target.absolutePath)) {
if (!existsSync4(target.absolutePath)) {
throw new Error(`file not found: ${target.relativePath}`);
}
rmSync(target.absolutePath, { force: false });
rmSync2(target.absolutePath, { force: false });
return {
ok: true,
action: "deleted",
@ -1108,7 +1160,7 @@ function deleteLocalFile(snapshot, args) {
function walkFiles(dir, baseLocal, out) {
let entries;
try {
entries = readdirSync3(dir);
entries = readdirSync4(dir);
} catch {
return;
}
@ -1118,7 +1170,7 @@ function walkFiles(dir, baseLocal, out) {
const full = join6(dir, name);
let st;
try {
st = statSync4(full);
st = statSync5(full);
} catch {
continue;
}
@ -1152,7 +1204,7 @@ function listLocalFiles(snapshot, args = {}) {
}
// src/netx/kb-read-ops.ts
import { readdirSync as readdirSync4, readFileSync as readFileSync4, realpathSync as realpathSync2, statSync as statSync5 } from "node:fs";
import { readdirSync as readdirSync5, readFileSync as readFileSync5, realpathSync as realpathSync2, statSync as statSync6 } from "node:fs";
import { extname, join as join7, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path";
var TEXT_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".json", ".yml", ".yaml", ".csv", ".ps1", ".sh", ".cfg", ".conf", ".log"]);
var SKIP_DIRS = new Set([".git", "node_modules"]);
@ -1197,14 +1249,14 @@ function resolveKbReadPath(snapshot, pathArg) {
}
function readKbFile(snapshot, args) {
const target = resolveKbReadPath(snapshot, args.path);
const st = statSync5(target.absolutePath);
const st = statSync6(target.absolutePath);
if (st.isDirectory()) {
const entries = readdirSync4(target.absolutePath, { withFileTypes: true }).filter((entry) => !SKIP_DIRS.has(entry.name)).map((entry) => {
const entries = readdirSync5(target.absolutePath, { withFileTypes: true }).filter((entry) => !SKIP_DIRS.has(entry.name)).map((entry) => {
if (entry.isDirectory())
return { name: entry.name, type: "dir" };
let size;
try {
size = statSync5(join7(target.absolutePath, entry.name)).size;
size = statSync6(join7(target.absolutePath, entry.name)).size;
} catch {}
return { name: entry.name, type: "file", size };
}).sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1);
@ -1213,7 +1265,7 @@ function readKbFile(snapshot, args) {
if (!TEXT_EXTENSIONS.has(extname(target.absolutePath).toLowerCase())) {
throw new Error(`not a text KB file: ${target.relativePath}`);
}
const lines = readFileSync4(target.absolutePath, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, `
const lines = readFileSync5(target.absolutePath, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, `
`).split(`
`);
const start = Math.max(1, Math.floor(args.offset ?? 1));
@ -1258,7 +1310,7 @@ function cachedText(path, mtimeMs, size) {
const hit = cache.files.get(path);
if (hit && hit.mtimeMs === mtimeMs && hit.size === size)
return hit;
const text = readFileSync4(path, "utf8").replace(/^\uFEFF/, "");
const text = readFileSync5(path, "utf8").replace(/^\uFEFF/, "");
if (hit)
cache.chars -= hit.text.length;
if (cache.chars + text.length > CACHE_MAX_CHARS) {
@ -1273,7 +1325,7 @@ function cachedText(path, mtimeMs, size) {
function walkTextFiles(dir, out) {
let entries;
try {
entries = readdirSync4(dir, { withFileTypes: true });
entries = readdirSync5(dir, { withFileTypes: true });
} catch {
return;
}
@ -1288,7 +1340,7 @@ function walkTextFiles(dir, out) {
if (!entry.isFile() || !TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase()))
continue;
try {
const st = statSync5(full);
const st = statSync6(full);
if (st.size <= SEARCH_MAX_FILE_BYTES)
out.push({ path: full, mtimeMs: st.mtimeMs, size: st.size });
} catch {}
@ -1318,7 +1370,7 @@ function searchKb(snapshot, args) {
const files = [];
const seen = new Set;
for (const dir of dirs) {
const st = statSync5(dir);
const st = statSync6(dir);
const batch = [];
if (st.isFile())
batch.push({ path: dir, mtimeMs: st.mtimeMs, size: st.size });
@ -1432,7 +1484,7 @@ function toLosslessJson(value) {
}
// src/netx/tool-mount-status.ts
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
import { homedir as homedir2 } from "node:os";
import { dirname as dirname4, join as join8 } from "node:path";
var KEY = Symbol.for("dsh-netxops.mount-inventory");
@ -1459,8 +1511,8 @@ function getToolMountStatus() {
function persist() {
try {
const path = join8(process.env.DSH_HOME?.trim() || join8(homedir2(), ".dsh"), "netxops-mount-status.json");
mkdirSync2(dirname4(path), { recursive: true });
writeFileSync2(path, `${JSON.stringify({ mount: getToolMountStatus() ?? null, mounts: getToolMountStatuses() }, null, 2)}
mkdirSync3(dirname4(path), { recursive: true });
writeFileSync3(path, `${JSON.stringify({ mount: getToolMountStatus() ?? null, mounts: getToolMountStatuses() }, null, 2)}
`, "utf8");
} catch {}
}
@ -1811,7 +1863,7 @@ async function registerKbPackSkills(ctx, snapshot, options) {
}
// src/netx/tools.ts
import { readFileSync as readFileSync5 } from "node:fs";
import { readFileSync as readFileSync6 } from "node:fs";
import { dirname as dirname5, join as join10 } from "node:path";
import { fileURLToPath } from "node:url";
import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools";
@ -1916,7 +1968,7 @@ function parametersFromInputSchema(schema) {
function packageVersion() {
try {
const here = dirname5(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync5(join10(here, "..", "package.json"), "utf8"));
const pkg = JSON.parse(readFileSync6(join10(here, "..", "package.json"), "utf8"));
return typeof pkg.version === "string" ? pkg.version : "0";
} catch {
return "0";
@ -2048,7 +2100,7 @@ function registerNetxTools(ctx, connection, options) {
function packageVersion2() {
try {
const here = dirname6(fileURLToPath2(import.meta.url));
const pkg = JSON.parse(readFileSync6(join11(here, "..", "package.json"), "utf8"));
const pkg = JSON.parse(readFileSync7(join11(here, "..", "package.json"), "utf8"));
return typeof pkg.version === "string" ? pkg.version : "0";
} catch {
return "0";