diff --git a/.gitignore b/.gitignore index 0d4b35e..e635103 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,11 @@ dist/ lib/* !lib/index.js !lib/agent-tools.js +!lib/agent-tools-ops.js !lib/agent-tools-nms.js !lib/agent-tools-common.js !lib/agent-tools-topology.js +!lib/agent-tools-biz-monitor.js !lib/client.js *.log .DS_Store diff --git a/lib/agent-tools-biz-monitor.js b/lib/agent-tools-biz-monitor.js new file mode 100644 index 0000000..6e03ec2 --- /dev/null +++ b/lib/agent-tools-biz-monitor.js @@ -0,0 +1,2704 @@ +// 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__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); +} +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__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id. 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-biz-monitor.ts +var name = "netxops-tools-biz-monitor"; +var inject = ["tools"]; +function apply(ctx) { + applyGroupToolsPlugin(ctx, { name, mode: "forced", only: ["bizMonitor"] }); +} +export { + name, + inject, + apply +};