// src/netx/capability-groups.ts var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ ops: Object.freeze({ inPreset: true, public: false }), topology: Object.freeze({ inPreset: false, public: false }), bizMonitor: Object.freeze({ inPreset: true, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ ops: Object.freeze([ "netx__queryNmsAlarms", "netx__aggregateNmsAlarms", "netx__runNmsDiagnostics", "netx__queryNmsNeInventory", "netx__getNmsNe", "netx__queryNmsAlarmsRaw", "netx__aggregateNmsAlarmsRaw", "netx__listNmsAlarmFields", "netx__sqlQueryNms", "netx__listManagedNe", "netx__getManagedNe", "netx__execManagedNe", "netx__listCliTargets", "netx__findTopologyPaths" ]), topology: Object.freeze([ "netx__getTopologyTree", "netx__getTopologyView", "netx__createTopologyFolder", "netx__addTopologyViewNodes", "netx__removeTopologyViewNodes", "netx__copyTopologyViewNodes", "netx__updateTopologyViewPositions", "netx__projectTopologyNeighbors", "netx__queryTopologyFabricNodes", "netx__classifyTopologyFabricNodes", "netx__queryTopologyNeighborhood", "netx__queryTopologyEdges", "netx__layoutTopologyView", "netx__suggestSinkHubs", "netx__analyzeTopologyViewLayout", "netx__sinkTopologyDualUnits" ]), bizMonitor: Object.freeze([ "netx__listBizMonitors", "netx__getBizMonitorContext", "netx__getBizMonitorBoard", "netx__listBizMonitorReds", "netx__getBizMonitorDiffs", "netx__getBizCollectBatch", "netx__getBizCollectCommandRaw" ]) }); var SKILL_DIR_BY_GROUP = Object.freeze({ ops: "ops", topology: "topology", bizMonitor: "biz-monitor" }); var CAPABILITY_GROUP_IDS = Object.freeze([ "ops", "topology", "bizMonitor" ]); function groupsForPlane(groups, plane, only) { const policy = groups ?? DEFAULT_CAPABILITY_GROUPS; const enabled = CAPABILITY_GROUP_IDS.filter((id) => plane === "preset" ? policy[id].inPreset : policy[id].public); if (!only || only.length === 0) return enabled; const allow = new Set(only); return enabled.filter((id) => allow.has(id)); } function groupsForced(only) { const normalized = only.map((id) => id === "nms" || id === "common" ? "ops" : id); return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)); } function toolNamesForGroups(groupIds) { const out = new Set; for (const id of groupIds) { for (const name of TOOLS_BY_GROUP[id] ?? []) out.add(name); } return out; } // src/netx/group-skills.ts import { existsSync } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; function opsSkillsRoot() { const envRoot = process.env.NETX_SKILLS_ROOT?.trim(); if (envRoot && existsSync(envRoot)) return envRoot; const here = dirname(fileURLToPath(import.meta.url)); const siblingCandidates = [ join(here, "..", "..", "..", "netx", "skills"), join(here, "..", "..", "netx", "skills") ]; for (const candidate of siblingCandidates) { if (existsSync(candidate)) return candidate; } const packaged = [ join(here, "..", "presets", "netxops", "skills"), join(here, "..", "..", "presets", "netxops", "skills") ]; for (const candidate of packaged) { if (existsSync(candidate)) return candidate; } return packaged[0]; } function parseFrontmatter(raw) { if (!raw.startsWith("---")) return; const end = raw.indexOf(` ---`, 3); if (end < 0) return; const yaml = raw.slice(3, end).replace(/^\r?\n/, ""); const body = raw.slice(end + 4).replace(/^\r?\n/, ""); const data = {}; const lines = yaml.split(/\r?\n/); for (let i = 0;i < lines.length; i += 1) { const line = lines[i]; const nameMatch = /^name\s*:\s*(.+)\s*$/.exec(line); if (nameMatch) { data.name = stripQuotes(nameMatch[1].trim()); continue; } const descMatch = /^description\s*:\s*(.*)$/.exec(line); if (!descMatch) continue; const head = descMatch[1].trim(); if (head === ">" || head === ">-" || head === "|" || head === "|-") { const parts = []; while (i + 1 < lines.length && /^[ \t]+/.test(lines[i + 1])) { i += 1; parts.push(lines[i].trim()); } data.description = parts.filter(Boolean).join(" "); continue; } data.description = stripQuotes(head); } return { data, body }; } function stripQuotes(value) { if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) { return value.slice(1, -1); } return value; } async function loadSkillBundle(dir) { const skillPath = join(dir, "SKILL.md"); let raw; try { raw = await readFile(skillPath, "utf8"); } catch { return null; } const parsed = parseFrontmatter(raw); if (!parsed) return null; const name = typeof parsed.data.name === "string" ? parsed.data.name.trim() : ""; const description = typeof parsed.data.description === "string" ? parsed.data.description.trim() : ""; if (!name || !description) return null; return { name, description, content: parsed.body.trimStart(), path: skillPath, directory: dir }; } async function loadGroupSkills(skillsRoot, groupId) { const groupDir = join(skillsRoot, SKILL_DIR_BY_GROUP[groupId]); let entries; try { entries = await readdir(groupDir); } catch { return []; } const skills = []; for (const entry of entries) { const full = join(groupDir, entry); let isDir = false; try { isDir = (await stat(full)).isDirectory(); } catch { continue; } if (!isDir) continue; const skill = await loadSkillBundle(full); if (skill) skills.push(skill); } return skills; } async function registerGroupSkills(ctx, groupIds, providerLabel) { const skillsApi = ctx.skills; if (!skillsApi || typeof skillsApi.register !== "function") { return () => {}; } const root = opsSkillsRoot(); const disposers = []; const enabled = new Set(groupIds); for (const groupId of CAPABILITY_GROUP_IDS) { if (!enabled.has(groupId)) continue; const skills = await loadGroupSkills(root, groupId); for (const skill of skills) { disposers.push(skillsApi.register({ name: skill.name, description: skill.description, content: skill.content, path: skill.path, resourceBase: { kind: "directory", path: skill.directory }, provider: providerLabel, source: "custom" })); } } return () => { for (const dispose of disposers) dispose(); }; } // src/netx/kb-local-path.ts import { existsSync as existsSync2, realpathSync, statSync } from "node:fs"; import { join as join2, relative, resolve, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", "suggestions", "refs" ]); var REF_AREAS = Object.freeze([ "inventory", "devices", "topology", "business", "commands", "handbooks" ]); var MEMORY_BUCKET_DIR = Object.freeze({ note: "日常笔记", rca_review: "排障复盘", ai_trace: "AI思维链" }); function resolveKbLocalRoot(snapshot) { if (snapshot.status !== "configured") return null; const rel = snapshot.paths.local?.trim(); if (!rel) return null; return resolve(snapshot.realRoot, rel); } function kbLocalToolsEnabled(snapshot) { return resolveKbLocalRoot(snapshot) !== null; } function sanitizeSlug(raw, fallback = "entry") { const cleaned = raw.trim().replace(/[/\\]+/g, "-").replace(/\.\.+/g, "").replace(/[^\w.\u4e00-\u9fff-]+/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); return cleaned || fallback; } function todayIsoDate(now = new Date) { const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, "0"); const d = String(now.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } function toCompactDate(isoOrEmpty, now = new Date) { const iso = (isoOrEmpty?.trim() || todayIsoDate(now)).replace(/-/g, ""); if (!/^\d{8}$/.test(iso)) { throw new Error(`invalid date (want YYYY-MM-DD or YYYYMMDD): ${isoOrEmpty}`); } return iso; } function normalizeIsoDate(raw, now = new Date) { const t = raw?.trim(); if (!t) return todayIsoDate(now); if (/^\d{4}-\d{2}-\d{2}$/.test(t)) return t; if (/^\d{8}$/.test(t)) { return `${t.slice(0, 4)}-${t.slice(4, 6)}-${t.slice(6, 8)}`; } throw new Error(`invalid date (want YYYY-MM-DD or YYYYMMDD): ${raw}`); } function memoryFileName(opts) { const date = normalizeIsoDate(opts.date, opts.now); return `${date}-${sanitizeSlug(opts.slug)}.md`; } function draftFileName(opts) { const compact = toCompactDate(opts.date, opts.now); const slug = sanitizeSlug(opts.slug); const base = slug.toUpperCase().startsWith("DRAFT-") ? slug : `DRAFT-${compact}-${slug}`; return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; } function suggestionFileName(opts) { const date = normalizeIsoDate(opts.date, opts.now); return `${date}-${sanitizeSlug(opts.slug)}.md`; } function refFileName(slug) { const base = sanitizeSlug(slug, "note"); return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; } function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); } const absolutePath = resolve(localRoot, ...segments); const rel = relative(localRoot, absolutePath); if (!rel || rel.startsWith("..") || rel.split(/[/\\]/).includes("..")) { throw new Error(`path escapes local root: ${absolutePath}`); } const top = rel.split(/[/\\]/)[0]; if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) { throw new Error(`writes only allowed under ${KB_LOCAL_WRITABLE_ROOTS.join("|")}/ (got ${top}/)`); } return { absolutePath, relativePath: rel.split(sep).join("/"), writableRoot: top }; } function resolveExistingWritableFile(localRoot, pathArg) { const trimmed = pathArg.trim(); if (!trimmed) throw new Error("path is empty"); const candidate = resolve(trimmed); let absolutePath; const localResolved = resolve(localRoot); const underLocal = candidate === localResolved || candidate.startsWith(localResolved + sep); if (underLocal) { absolutePath = candidate; } else { absolutePath = resolve(localRoot, trimmed); } const checked = resolveWritableLocalPath(localRoot, ...relative(localRoot, absolutePath).split(/[/\\]/).filter(Boolean)); try { if (existsSync2(checked.absolutePath)) { const realFile = realpathSync(checked.absolutePath); const realLocal = realpathSync(localRoot); const realRel = relative(realLocal, realFile); if (!realRel || realRel.startsWith("..") || realRel.split(/[/\\]/).includes("..")) { throw new Error(`path escapes local root after realpath: ${realFile}`); } const top = realRel.split(/[/\\]/)[0]; if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) { throw new Error(`path not under writable roots: ${realRel}`); } if (!statSync(realFile).isFile()) { throw new Error(`not a file: ${realFile}`); } return { absolutePath: realFile, relativePath: realRel.split(sep).join("/"), writableRoot: top }; } } catch (error) { if (error instanceof Error && /escapes|writable|not a file/.test(error.message)) { throw error; } } return checked; } function memoryDir(bucket) { return join2("memories", MEMORY_BUCKET_DIR[bucket]); } function suggestionDir(kind) { return join2("suggestions", kind); } function draftDir(domain) { const d = domain?.trim(); if (!d) return "drafts"; return join2("drafts", sanitizeSlug(d, "misc")); } function refDir(area, device) { if (!REF_AREAS.includes(area)) { throw new Error(`invalid refs area: ${area}`); } if (area === "devices") { const host = sanitizeSlug(device?.trim() || "", ""); if (!host) { throw new Error("refs/devices requires device (host_name)"); } return join2("refs", "devices", host); } return join2("refs", area); } // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; function skillBody(snapshot) { if (snapshot.status === "configured") { const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)"; const localRoot = resolveKbLocalRoot(snapshot); const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; const localGuide = localRoot ? [ "", "### Dual plane (mandatory)", "", "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).", `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):", " `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /", " `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.", "", "### refs — site product knowledge (evolve every task)", "- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.", "- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.", "- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.", "- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).", "", "### memories — write immediately when valuable", "- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.", "- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`." ] : [ "", "No local write tools until `paths.local` is present in MANIFEST." ]; return { description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", content: [ "## Knowledge base (configured)", "", "When troubleshooting with operator playbooks or docs, **compose paths from this root**.", "Do not invent another operator or country.", "", `| Field | Value |`, `| --- | --- |`, `| kbStatus | configured |`, `| kbRoot | \`${snapshot.realRoot}\` |`, localRow, `| kbOperator | ${snapshot.operatorName} |`, `| kbCountry | ${snapshot.country} |`, `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, ...localGuide, "", "Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", "", "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from", `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, "plus paths.localSkills when present — use those skills for KB triage;", "keep netx-ops for live netx evidence only." ].join(` `) }; } const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty"; return { description: "Knowledge-base context is unavailable — stay on pure netx evidence.", content: [ "## Knowledge base (unavailable)", "", `kbStatus=${snapshot.status}. ${reason}`, "", "**Do not invent an operator, country, or KB paths.**", "Use only netx tools / live evidence (alarms, inventory, CLI, topology).", "Operator-specific playbooks are out of scope until a valid MANIFEST is configured." ].join(` `) }; } function registerKbContextSkill(ctx, snapshot) { const skillsApi = ctx.skills; if (!skillsApi || typeof skillsApi.register !== "function") { return () => {}; } const body = skillBody(snapshot); return skillsApi.register({ name: SKILL_NAME, description: body.description, content: body.content, provider: "netxops-kb", source: "custom" }); } // src/netx/kb-local-tools.ts import { defineTool } from "@deepseek-ai/dsh-tools"; // src/netx/kb-local-ops.ts import { existsSync as existsSync3, mkdirSync, readdirSync, readFileSync, rmSync, statSync as statSync2, writeFileSync } from "node:fs"; import { dirname as dirname2, join as join3 } from "node:path"; function requireLocalRoot(snapshot) { const root = resolveKbLocalRoot(snapshot); if (!root) { throw new Error("KB local root unavailable (configure kbRoot + MANIFEST paths.local)"); } return root; } function ensureDraftBody(body) { const trimmed = body.replace(/^\uFEFF/, ""); if (/^---\r?\n[\s\S]*?\r?\nstatus:\s*draft\b/m.test(trimmed) || /^---\r?\n[\s\S]*?\nstatus:\s*["']?draft["']?\s*$/m.test(trimmed)) { return trimmed.endsWith(` `) ? trimmed : `${trimmed} `; } if (trimmed.startsWith("---")) { const end = trimmed.indexOf(` ---`, 3); if (end !== -1) { const fm = trimmed.slice(0, end + 4); const rest = trimmed.slice(end + 4); if (/\nstatus:\s*/.test(fm)) return trimmed.endsWith(` `) ? trimmed : `${trimmed} `; const injected = fm.replace(/^---\r?\n/, `--- status: draft `); const out = `${injected}${rest}`; return out.endsWith(` `) ? out : `${out} `; } } return `--- status: draft --- ${trimmed.endsWith(` `) ? trimmed : `${trimmed} `}`; } 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) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } mkdirSync(dirname2(target.absolutePath), { recursive: true }); const existed = existsSync3(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); return { ok: true, action: existed ? "updated" : "created", absolutePath: target.absolutePath, relativePath: target.relativePath }; } 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) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } mkdirSync(dirname2(target.absolutePath), { recursive: true }); const existed = existsSync3(target.absolutePath); writeFileSync(target.absolutePath, ensureDraftBody(args.body), "utf8"); return { ok: true, action: existed ? "updated" : "created", absolutePath: target.absolutePath, relativePath: target.relativePath }; } 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) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } mkdirSync(dirname2(target.absolutePath), { recursive: true }); const existed = existsSync3(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); return { ok: true, action: existed ? "updated" : "created", absolutePath: target.absolutePath, relativePath: target.relativePath }; } 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) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } mkdirSync(dirname2(target.absolutePath), { recursive: true }); const existed = existsSync3(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); return { ok: true, action: existed ? "updated" : "created", absolutePath: target.absolutePath, relativePath: target.relativePath }; } function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); if (!existsSync3(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"); return { ok: true, action: "updated", absolutePath: target.absolutePath, relativePath: target.relativePath }; } function deleteLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); if (!existsSync3(target.absolutePath)) { throw new Error(`file not found: ${target.relativePath}`); } rmSync(target.absolutePath, { force: false }); return { ok: true, action: "deleted", absolutePath: target.absolutePath, relativePath: target.relativePath }; } function walkFiles(dir, baseLocal, out) { let entries; try { entries = readdirSync(dir); } catch { return; } for (const name of entries) { if (name === "." || name === "..") continue; const full = join3(dir, name); let st; try { st = statSync2(full); } catch { continue; } if (st.isDirectory()) { walkFiles(full, baseLocal, out); } else if (st.isFile() && name.toLowerCase().endsWith(".md")) { const rel = full.slice(baseLocal.length).replace(/^[/\\]/, "").split(/[/\\]/).join("/"); out.push({ absolutePath: full, relativePath: rel, mtimeMs: st.mtimeMs, size: st.size }); } } } function listLocalFiles(snapshot, args = {}) { const localRoot = requireLocalRoot(snapshot); const roots = args.root && args.root !== "all" ? [args.root] : [...KB_LOCAL_WRITABLE_ROOTS]; const entries = []; for (const r of roots) { walkFiles(join3(localRoot, r), localRoot, entries); } entries.sort((a, b) => b.mtimeMs - a.mtimeMs); const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); return { ok: true, localRoot, entries: entries.slice(0, limit) }; } // src/netx/kb-manifest.ts var EMPTY_CONTENT = { hasRegions: false, hasTheory: false, hasPacket: false, hasCommon: false, hasSkills: false }; function unconfiguredKbSnapshot() { return { status: "unconfigured", realRoot: "", operatorName: "", country: "", version: "", content: { ...EMPTY_CONTENT }, paths: {}, errorMessage: "" }; } // src/netx/kb-runtime.ts var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); function store() { const root = globalThis; let current = root[STORE_KEY]; if (current === undefined) { current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set }; root[STORE_KEY] = current; } return current; } function getKbContext() { const snap = store().snapshot; return { ...snap, content: { ...snap.content }, paths: { ...snap.paths } }; } function watchKbContext(listener) { const state = store(); state.listeners.add(listener); return () => { state.listeners.delete(listener); }; } // src/netx/kb-local-tools.ts var str = (description) => ({ type: "string", ...description ? { description } : {} }); var bool = (description) => ({ type: "boolean", ...description ? { description } : {} }); var num = (description) => ({ type: "number", ...description ? { description } : {} }); var reqStr = (description) => ({ type: "string", required: true, ...description ? { description } : {} }); function renderJson(_args, value) { return [{ type: "text", text: JSON.stringify(value, null, 0) }]; } var jsonOut = { schema: { type: "json" }, render: renderJson }; function liveSnapshot() { return getKbContext(); } function tool(name, description, parameters, execute) { return defineTool({ name, description, parameters, output: jsonOut, timeoutMs: 30000, isConcurrencySafe: () => false, async execute(args) { try { return await execute(args); } catch (error) { throw new Error(error instanceof Error ? error.message : String(error)); } } }); } function registerKbLocalTools(ctx, snapshot = getKbContext()) { if (!kbLocalToolsEnabled(snapshot)) return () => {}; const localRoot = resolveKbLocalRoot(snapshot); const toolsApi = ctx.tools; if (!toolsApi || typeof toolsApi.register !== "function") return () => {}; const entries = [ tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", { bucket: { type: "string", required: true, enum: ["note", "rca_review", "ai_trace"], description: "Memory subdirectory" }, slug: reqStr("Short filename stem (no path separators)"), body: reqStr("Full markdown body"), date: str("Optional YYYY-MM-DD (default today)"), overwrite: bool("Replace if the target file already exists") }, (args) => createMemory(liveSnapshot(), { bucket: args.bucket, slug: String(args.slug ?? ""), body: String(args.body ?? ""), date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", { slug: reqStr("Case stem (DRAFT- prefix added if missing)"), body: reqStr("Markdown body (RCA-ish draft)"), domain: str("Optional fault-domain subfolder under drafts/"), date: str("Optional event date YYYY-MM-DD or YYYYMMDD"), overwrite: bool("Replace if the target file already exists") }, (args) => createDraft(liveSnapshot(), { slug: String(args.slug ?? ""), body: String(args.body ?? ""), domain: args.domain != null ? String(args.domain) : undefined, date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", { kind: { type: "string", required: true, enum: ["theory", "improvement"], description: "theory = knowledge fix; improvement = tools/skills/process" }, slug: reqStr("Short filename stem"), body: reqStr("Full markdown body"), date: str("Optional YYYY-MM-DD (default today)"), overwrite: bool("Replace if the target file already exists") }, (args) => createSuggestion(liveSnapshot(), { kind: args.kind, slug: String(args.slug ?? ""), body: String(args.body ?? ""), date: args.date != null ? String(args.date) : undefined, overwrite: args.overwrite === true })), tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", { area: { type: "string", required: true, enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"], description: "refs/ subtree" }, slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"), body: reqStr("Full markdown body"), device: str("Required when area=devices — host_name (never UUID)"), overwrite: bool("Replace if the target file already exists") }, (args) => createRef(liveSnapshot(), { area: args.area, slug: String(args.slug ?? ""), body: String(args.body ?? ""), device: args.device != null ? String(args.device) : undefined, overwrite: args.overwrite === true })), tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", { path: reqStr("Absolute path or path relative to KB local root"), body: reqStr("New full markdown body") }, (args) => updateLocalFile(liveSnapshot(), { path: String(args.path ?? ""), body: String(args.body ?? "") })), tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, { path: reqStr("Absolute path or path relative to KB local root") }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { root: { type: "string", enum: ["memories", "drafts", "suggestions", "refs", "all"], description: "Subtree to list (default all writable)" }, limit: num("Max entries 1–200 (default 50)") }, (args) => listLocalFiles(liveSnapshot(), { root: args.root ?? "all", limit: typeof args.limit === "number" ? args.limit : undefined })) ]; const disposers = entries.map((entry) => toolsApi.register(entry)); ctx.logger.info("netxops: kb local tools registered count=%s localRoot=%s", disposers.length, localRoot); return () => { for (const dispose of disposers) dispose(); }; } // src/netx/kb-pack-skills.ts import { readdir as readdir2, stat as stat2 } from "node:fs/promises"; import { join as join4 } from "node:path"; function kbPackSkillsEnabled(snapshot) { return snapshot.status === "configured" && snapshot.content.hasSkills === true; } function resolveKbSkillsRoot(snapshot) { const rel = snapshot.paths.skills?.trim() || "_skills"; return join4(snapshot.realRoot, rel); } function resolveKbLocalSkillsRoot(snapshot) { const rel = snapshot.paths.localSkills?.trim(); if (!rel) return null; return join4(snapshot.realRoot, rel); } async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; try { entries = await readdir2(root); } catch { if (options.logMissing) { ctx.logger.info("netxops: kb %s dir missing at %s — skip", options.label, root); } return []; } if (entries.length === 0) return []; const disposers = []; for (const entry of entries) { const full = join4(root, entry); let isDir = false; try { isDir = (await stat2(full)).isDirectory(); } catch { continue; } if (!isDir) continue; let skill; try { skill = await loadSkillBundle(full); } catch (error) { ctx.logger.warn("netxops: kb %s skill load failed at %s: %s", options.label, full, error instanceof Error ? error.message : String(error)); continue; } if (!skill) { ctx.logger.warn("netxops: kb %s skill skipped (invalid SKILL.md): %s", options.label, full); continue; } disposers.push(skillsApi.register({ name: skill.name, description: skill.description, content: skill.content, path: skill.path, resourceBase: { kind: "directory", path: skill.directory }, provider, source: "custom" })); } return disposers; } async function registerKbPackSkills(ctx, snapshot, options) { if (!options.enabled || !kbPackSkillsEnabled(snapshot)) { return () => {}; } const skillsApi = ctx.skills; if (!skillsApi || typeof skillsApi.register !== "function") { return () => {}; } const provider = options.providerLabel ?? "netxops-kb-pack"; const disposers = []; const skillsRoot = resolveKbSkillsRoot(snapshot); disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { logMissing: true, label: "pack" })); const localRoot = resolveKbLocalSkillsRoot(snapshot); if (localRoot) { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, { logMissing: false, label: "local" })); } if (disposers.length > 0) { ctx.logger.info("netxops: kb pack skills registered count=%s skillsRoot=%s localSkills=%s provider=%s", disposers.length, skillsRoot, localRoot ?? "(none)", provider); } return () => { for (const dispose of disposers) dispose(); }; } // src/netx/runtime.ts var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); function store2() { const root = globalThis; let current = root[STORE_KEY2]; if (current === undefined) { current = { connection: undefined, listeners: new Set }; root[STORE_KEY2] = current; } return current; } function getNetxConnection() { return store2().connection; } function watchNetxConnection(listener) { const state = store2(); state.listeners.add(listener); return () => { state.listeners.delete(listener); }; } // src/netx/tools.ts import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; // src/netx/http.ts var PROTOCOL_KEY_ZH_TO_EN = { 其他: "Other", 时钟: "Clock", "OTN/光": "OTN/Optical", 电源: "Power" }; function localizePayload(lang, data) { if (!lang.trim().toLowerCase().startsWith("en")) return data; const proto = data.protocol_summary; if (!Array.isArray(proto)) return data; for (const row of proto) { if (typeof row !== "object" || row === null || Array.isArray(row)) continue; const rec = row; const key = typeof rec.key === "string" ? rec.key : ""; const mapped = PROTOCOL_KEY_ZH_TO_EN[key]; if (mapped !== undefined) rec.key = mapped; } return data; } function encodeQuery(params) { const sp = new URLSearchParams; for (const [key, value] of Object.entries(params)) { sp.set(key, String(value)); } const q = sp.toString(); return q.length > 0 ? `?${q}` : ""; } function createNetxClient(connection) { const base = connection.apiUrl.replace(/\/$/, ""); const langParams = () => { const lang = connection.lang.trim().toLowerCase(); if (lang.startsWith("en")) return { lang: "en" }; return {}; }; async function request(method, path, options = {}) { const token = connection.getToken().trim(); if (token.length === 0) { return { ok: false, error: "netx_token_missing", detail: "Set credential NETX_API_TOKEN (Plugins → Netx Ops or scripts/set-netx-token)." }; } const merged = { ...langParams(), ...options.params }; const url = `${base}${path}${encodeQuery(merged)}`; const timeoutMs = options.timeoutMs ?? connection.timeoutMs; const controller = new AbortController; const timer = setTimeout(() => { controller.abort(); }, timeoutMs); const onOuterAbort = () => { controller.abort(); }; options.signal?.addEventListener("abort", onOuterAbort, { once: true }); try { const headers = { accept: "application/json", authorization: `Bearer ${token}` }; if (options.body !== undefined) headers["content-type"] = "application/json"; const init = { method, headers, signal: controller.signal }; if (options.body !== undefined) init.body = JSON.stringify(options.body); const resp = await fetch(url, init); const text = await resp.text(); if (!resp.ok) { return { ok: false, error: `netx_http_${resp.status}`, detail: text.slice(0, 800) }; } const data = text.length > 0 ? JSON.parse(text) : {}; if (typeof data === "object" && data !== null && !Array.isArray(data)) { return { ok: true, data: localizePayload(connection.lang, data) }; } return { ok: true, data: { raw: data } }; } catch (error) { const detail = error instanceof Error ? error.message : String(error); return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) }; } finally { clearTimeout(timer); options.signal?.removeEventListener("abort", onOuterAbort); } } return { get(path, params, signal, timeoutMs) { return request("GET", path, { params, signal, timeoutMs }); }, post(path, body, signal, timeoutMs) { return request("POST", path, { body, signal, timeoutMs }); }, patch(path, body, signal, timeoutMs) { return request("PATCH", path, { body, signal, timeoutMs }); } }; } function quoteNeId(neId) { return encodeURIComponent(neId.trim()); } // src/netx/handlers.ts var EXEC_MAX_COMMANDS = 5; var UME_RAW_FIELD_PRESETS = { brief: [ "alarm_alarm_key", "alarm_host_name", "alarm_perceived_severity", "alarm_event_type", "alarm_last_seen_at", "ne_host_name", "ne_user_label", "ne_ne_name", "ne_ip_address", "ne_exists" ], evidence: [ "alarm_alarm_key", "alarm_host_name", "alarm_object_name", "alarm_event_type", "alarm_native_probable_cause", "alarm_perceived_severity", "alarm_is_cleared", "alarm_time_created", "alarm_last_seen_at", "ne_host_name", "ne_user_label", "ne_ne_name", "ne_ip_address", "ne_connection_status", "ne_exists" ], ne_debug: [ "alarm_alarm_key", "alarm_ne_id", "alarm_perceived_severity", "alarm_last_seen_at", "ne_user_label", "ne_ne_name", "ne_ip_address", "ne_ipv6_address", "ne_device_level", "ne_host_name", "ne_connection_status", "ne_admin_status", "ne_address_type", "ne_maintain_status", "ne_exists" ] }; function asRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; } function str2(args, key, fallback = "") { const v = args[key]; if (typeof v === "string") return v; if (typeof v === "number" || typeof v === "boolean") return String(v); return fallback; } function nmsOrUme(args, nmsKey, umeKey) { return str2(args, nmsKey).trim() || str2(args, umeKey).trim(); } function nmsOrUmeList(args, nmsKey, umeKey) { const primary = strList(args, nmsKey); return primary.length > 0 ? primary : strList(args, umeKey); } function num2(args, key) { const v = args[key]; return typeof v === "number" && Number.isFinite(v) ? v : undefined; } function bool2(args, key) { const v = args[key]; return typeof v === "boolean" ? v : undefined; } function strList(args, key) { const v = args[key]; if (!Array.isArray(v)) return []; return v.map((x) => String(x).trim()).filter((x) => x.length > 0); } function clampInt(value, fallback, min, max) { const n = value === undefined ? fallback : Math.trunc(value); return Math.max(min, Math.min(max, n)); } function putStr(params, args, keys) { for (const key of keys) { const v = str2(args, key).trim(); if (v) params[key] = v; } } async function queryUmeAlarms(client, args, signal) { let page = clampInt(num2(args, "page"), 1, 1, 2); const pageSize = clampInt(num2(args, "page_size"), 50, 1, 500); const params = { page, page_size: pageSize }; putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]); const keyword = str2(args, "keyword").trim(); const neName = str2(args, "ne_name").trim(); if (keyword) params.keyword = keyword; else if (neName) params.keyword = neName; return client.get("/v1/ume/alarms", params, signal); } async function aggregateUmeAlarmsRaw(client, args, signal) { const params = {}; putStr(params, args, [ "group_by", "group_by2", "severity", "is_cleared", "ne_id", "event_type", "keyword", "time_from", "time_to", "limit" ]); if ("exclude_missing_host" in args) { const flag = bool2(args, "exclude_missing_host"); if (flag !== undefined) params.exclude_missing_host = flag; } return client.get("/v1/ume/alarms/aggregate/raw", params, signal); } async function aggregateUmeAlarms(client, args, signal) { if (str2(args, "group_by").trim()) return aggregateUmeAlarmsRaw(client, args, signal); const topNe = clampInt(num2(args, "top_ne"), 50, 0, 500); const params = { top_ne: topNe }; if ("exclude_missing_host" in args) { const flag = bool2(args, "exclude_missing_host"); if (flag !== undefined) params.exclude_missing_host = flag; } putStr(params, args, ["severity", "time_from", "time_to"]); return client.get("/v1/ume/alarms/aggregate", params, signal); } async function runUmeDiagnostics(client, _args, signal) { return client.get("/v1/ume/diagnostics", undefined, signal); } async function queryUmeNeInventory(client, args, signal) { const params = { page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), page_size: clampInt(num2(args, "page_size"), 50, 1, 500) }; putStr(params, args, ["keyword"]); return client.get("/v1/ume/inventory/ne", params, signal); } async function getUmeNe(client, args, signal) { const neId = str2(args, "ne_id").trim(); if (!neId) return { ok: false, error: "ne_id_required", error_code: "ne_id_required" }; return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal); } async function queryUmeAlarmsRaw(client, args, signal) { const params = { page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), page_size: clampInt(num2(args, "page_size"), 50, 1, 500) }; putStr(params, args, [ "severity", "is_cleared", "ne_id", "event_type", "keyword", "time_from", "time_to", "order_by", "order" ]); let fields = strList(args, "select_fields"); if (fields.length === 0) { const preset = str2(args, "field_preset").trim().toLowerCase(); fields = UME_RAW_FIELD_PRESETS[preset] ?? []; } if (fields.length > 0) params.select_fields = fields.join(","); return client.get("/v1/ume/alarms/raw", params, signal); } async function listUmeAlarmFields(client, _args, signal) { return client.get("/v1/ume/alarms/fields", undefined, signal); } async function sqlQueryUme(client, args, signal) { const sql = str2(args, "sql").trim(); if (!sql) return { ok: false, error: "sql_required" }; const limit = clampInt(num2(args, "limit"), 200, 1, 2000); const statementTimeoutMs = clampInt(num2(args, "statement_timeout_ms"), 0, 0, 30000); return client.post("/v1/sql/ume_query", { sql, limit, statement_timeout_ms: statementTimeoutMs }, signal, 60000); } async function listManagedNe(client, args, signal) { const keyword = str2(args, "keyword").trim(); const vendor = str2(args, "vendor").trim(); const connectStatus = str2(args, "connect_status").trim(); if (!(keyword || vendor || connectStatus)) { return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" }; } if (keyword && keyword.length < 2) { return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; } const params = { page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), page_size: clampInt(num2(args, "page_size"), 20, 1, 100) }; if (keyword) params.keyword = keyword; if (vendor) params.vendor = vendor; if (connectStatus) params.connect_status = connectStatus; return client.get("/v1/managed-ne", params, signal); } async function getManagedNe(client, args, signal) { const neId = (str2(args, "ne_id") || str2(args, "managed_ne_id") || str2(args, "id")).trim(); if (!neId) { return { ok: false, error: "ne_id_required", error_code: "ne_id_required", hint: "Pass managed NE id from listManagedNe/listCliTargets (source=managed). For NMS inventory UUIDs use execManagedNe(nms_ne_id=...) or getNmsNe, not getManagedNe.", example: { ne_id: "" } }; } const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); if (out.ok === false) { const detail = `${str2(out, "detail")}${str2(out, "error")}`.toLowerCase(); if (detail.includes("404") || detail.includes("not_found") || detail.includes("not found") || out.error === "netx_http_404") { return { ...out, hint: "Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is an NMS inventory id, use execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe." }; } } return out; } async function execManagedNe(client, args, signal) { const targetsRaw = args.targets; const neIds = strList(args, "ne_ids"); const umeNeIds = nmsOrUmeList(args, "nms_ne_ids", "ume_ne_ids"); const sharedCommands = strList(args, "commands"); const multi = Array.isArray(targetsRaw) && targetsRaw.length > 0 || neIds.length > 0 || umeNeIds.length > 0; if (multi) { const body2 = {}; if (Array.isArray(targetsRaw) && targetsRaw.length > 0) { const cleaned = []; for (const t of targetsRaw) { if (typeof t !== "object" || t === null || Array.isArray(t)) continue; const row = t; const item = {}; const neId2 = str2(row, "ne_id").trim(); const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); if (neId2) item.ne_id = neId2; if (umeNeId2) item.ume_ne_id = umeNeId2; const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : []; if (cmds.length > 0) item.commands = cmds; if (Object.keys(item).length > 0) cleaned.push(item); } body2.targets = cleaned; } if (neIds.length > 0) body2.ne_ids = neIds; if (umeNeIds.length > 0) body2.ume_ne_ids = umeNeIds; if (sharedCommands.length > 0) { if (sharedCommands.length > EXEC_MAX_COMMANDS) { return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; } body2.commands = sharedCommands; } body2.read_timeout_sec = clampInt(num2(args, "read_timeout_sec"), 60, 10, 120); const concurrency = num2(args, "concurrency"); if (concurrency !== undefined) body2.concurrency = clampInt(concurrency, 4, 1, 8); const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); if (out2.ok !== true) return out2; const data2 = asRecord(out2.data); if (data2.ok === false) { return { ok: false, data: data2, error: str2(data2, "error", "exec_batch_failed") }; } return { ok: true, data: data2 }; } const neId = str2(args, "ne_id").trim(); const umeNeId = nmsOrUme(args, "nms_ne_id", "ume_ne_id"); if (Boolean(neId) === Boolean(umeNeId)) { return { ok: false, error: "exactly_one_of_ne_id_or_nms_ne_id_required", error_code: "exactly_one_of_ne_id_or_nms_ne_id_required", hint: "For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass ne_ids / nms_ne_ids with shared commands, or targets[] — one call, concurrent on server." }; } if (sharedCommands.length === 0) { return { ok: false, error: "commands_required", error_code: "commands_required" }; } if (sharedCommands.length > EXEC_MAX_COMMANDS) { return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; } const body = { commands: sharedCommands, read_timeout_sec: clampInt(num2(args, "read_timeout_sec"), 60, 10, 120) }; if (neId) body.ne_id = neId; if (umeNeId) body.ume_ne_id = umeNeId; const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); if (out.ok !== true) return out; const data = asRecord(out.data); if (data.ok === false) { return { ok: false, data, error: str2(data, "error", "exec_failed") }; } return { ok: true, data }; } async function listCliTargets(client, args, signal) { const params = { page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), page_size: clampInt(num2(args, "page_size"), 50, 1, 500) }; putStr(params, args, ["source", "keyword"]); return client.get("/v1/cli/targets", params, signal); } async function findTopologyPaths(client, args, signal) { const fromUid = nmsOrUme(args, "from_nms_ne_id", "from_ume_ne_id"); const fromMid = str2(args, "from_managed_ne_id").trim(); const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); const toMid = str2(args, "to_managed_ne_id").trim(); if (Boolean(fromUid) === Boolean(fromMid)) { return { ok: false, error: "exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required" }; } if (Boolean(toUid) === Boolean(toMid)) { return { ok: false, error: "exactly_one_of_to_nms_ne_id_or_to_managed_ne_id_required" }; } let detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; if (detail !== "summary" && detail !== "full") detail = "summary"; const body = { max_paths: clampInt(num2(args, "max_paths"), 3, 1, 10), max_hops: clampInt(num2(args, "max_hops"), 6, 1, 12), layer: str2(args, "layer", "physical").trim() || "physical", detail }; if (fromUid) body.from_ume_ne_id = fromUid; else body.from_managed_ne_id = fromMid; if (toUid) body.to_ume_ne_id = toUid; else body.to_managed_ne_id = toMid; return client.post("/v1/topology/fabric/paths", body, signal, 30000); } function taskBriefNe(brief) { const rec = asRecord(brief); const name = typeof rec.ne_name === "string" ? rec.ne_name.trim() : ""; const ip = typeof rec.ne_ip === "string" ? rec.ne_ip.trim() : ""; if (name && ip) return `${name} (${ip})`; return name || ip; } function slimMigrationProject(row) { const mt = asRecord(row.monitor_template); return { id: row.id, name: row.name, status: row.status, note: row.note, monitor_template_id: row.monitor_template_id, monitor_template_name: typeof mt.name === "string" ? mt.name : "", old_task_id: row.old_task_id, new_task_id: row.new_task_id, old_hf_task_id: row.old_hf_task_id, new_hf_task_id: row.new_hf_task_id, old_ne: taskBriefNe(row.old_task), new_ne: taskBriefNe(row.new_task), hf_interval_sec: row.hf_interval_sec, hf_start_at: row.hf_start_at, hf_end_at: row.hf_end_at, updated_at: row.updated_at }; } function matchesBizMonitorQ(row, q) { if (!q) return true; const hay = [ row.id, row.name, row.note, row.status, row.ne_name, row.ne_ip, row.ne_id, row.old_ne, row.new_ne, row.monitor_template_name, row.purpose, row.vendor ].map((v) => typeof v === "string" || typeof v === "number" ? String(v).toLowerCase() : "").join(" "); return hay.includes(q); } async function listBizMonitors(client, args, signal) { const kindRaw = str2(args, "kind", "all").trim().toLowerCase() || "all"; const kind = kindRaw === "project" ? "projects" : kindRaw === "task" ? "tasks" : kindRaw; if (kind !== "all" && kind !== "projects" && kind !== "tasks") { return { ok: false, error: "kind_invalid", detail: "kind must be all|projects|tasks" }; } const purpose = str2(args, "purpose").trim(); const status = str2(args, "status").trim().toLowerCase(); const q = str2(args, "q").trim().toLowerCase(); const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500); const wantProjects = kind === "all" || kind === "projects"; const wantTasks = kind === "all" || kind === "tasks"; const fetches = []; if (wantProjects) fetches.push(client.get("/v1/biz-migration/projects", {}, signal)); else fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); if (wantTasks) { const params = {}; if (purpose) params.purpose = purpose; fetches.push(client.get("/v1/biz-state/tasks", params, signal)); } else { fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); } const [projRes, taskRes] = await Promise.all(fetches); if (wantProjects && projRes.ok === false) return projRes; if (wantTasks && taskRes.ok === false) return taskRes; const projItems = Array.isArray(asRecord(projRes.data).items) ? asRecord(projRes.data).items : []; const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : []; let projects = projItems.map((row) => slimMigrationProject(asRecord(row))).filter((row) => { if (status && String(row.status || "").toLowerCase() !== status) return false; return matchesBizMonitorQ(row, q); }); let tasks = taskItems.map((row) => asRecord(row)).filter((row) => { if (status && String(row.status || "").toLowerCase() !== status) return false; return matchesBizMonitorQ(row, q); }); const projectsTotal = projects.length; const tasksTotal = tasks.length; projects = projects.slice(0, limit); tasks = tasks.slice(0, limit); return { ok: true, data: { kind, projects: wantProjects ? projects : undefined, tasks: wantTasks ? tasks : undefined, counts: { projects: wantProjects ? projectsTotal : 0, tasks: wantTasks ? tasksTotal : 0, projects_returned: wantProjects ? projects.length : 0, tasks_returned: wantTasks ? tasks.length : 0 }, next: "Pass project_id (or task_id) to netx__getBizMonitorContext for the full definition bundle." } }; } async function getBizMonitorContext(client, args, signal) { const projectId = str2(args, "project_id").trim(); const taskId = str2(args, "task_id").trim(); if (!projectId && !taskId) { return { ok: false, error: "project_id_or_task_id_required" }; } const params = {}; if (projectId) params.project_id = projectId; if (taskId) params.task_id = taskId; return client.get("/v1/biz-migration/monitor-context", params, signal); } async function getBizMonitorBoard(client, args, signal) { const batchId = str2(args, "batch_id").trim(); if (!batchId) return { ok: false, error: "batch_id_required" }; const params = {}; const runId = str2(args, "run_id").trim(); if (runId) params.run_id = runId; return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal); } async function listBizMonitorReds(client, args, signal) { const projectId = str2(args, "project_id").trim(); if (!projectId) return { ok: false, error: "project_id_required" }; const params = {}; putStr(params, args, ["status"]); const limit = num2(args, "limit"); if (limit !== undefined) params.limit = clampInt(limit, 200, 1, 500); return client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`, params, signal); } async function getBizMonitorDiffs(client, args, signal) { const runId = str2(args, "run_id").trim(); if (!runId) return { ok: false, error: "run_id_required" }; const params = {}; putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]); const limit = num2(args, "limit"); if (limit !== undefined) params.limit = clampInt(limit, 200, 1, 500); const offset = num2(args, "offset"); if (offset !== undefined) params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER); return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal); } async function getBizCollectBatch(client, args, signal) { const batchId = str2(args, "batch_id").trim(); if (!batchId) return { ok: false, error: "batch_id_required" }; return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal); } async function getBizCollectCommandRaw(client, args, signal) { const batchId = str2(args, "batch_id").trim(); const commandId = str2(args, "command_id").trim(); if (!batchId || !commandId) { return { ok: false, error: "batch_id_and_command_id_required" }; } return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, {}, signal); } // src/netx/topology-handlers.ts function asRecord2(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; } function str3(args, key, fallback = "") { const v = args[key]; if (typeof v === "string") return v; if (typeof v === "number" || typeof v === "boolean") return String(v); return fallback; } function num3(args, key) { const v = args[key]; return typeof v === "number" && Number.isFinite(v) ? v : undefined; } function bool3(args, key) { const v = args[key]; return typeof v === "boolean" ? v : undefined; } function strList2(args, key) { const v = args[key]; if (!Array.isArray(v)) return []; return v.map((x) => String(x).trim()).filter((x) => x.length > 0); } function clampInt2(value, fallback, min, max) { const n = value === undefined ? fallback : Math.trunc(value); return Math.max(min, Math.min(max, n)); } function unwrap(out) { if (out.ok !== true) return out; const data = asRecord2(out.data); if (Object.keys(data).length === 0) return { ok: true, data: out.data }; return { ok: true, ...data, data }; } function filterFields(args) { const out = {}; for (const key of ["keyword", "role", "vendor", "link_status"]) { const v = str3(args, key).trim(); if (v) out[key] = v; } return out; } function edgeEndpoints(edge) { const a = str3(edge, "a_node_id") || str3(edge, "a"); const b = str3(edge, "b_node_id") || str3(edge, "b"); return [a.trim(), b.trim()]; } function collapseEdgesToLinks(edges, includeNames = false) { const buckets = new Map; for (const raw of edges) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue; const e = raw; const [a, b] = edgeEndpoints(e); if (!a || !b || a === b) continue; const lo = a < b ? a : b; const hi = a < b ? b : a; const key = `${lo}\x00${hi}`; const row = buckets.get(key) ?? { a_node_id: lo, b_node_id: hi, link_count: 0 }; row.link_count = Number(row.link_count || 0) + 1; if (includeNames && !row.a_name) { if (a === lo) { row.a_name = str3(e, "a_name"); row.b_name = str3(e, "b_name"); } else { row.a_name = str3(e, "b_name"); row.b_name = str3(e, "a_name"); } } buckets.set(key, row); } return [...buckets.values()]; } function summarizeViewGraph(graph, sample = 20) { const nodes = Array.isArray(graph.nodes) ? graph.nodes : []; const edges = Array.isArray(graph.edges) ? graph.edges : Array.isArray(graph.links) ? graph.links : []; const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => { const row = asRecord2(n); return { id: str3(row, "id") || str3(row, "fabric_node_id"), name: str3(row, "name"), x: row.x, y: row.y, level: row.level, role: row.role }; }); return { ok: true, view_id: str3(graph, "id") || str3(graph, "view_id"), node_count: nodes.length, edge_count: edges.length, link_count: collapseEdgesToLinks(edges).length, links: collapseEdgesToLinks(edges), sample_nodes: sampleNodes, detail: "summary" }; } function layoutRecipeUnavailable(action) { return { ok: false, error: "layout_recipe_requires_netx_topology_mcp", action, hint: "Canvas CRUD/query and move_nodes work in dsh-netxops. Dual-unit / orbit / polish recipes still need the netx-topology MCP layout engine (or enable that MCP alongside)." }; } async function getTopologyTree(client, args, signal) { const out = unwrap(await client.get("/v1/topology/tree", undefined, signal)); if (out.ok !== true) return out; const compact = bool3(args, "compact") !== false; const maxDepth = num3(args, "max_depth"); if (!compact && maxDepth === undefined) return out; return { ...out, compact, max_depth: maxDepth ?? null }; } async function getTopologyView(client, args, signal) { const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); if (out.ok !== true) return out; const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; if (detail === "full") return { ...out, detail: "full" }; return summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 200)); } async function createTopologyFolder(client, args, signal) { const name = str3(args, "name").trim(); if (!name) return { ok: false, error: "name_required" }; const body = { name }; const parentId = str3(args, "parent_id").trim(); if (parentId) body.parent_id = parentId; const locale = str3(args, "locale").trim(); if (locale) body.locale = locale; if (num3(args, "sort_order") !== undefined) body.sort_order = Math.trunc(num3(args, "sort_order")); return unwrap(await client.post("/v1/topology/folders", body, signal)); } async function addTopologyViewNodes(client, args, signal) { const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; if (strList2(args, "managed_ne_ids").length || strList2(args, "ume_ne_ids").length) { return { ok: false, error: "fabric_nodes_only", detail: "Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids." }; } const filters = filterFields(args); const fabricIds = strList2(args, "fabric_node_ids"); if (Object.keys(filters).length === 0 && fabricIds.length === 0) { return { ok: false, error: "filter_or_fabric_node_ids_required", detail: "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids." }; } if (num3(args, "max_nodes") !== undefined) { const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal)); if (got.ok === true) { const data = asRecord2(got.data); const filt = asRecord2(got.filter ?? data.filter); const membership = asRecord2(filt.membership ?? {}); membership.max_nodes = clampInt2(num3(args, "max_nodes"), 2000, 1, 2000); filt.membership = membership; await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal); } } const layout = str3(args, "layout", "grid").trim() || "grid"; const body = { managed_ne_ids: [], layout, ...filters }; if (fabricIds.length) body.fabric_node_ids = fabricIds; if (num3(args, "limit") !== undefined) body.limit = clampInt2(num3(args, "limit"), 500, 1, 2000); if (num3(args, "offset") !== undefined) body.offset = clampInt2(num3(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180000)); } async function removeTopologyViewNodes(client, args, signal) { const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; const body = { ...filterFields(args) }; const fabricIds = strList2(args, "fabric_node_ids"); if (fabricIds.length) body.fabric_node_ids = fabricIds; return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, body, signal)); } async function copyTopologyViewNodes(client, args, signal) { const sourceId = str3(args, "source_view_id").trim(); const targetId = str3(args, "target_view_id").trim(); if (!sourceId || !targetId) { return { ok: false, error: "source_view_id_and_target_view_id_required" }; } const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(sourceId)}`, undefined, signal, 120000)); if (source.ok !== true) return source; const nodes = Array.isArray(source.nodes) ? source.nodes : []; const limit = num3(args, "limit"); const selected = typeof limit === "number" ? nodes.slice(0, clampInt2(limit, nodes.length, 1, 2000)) : nodes; const fabricIds = selected.map((n) => { const row = asRecord2(n); return str3(row, "id") || str3(row, "fabric_node_id"); }).filter(Boolean); if (bool3(args, "dry_run") === true) { return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }; } if (bool3(args, "clear_target") === true) { await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, { fabric_node_ids: "ALL", clear_all: true }, signal); } if (fabricIds.length === 0) { return { ok: true, added: 0, source_view_id: sourceId, target_view_id: targetId }; } const add = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes`, { fabric_node_ids: fabricIds, layout: "keep", managed_ne_ids: [] }, signal, 180000)); if (add.ok !== true) return add; if (bool3(args, "copy_positions") !== false) { const ox = num3(args, "offset_x") ?? 0; const oy = num3(args, "offset_y") ?? 0; const positions = selected.map((n) => { const row = asRecord2(n); const id = str3(row, "id") || str3(row, "fabric_node_id"); return { fabric_node_id: id, x: Number(row.x || 0) + ox, y: Number(row.y || 0) + oy }; }).filter((p) => p.fabric_node_id); if (positions.length) { await client.patch(`/v1/topology/views/${encodeURIComponent(targetId)}/positions`, { positions }, signal, 120000); } } return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; } async function updateTopologyViewPositions(client, args, signal) { const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; const body = {}; if (Array.isArray(args.positions)) body.positions = args.positions; const layout = str3(args, "layout").trim(); if (layout) body.layout = layout; Object.assign(body, filterFields(args)); for (const key of ["offset_x", "offset_y", "cols", "gap_x", "gap_y"]) { if (num3(args, key) !== undefined) body[key] = num3(args, key); } const fabricIds = strList2(args, "fabric_node_ids"); if (fabricIds.length) body.fabric_node_ids = fabricIds; return unwrap(await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, body, signal, 120000)); } async function projectTopologyNeighbors(client, args, signal) { const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; const body = {}; const seeds = strList2(args, "seed_fabric_node_ids").length ? strList2(args, "seed_fabric_node_ids") : strList2(args, "fabric_node_ids"); if (seeds.length) body.seed_fabric_node_ids = seeds; const mids = strList2(args, "managed_ne_ids"); if (mids.length) body.managed_ne_ids = mids; const region = str3(args, "region_folder_id").trim(); if (region) body.region_folder_id = region; const out = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, body, signal, 180000)); if (out.ok !== true) return out; const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; if (detail === "full" || detail === "raw" || detail === "graph") { return { ...out, detail: "full", projected: true }; } return { ...summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; } async function queryTopologyFabricNodes(client, args, signal) { let mode = str3(args, "mode").trim().toLowerCase(); const q = str3(args, "q").trim(); const keyword = str3(args, "keyword").trim(); if (!mode) { if (bool3(args, "summary") === true) mode = "summary"; else if (q) mode = "search"; else mode = "list"; } if (mode === "summary" || mode === "stats" || mode === "count") { const out2 = unwrap(await client.get("/v1/topology/fabric/summary", undefined, signal)); return out2.ok === true ? { ...out2, mode: "summary" } : out2; } if (mode === "search" || mode === "find") { const needle = q || keyword; if (!needle) return { ok: false, error: "q_required", hint: "mode=search needs q (or keyword)." }; return unwrap(await client.get("/v1/topology/fabric/nodes/search", { q: needle, page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 200) }, signal)); } const params = { page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 500) }; for (const key of ["keyword", "role", "level", "level_major", "region_folder_id", "link_status"]) { const v = str3(args, key).trim() || (key === "keyword" ? q : ""); if (v) params[key] = v; } const out = unwrap(await client.get("/v1/topology/fabric/nodes", params, signal)); return out.ok === true ? { ...out, mode: "list" } : out; } async function classifyTopologyFabricNodes(client, args, signal) { const action = str3(args, "action").trim().toLowerCase(); if (!action) return { ok: false, error: "action_required" }; if (action === "match") { const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); if (!pattern) return { ok: false, error: "pattern_required" }; return unwrap(await client.post("/v1/topology/fabric/nodes/match", { pattern, match_field: str3(args, "match_field", "name").trim() || "name", sample_limit: clampInt2(num3(args, "sample_limit"), 50, 1, 200) }, signal)); } if (action === "tag") { const body = { fabric_node_ids: strList2(args, "fabric_node_ids"), dry_run: bool3(args, "dry_run") === true }; const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); if (pattern) body.pattern = pattern; if (args.level !== undefined) body.level = args.level; const role = str3(args, "role").trim(); if (role) body.role = role; const region = str3(args, "region_folder_id").trim(); if (region) body.region_folder_id = region; if (bool3(args, "clear_region") === true) body.clear_region = true; return unwrap(await client.post("/v1/topology/fabric/nodes/tags/bulk", body, signal)); } if (action === "patch") { const nodeId = str3(args, "fabric_node_id").trim() || str3(args, "node_id").trim(); if (!nodeId) return { ok: false, error: "fabric_node_id_required" }; const body = {}; if (args.level !== undefined) body.level = args.level; const role = str3(args, "role").trim(); if (role) body.role = role; const region = str3(args, "region_folder_id").trim(); if (region) body.region_folder_id = region; if (bool3(args, "clear_region") === true) body.clear_region = true; return unwrap(await client.patch(`/v1/topology/fabric/nodes/${encodeURIComponent(nodeId)}/tags`, body, signal)); } if (action === "unmatched") { return unwrap(await client.get("/v1/topology/classify/unmatched", { kind: str3(args, "kind", "any").trim() || "any", page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 500) }, signal)); } if (action === "preview_rules") { return unwrap(await client.post("/v1/topology/classify/preview", {}, signal)); } if (action === "apply_rules") { const params = {}; if (bool3(args, "overwrite_manual") === true) params.skip_manual = false; else if (bool3(args, "skip_manual") !== undefined) params.skip_manual = bool3(args, "skip_manual") !== false; if (bool3(args, "fill_empty_only") === true) params.fill_empty_only = true; return unwrap(await client.post("/v1/topology/classify/apply", {}, signal)); } if (action === "list_rules") { return unwrap(await client.get("/v1/topology/classify/rules", undefined, signal)); } return { ok: false, error: "unknown_action", action }; } async function queryTopologyNeighborhood(client, args, signal) { const nodeId = str3(args, "node_id").trim(); if (!nodeId) return { ok: false, error: "node_id_required" }; const out = unwrap(await client.get("/v1/topology/fabric/neighborhood", { node_id: nodeId, depth: clampInt2(num3(args, "depth"), 1, 1, 3), layer: str3(args, "layer", "physical").trim() || "physical" }, signal)); if (out.ok !== true) return out; const edges = Array.isArray(out.edges) ? out.edges : []; return { ...out, links: collapseEdgesToLinks(edges) }; } async function queryTopologyEdges(client, args, signal) { const params = { page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), page_size: clampInt2(num3(args, "page_size"), 100, 1, 500), layer: str3(args, "layer", "physical").trim() || "physical" }; for (const key of ["node_id", "keyword", "status", "source"]) { const v = str3(args, key).trim(); if (v) params[key] = v; } const out = unwrap(await client.get("/v1/topology/fabric/edges", params, signal)); if (out.ok !== true) return out; const detail = str3(args, "detail", "adjacency").trim().toLowerCase() || "adjacency"; const items = Array.isArray(out.items) ? out.items : Array.isArray(out.edges) ? out.edges : []; if (detail === "ports") return { ...out, detail: "ports", items }; const links = collapseEdgesToLinks(items, true); return { ...out, detail: "adjacency", links, items: undefined }; } async function suggestSinkHubs(client, args, signal) { const viewId = str3(args, "source_view_id").trim() || str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "source_view_id_required" }; const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); if (out.ok !== true) return out; const nodes = Array.isArray(out.nodes) ? out.nodes : []; const edges = Array.isArray(out.edges) ? out.edges : []; const degree = new Map; for (const raw of edges) { const [a, b] = edgeEndpoints(asRecord2(raw)); if (!a || !b) continue; degree.set(a, (degree.get(a) || 0) + 1); degree.set(b, (degree.get(b) || 0) + 1); } const exclude = new Set([ ...strList2(args, "exclude_portal_ids"), ...strList2(args, "exclude_fabric_node_ids") ]); const pick = clampInt2(num3(args, "pick"), 5, 1, 50); const hubs = [...degree.entries()].filter(([id]) => !exclude.has(id)).sort((a, b) => b[1] - a[1]).slice(0, pick).map(([fabric_node_id, deg]) => { const node = nodes.find((n) => { const row2 = asRecord2(n); return (str3(row2, "id") || str3(row2, "fabric_node_id")) === fabric_node_id; }); const row = asRecord2(node); return { fabric_node_id, degree: deg, name: str3(row, "name"), level: row.level, role: row.role }; }); return { ok: true, source_view_id: viewId, hubs, hint: "Degree ranking only — full dual_unit-aware suggestSinkHubs lives in netx-topology MCP." }; } async function analyzeTopologyViewLayout(client, args, signal) { const viewId = str3(args, "view_id").trim(); if (!viewId) { return { ok: false, error: "view_id_required", hint: "Pass view_id. folder_id sampling without view_id needs netx-topology MCP structure analysis." }; } const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); if (out.ok !== true) return out; const nodes = Array.isArray(out.nodes) ? out.nodes : []; const edges = Array.isArray(out.edges) ? out.edges : []; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; let missingxy = 0; for (const raw of nodes) { const row = asRecord2(raw); const x = Number(row.x); const y = Number(row.y); if (!Number.isFinite(x) || !Number.isFinite(y)) { missingxy += 1; continue; } minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } const degree = new Map; for (const raw of edges) { const [a, b] = edgeEndpoints(asRecord2(raw)); if (!a || !b) continue; degree.set(a, (degree.get(a) || 0) + 1); degree.set(b, (degree.get(b) || 0) + 1); } const hubs = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([id, deg]) => ({ fabric_node_id: id, degree: deg })); return { ok: true, view_id: viewId, detail: str3(args, "detail", "summary") || "summary", node_count: nodes.length, edge_count: edges.length, link_count: collapseEdgesToLinks(edges).length, missing_xy: missingxy, bbox: Number.isFinite(minX) ? { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY } : null, hubs, hint: "Basic graph stats only. Crossing/dual_unit/score.total analysis needs netx-topology MCP." }; } async function sinkTopologyDualUnits(_client, args) { return { ok: false, error: "sink_dual_units_requires_netx_topology_mcp", source_view_id: str3(args, "source_view_id"), sink_view_id: str3(args, "sink_view_id"), hint: "sinkTopologyDualUnits needs the dual_unit layout engine from netx-topology MCP. Use layoutTopologyView(action=move_nodes, park=true) for HTTP-only parking, or enable the topology MCP." }; } async function layoutTopologyView(client, args, signal) { if (bool3(args, "catalog") === true) { return { ok: true, catalog: true, actions: [ "layout", "layout_dual_unit", "move_nodes", "sink_nodes", "orbit_sweep", "level_bands", "polish_crossings", "clear_edge_hits", "compact_bbox", "pull_far_chains", "align_reference", "fix_overlaps", "resolve_overlaps", "untangle", "straighten_channels", "job_status", "job_cancel" ], dsh_supported: ["move_nodes", "sink_nodes", "catalog"], recipes: ["rings", "corridor", "compact", "unstick"], hint: "Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP." }; } const action = str3(args, "action", "layout").trim().toLowerCase() || "layout"; if (action === "job_status" || action === "job_cancel") { return { ok: false, error: "layout_jobs_not_in_dsh_host", hint: "Background layout jobs are owned by netx-topology MCP." }; } if (action === "move_nodes" || action === "sink_nodes") { const toId = str3(args, "view_id").trim(); const fromId = str3(args, "source_view_id").trim(); const ids = strList2(args, "fabric_node_ids"); const params = asRecord2(args.params); const fabricIds = ids.length ? ids : strList2(params, "fabric_node_ids"); if (!toId || !fromId) { return { ok: false, error: "view_id_and_source_view_id_required" }; } if (fabricIds.length === 0) { return { ok: false, error: "fabric_node_ids_required" }; } const copyPositions = bool3(params, "copy_positions") !== false && bool3(args, "copy_positions") !== false; const removeFromSource = bool3(params, "remove_from_source") !== false; const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120000)); if (source.ok !== true) return source; const nodes = Array.isArray(source.nodes) ? source.nodes : []; const wanted = new Set(fabricIds); const selected = nodes.filter((n) => { const row = asRecord2(n); const id = str3(row, "id") || str3(row, "fabric_node_id"); return wanted.has(id); }); const add = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(toId)}/nodes`, { fabric_node_ids: fabricIds, layout: copyPositions ? "keep" : "grid", managed_ne_ids: [] }, signal, 180000)); if (add.ok !== true) return add; if (copyPositions && selected.length) { const ox = Number(params.offset_x || 0); const oy = Number(params.offset_y || 0); const positions = selected.map((n) => { const row = asRecord2(n); return { fabric_node_id: str3(row, "id") || str3(row, "fabric_node_id"), x: Number(row.x || 0) + ox, y: Number(row.y || 0) + oy }; }).filter((p) => p.fabric_node_id); await client.patch(`/v1/topology/views/${encodeURIComponent(toId)}/positions`, { positions }, signal, 120000); } if (removeFromSource) { await client.post(`/v1/topology/views/${encodeURIComponent(fromId)}/nodes/remove`, { fabric_node_ids: fabricIds }, signal); } return { ok: true, action, moved: fabricIds.length, source_view_id: fromId, view_id: toId, park: bool3(params, "park") === true || bool3(args, "park") === true }; } return layoutRecipeUnavailable(action); } // src/netx/tools.ts var str4 = (description) => ({ type: "string", ...description ? { description } : {} }); var num4 = (description) => ({ type: "number", ...description ? { description } : {} }); var bool4 = (description) => ({ type: "boolean", ...description ? { description } : {} }); var strArr = (description) => ({ type: "array", items: { type: "string" }, ...description ? { description } : {} }); var anyObj = (description) => ({ type: "object", additionalProperties: true, ...description ? { description } : {} }); function renderJson2(_args, value) { return [{ type: "text", text: JSON.stringify(value, null, 0) }]; } var jsonOut2 = { schema: { type: "json" }, render: renderJson2 }; function tool2(name, description, parameters, handler, getClient, timeoutMs) { return defineTool2({ name, description, parameters, output: jsonOut2, timeoutMs, isConcurrencySafe: () => true, async execute(args, exec) { const result = await handler(getClient(), args, exec.signal); if (result.ok === false) { throw new Error(JSON.stringify(result)); } return result; } }); } function registerNetxTools(ctx, connection, options) { const enabledGroups = options.forceGroups ?? groupsForPlane(connection.groups, options.plane, options.only); const allow = toolNamesForGroups(enabledGroups); if (allow.size === 0) return () => {}; const client = createNetxClient({ apiUrl: connection.apiUrl, lang: connection.lang, timeoutMs: Math.min(connection.toolCallTimeoutMs, 45000), getToken: () => getNetxConnection()?.token ?? "" }); const getClient = () => client; const t = connection.toolCallTimeoutMs; const catalog = [ tool2("netx__queryNmsAlarms", "Query NMS current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.", { severity: str4(), ne_id: str4("Filter only; do not show UUID to users"), host_name: str4("Filter by NE host_name"), ne_name: str4("Legacy alias mapped to keyword"), keyword: str4("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), time_from: str4("ISO time; filters last_seen_at >="), time_to: str4("ISO time; filters last_seen_at <="), page: num4(), page_size: num4() }, queryUmeAlarms, getClient, t), tool2("netx__aggregateNmsAlarms", "Aggregate NMS current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.", { severity: str4("Optional perceived_severity filter (critical/major/minor/warning)."), top_ne: num4("Max NE buckets (default 50). Ignored when group_by is set."), exclude_missing_host: bool4("Omit missing host_name from by_ne."), time_from: str4(), time_to: str4(), group_by: str4("When set, routes to raw aggregation. Prefer alarm_host_name."), group_by2: str4(), is_cleared: str4(), ne_id: str4(), event_type: str4(), keyword: str4(), limit: num4() }, aggregateUmeAlarms, getClient, t), tool2("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), tool2("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { keyword: str4(), page: num4(), page_size: num4() }, queryUmeNeInventory, getClient, t), tool2("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { ne_id: { type: "string", required: true, description: "NMS inventory ne_id (UUID)." } }, getUmeNe, getClient, t), tool2("netx__queryNmsAlarmsRaw", "Power query NMS current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.", { severity: str4(), is_cleared: str4(), ne_id: str4(), event_type: str4(), keyword: str4(), time_from: str4(), time_to: str4(), order_by: str4("last_seen_at | time_created | perceived_severity | event_type | ne_id"), order: str4("asc | desc"), select_fields: strArr(), field_preset: str4("brief | evidence | ne_debug"), page: num4(), page_size: num4() }, queryUmeAlarmsRaw, getClient, t), tool2("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { group_by: { type: "string", required: true }, group_by2: str4(), severity: str4(), is_cleared: str4(), ne_id: str4(), event_type: str4(), keyword: str4(), time_from: str4(), time_to: str4(), exclude_missing_host: bool4(), limit: num4() }, aggregateUmeAlarmsRaw, getClient, t), tool2("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), tool2("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { sql: { type: "string", required: true }, limit: num4(), statement_timeout_ms: num4() }, sqlQueryUme, getClient, t), tool2("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { keyword: str4(), vendor: str4(), connect_status: str4("unknown | testing | pass | fail"), page: num4(), page_size: num4() }, listManagedNe, getClient, t), tool2("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { ne_id: str4("Managed NE id"), managed_ne_id: str4("Alias for ne_id"), id: str4("Alias for ne_id") }, getManagedNe, getClient, t), tool2("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { ne_id: str4(), nms_ne_id: str4("NMS inventory id; alias ume_ne_id"), ume_ne_id: str4("Legacy alias of nms_ne_id"), ne_ids: strArr("Managed NE ids for concurrent batch (shared commands)."), nms_ne_ids: strArr("NMS inventory ids; alias ume_ne_ids"), ume_ne_ids: strArr("Legacy alias of nms_ne_ids"), targets: { type: "array", description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].", items: { type: "object", additionalProperties: false, properties: { ne_id: str4(), nms_ne_id: str4(), ume_ne_id: str4(), commands: strArr() } } }, commands: strArr("Commands for single NE, or shared commands for batch."), read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."), concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."), async: bool4("oclaw-only async hint; ignored by native REST client.") }, execManagedNe, getClient, Math.max(t, 300000)), tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { source: str4("managed | nms | ume | all"), keyword: str4(), page: num4(), page_size: num4() }, listCliTargets, getClient, t), tool2("netx__findTopologyPaths", "Find up to max_paths simple paths between two fabric nodes (common group — native netx, not NMS-bound). For each endpoint provide exactly one of ume_ne_id or managed_ne_id.", { from_nms_ne_id: str4("NMS inventory id"), from_ume_ne_id: str4("Legacy alias of from_nms_ne_id"), from_managed_ne_id: str4(), to_nms_ne_id: str4("NMS inventory id"), to_ume_ne_id: str4("Legacy alias of to_nms_ne_id"), to_managed_ne_id: str4(), max_paths: num4(), max_hops: num4(), layer: str4(), detail: str4("summary | full") }, findTopologyPaths, getClient, t), tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then use getBizMonitorContext.", { kind: str4("all | projects | tasks (default all)"), purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), status: str4("Filter by status (e.g. active / paused)"), q: str4("Keyword on name / ne_name / note / id"), limit: num4("Max rows per list (default 100, max 500)") }, listBizMonitors, getClient, t), tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", { project_id: str4("Cutover / biz-migration project id"), task_id: str4("biz_state task id (optional if project_id set)") }, getBizMonitorContext, getClient, t), tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", { batch_id: { type: "string", required: true }, run_id: str4("Optional evaluate run id") }, getBizMonitorBoard, getClient, t), tool2("netx__listBizMonitorReds", "Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).", { project_id: { type: "string", required: true }, status: str4("open|carried|resolved or empty=all"), limit: num4() }, listBizMonitorReds, getClient, t), tool2("netx__getBizMonitorDiffs", "Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.", { run_id: { type: "string", required: true }, metric_id: str4(), sheet_id: str4(), verdict: str4(), color: str4("e.g. red"), q: str4(), limit: num4(), offset: num4() }, getBizMonitorDiffs, getClient, t), tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", { batch_id: { type: "string", required: true } }, getBizCollectBatch, getClient, t), tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", { batch_id: { type: "string", required: true }, command_id: { type: "string", required: true } }, getBizCollectCommandRaw, getClient, t), tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t), tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { view_id: { type: "string", required: true }, detail: str4("summary | full"), sample: num4() }, getTopologyView, getClient, t), tool2("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { name: { type: "string", required: true }, parent_id: str4(), locale: str4("zh | en"), sort_order: num4() }, createTopologyFolder, getClient, t), tool2("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { view_id: { type: "string", required: true }, max_nodes: num4(), keyword: str4(), role: str4(), vendor: str4(), link_status: str4(), limit: num4(), offset: num4(), fabric_node_ids: strArr(), layout: str4("grid | keep") }, addTopologyViewNodes, getClient, t), tool2("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { view_id: { type: "string", required: true }, keyword: str4(), role: str4(), vendor: str4(), link_status: str4(), fabric_node_ids: strArr() }, removeTopologyViewNodes, getClient, t), tool2("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { source_view_id: { type: "string", required: true }, target_view_id: { type: "string", required: true }, copy_positions: bool4(), clear_target: bool4(), offset_x: num4(), offset_y: num4(), limit: num4(), dry_run: bool4() }, copyTopologyViewNodes, getClient, t), tool2("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { view_id: { type: "string", required: true }, positions: { type: "array", description: "Manual placements: [{ fabric_node_id, x, y }, …]", items: { type: "object", additionalProperties: false, properties: { fabric_node_id: str4(), x: num4(), y: num4() } } }, layout: str4("grid | offset | stack"), keyword: str4(), role: str4(), vendor: str4(), link_status: str4(), fabric_node_ids: strArr(), offset_x: num4(), offset_y: num4() }, updateTopologyViewPositions, getClient, t), tool2("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { view_id: { type: "string", required: true }, seed_fabric_node_ids: strArr(), managed_ne_ids: strArr(), region_folder_id: str4(), detail: str4("summary | full"), sample: num4() }, projectTopologyNeighbors, getClient, t), tool2("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { mode: str4("summary | list | search"), q: str4(), keyword: str4(), role: str4(), level: str4(), level_major: str4(), region_folder_id: str4(), link_status: str4(), page: num4(), page_size: num4(), limit: num4(), summary: bool4() }, queryTopologyFabricNodes, getClient, t), tool2("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { action: { type: "string", required: true }, pattern: str4(), q: str4(), match_field: str4(), sample_limit: num4(), fabric_node_ids: strArr(), fabric_node_id: str4(), node_id: str4(), level: str4(), role: str4(), region_folder_id: str4(), clear_region: bool4(), dry_run: bool4(), kind: str4(), page: num4(), page_size: num4(), skip_manual: bool4(), overwrite_manual: bool4(), fill_empty_only: bool4() }, classifyTopologyFabricNodes, getClient, t), tool2("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { node_id: { type: "string", required: true }, depth: num4(), layer: str4() }, queryTopologyNeighborhood, getClient, t), tool2("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { node_id: str4(), keyword: str4(), layer: str4(), status: str4(), source: str4("lldp | ume | manual"), detail: str4("adjacency | ports"), page: num4(), page_size: num4() }, queryTopologyEdges, getClient, t), tool2("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { source_view_id: str4(), view_id: str4(), pick: num4(), exclude_portal_ids: strArr(), exclude_fabric_node_ids: strArr() }, suggestSinkHubs, getClient, t), tool2("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { view_id: str4(), folder_id: str4(), detail: str4("summary | structure | hotspots | blocks | both"), score_profile: str4(), sight_limit: num4(), max_views: num4(), min_nodes: num4(), max_nodes: num4(), with_meta: bool4() }, analyzeTopologyViewLayout, getClient, t), tool2("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { source_view_id: str4(), sink_view_id: str4(), max_units: num4(), min_nodes: num4(), max_nodes: num4(), max_batch_nodes: num4(), layout_batch: bool4(), until_empty: bool4(), dry_run: bool4() }, sinkTopologyDualUnits, getClient, t), tool2("netx__layoutTopologyView", "Layout / polish a canvas. DSH supports catalog + move_nodes|sink_nodes over HTTP; dual_unit/orbit/polish recipes need netx-topology MCP.", { view_id: str4(), action: str4(), source_view_id: str4(), recipe: str4(), preset: str4(), mode: str4("preview | apply"), tune: bool4(), params: anyObj("Action-specific options (move_nodes / recipes / polish)."), catalog: bool4(), fabric_node_ids: strArr(), park: bool4(), copy_positions: bool4() }, layoutTopologyView, getClient, Math.max(t, 180000)) ]; const disposers = catalog.filter((entry) => allow.has(entry.name)).map((entry) => ctx.tools.register(entry)); return () => { for (const dispose of disposers) dispose(); }; } // src/netx/group-tools-plugin.ts function applyGroupToolsPlugin(ctx, options) { let unregisterTools; let unregisterKbLocal; let unregisterSkills; let skillGeneration = 0; const resolveGroups = () => { const connection = getNetxConnection(); if (options.mode === "forced") { return groupsForced(options.only ?? []); } return groupsForPlane(connection?.groups, "preset", options.only); }; const remountTools = () => { unregisterTools?.(); unregisterTools = undefined; const connection = getNetxConnection(); if (connection === undefined) { ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name); return; } const enabled = resolveGroups(); if (enabled.length === 0) { ctx.logger.info("%s: no groups enabled", options.name); return; } unregisterTools = registerNetxTools(ctx, connection, { plane: "preset", only: options.mode === "forced" ? enabled : options.only, forceGroups: options.mode === "forced" ? enabled : undefined }); ctx.logger.info("%s: groups=[%s] → %s tokenConfigured=%s", options.name, enabled.join(",") || "(none)", connection.apiUrl, connection.token.trim().length > 0); }; const remountKbLocal = () => { unregisterKbLocal?.(); unregisterKbLocal = undefined; const connection = getNetxConnection(); if (connection === undefined) return; if (connection.groupKbInPreset === false) return; unregisterKbLocal = registerKbLocalTools(ctx, getKbContext()); }; remountTools(); remountKbLocal(); const stopToolWatch = watchNetxConnection(() => { remountTools(); remountKbLocal(); }); const stopKbToolWatch = watchKbContext(() => { remountKbLocal(); }); ctx.inject(["skills"], (skillsCtx) => { let unregisterKbSkill; let unregisterKbPack; let packGeneration = 0; const remountSkills = () => { const gen = ++skillGeneration; unregisterSkills?.(); unregisterSkills = undefined; if (getNetxConnection() === undefined) return; const enabled = resolveGroups(); registerGroupSkills(skillsCtx, enabled, options.name).then((dispose) => { if (gen !== skillGeneration) { dispose(); return; } unregisterSkills = dispose; }).catch((error) => { skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error); }); }; const remountKbSkill = () => { unregisterKbSkill?.(); unregisterKbSkill = undefined; unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext()); }; const remountKbPack = () => { const gen = ++packGeneration; unregisterKbPack?.(); unregisterKbPack = undefined; const connection = getNetxConnection(); const enabled = connection?.groupKbInPreset !== false; registerKbPackSkills(skillsCtx, getKbContext(), { enabled, providerLabel: `${options.name}-kb-pack` }).then((dispose) => { if (gen !== packGeneration) { dispose(); return; } unregisterKbPack = dispose; }).catch((error) => { skillsCtx.logger.warn("%s: kb pack skill register failed: %s", options.name, error); }); }; remountSkills(); remountKbSkill(); remountKbPack(); const stopSkillWatch = watchNetxConnection(() => { remountSkills(); remountKbPack(); }); const stopKbWatch = watchKbContext(() => { remountKbSkill(); remountKbPack(); }); skillsCtx.effect(() => () => { skillGeneration += 1; packGeneration += 1; stopSkillWatch(); stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; unregisterKbSkill?.(); unregisterKbSkill = undefined; unregisterKbPack?.(); unregisterKbPack = undefined; }, `${options.name}: dispose skills`); }); ctx.effect(() => () => { stopToolWatch(); stopKbToolWatch(); unregisterTools?.(); unregisterTools = undefined; unregisterKbLocal?.(); unregisterKbLocal = undefined; }, `${options.name}: dispose tools`); } // src/agent-tools.ts var name = "netxops-tools"; var inject = ["tools"]; function apply(ctx) { applyGroupToolsPlugin(ctx, { name, mode: "settings" }); } export { name, inject, apply };