From 3a731389f0459334b9f84989701a375a6a8033e6 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 15 Sep 2026 21:43:41 +0800 Subject: [PATCH] Add KB local write tools and align Netx Ops with standard preset. Host-side netx__kb* tools jail create/update/delete under paths.local memories/drafts/suggestions; Netx Ops agent.cordis.yml now includes full standard capability rows plus ops tools. Co-authored-by: Cursor --- docs/INSTALL.md | 1 + docs/KNOWLEDGE_BASE.md | 5 +- docs/TOOL_MAP.md | 13 + lib/agent-tools-common.js | 1361 ++++++++++++++++++--------- lib/agent-tools-nms.js | 1361 ++++++++++++++++++--------- lib/agent-tools-ops.js | 1361 ++++++++++++++++++--------- lib/agent-tools-topology.js | 1361 ++++++++++++++++++--------- lib/agent-tools.js | 1361 ++++++++++++++++++--------- lib/index.js | 1466 ++++++++++++++++++++---------- presets/netxops/PERSONA.md | 7 +- presets/netxops/agent.cordis.yml | 163 +++- presets/netxops/preset.yml | 2 +- src/index.ts | 18 +- src/netx/group-tools-plugin.ts | 21 +- src/netx/kb-context-skill.ts | 23 +- src/netx/kb-local-ops.ts | 272 ++++++ src/netx/kb-local-path.ts | 209 +++++ src/netx/kb-local-tools.ts | 225 +++++ src/netx/kb-runtime.ts | 4 + test/kb-local.test.mjs | 165 ++++ 20 files changed, 6764 insertions(+), 2635 deletions(-) create mode 100644 src/netx/kb-local-ops.ts create mode 100644 src/netx/kb-local-path.ts create mode 100644 src/netx/kb-local-tools.ts create mode 100644 test/kb-local.test.mjs diff --git a/docs/INSTALL.md b/docs/INSTALL.md index bdf956b..41029e0 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -45,6 +45,7 @@ One command, three **direct** profile bundles. Do **not** nest IM/cron under net Capability groups: leave default for **ops**, or enable **topology** / **对其他预设公开** (new sessions after save). Optional: **知识库** — set package root (MANIFEST v1.0); default mounts `_skills/kb-*` into Netx Ops; optional public. See [KNOWLEDGE_BASE.md](KNOWLEDGE_BASE.md). 2. Restart or open Settings → **Agent presets** → Custom → **Netx Ops** should appear after the host plugin has activated once. + The Netx Ops preset is **standard + netxops tools/persona** (shell, search, plan, todo, web, workflows, … plus `netx__*`). Re-activate / reinstall the plugin so `$DSH_HOME/.agent-presets/netxops` is refreshed from the package. 3. **New session → preset Netx Ops** → ask e.g. Critical Top / single-host alarms / 「能否登录」. ## Key-alarm push diff --git a/docs/KNOWLEDGE_BASE.md b/docs/KNOWLEDGE_BASE.md index 68b0b14..4cdd832 100644 --- a/docs/KNOWLEDGE_BASE.md +++ b/docs/KNOWLEDGE_BASE.md @@ -37,9 +37,10 @@ Authoritative packaging prose lives in the workspace contract notes (`插件` / | Channel | Fields | |---------|--------| -| `process.env` | `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT` (JSON of `has*`), `KB_STATUS` | -| Skill `kb-context` | Same identity for the model (markdown table) | +| `process.env` | `KB_ROOT`, `KB_LOCAL` (when `paths.local` set), `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT` (JSON of `has*`), `KB_STATUS` | +| Skill `kb-context` | Same identity + `kbLocal` + local create/update/delete tool names | | Skills from pack | Registered when `configured` **and** `hasSkills` **and** the inPreset/public toggle for that plane | +| Local FS tools | When `configured` + `paths.local` + same KB toggles: `netx__kbWriteMemory` / `WriteDraft` / `WriteSuggestion` / `UpdateLocal` / `DeleteLocal` / `ListLocal` — jail under `memories\|drafts\|suggestions` only (`identity/` host persona, agent read-only) | RPC (channel `/netxops`): `kb.status` (saved snapshot), `kb.resolve` with `{ path }` (preview unsaved paths). diff --git a/docs/TOOL_MAP.md b/docs/TOOL_MAP.md index eff9b61..e84243b 100644 --- a/docs/TOOL_MAP.md +++ b/docs/TOOL_MAP.md @@ -32,3 +32,16 @@ Model names: `netx__`. NMS tools use `Nms`; adapter `nmsProvider=zte-ume` Canonical skill bodies: sibling **`netx/skills/`**. DSH loads them at runtime (`NETX_SKILLS_ROOT` or `../netx/skills`) or from `presets/netxops/skills` after sync. Execution: HTTP `apiUrl` + Bearer `NETX_API_TOKEN`. + +## knowledge base → `groupKb*` (not ops/topology) + +Registered when KB is configured, MANIFEST has `paths.local`, and the KB in-preset / public toggle is on. Host-side FS writes (bypass workspace sandbox). Jail: `{kbLocal}/memories|drafts|suggestions` only — **not** `identity/`. + +| Tool | Role | +|------|------| +| `kbWriteMemory` | New diary entry under `memories/{日常笔记\|排障复盘\|AI思维链}/` (`overwrite` optional) | +| `kbWriteDraft` | New `DRAFT-…` under `drafts/` (+ `status: draft`) | +| `kbWriteSuggestion` | New file under `suggestions/{theory\|improvement}/` | +| `kbUpdateLocal` | Replace body of an existing jailed file | +| `kbDeleteLocal` | Delete one jailed file | +| `kbListLocal` | List recent `.md` under writable trees | diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index 115ef0f..681b622 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -215,11 +215,165 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) { }; } +// 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" +]); +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 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")); +} + // 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 ? [ + "", + "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", + "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", + "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", + "Do **not** use workspace Write/bash for KB paths (sandbox).", + "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", + "file boundary issues as `suggestions/` instead." + ] : [ + "", + "No local write tools until `paths.local` is present in MANIFEST." + ]; return { description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", content: [ @@ -232,12 +386,14 @@ function skillBody(snapshot) { `| --- | --- |`, `| kbStatus | configured |`, `| kbRoot | \`${snapshot.realRoot}\` |`, + localRow, `| kbOperator | ${snapshot.operatorName} |`, `| kbCountry | ${snapshot.country} |`, `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, + ...localGuide, "", - "Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "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,`, @@ -277,21 +433,403 @@ function registerKbContextSkill(ctx, snapshot) { }); } +// 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 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 } : {} }); +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.", { + type: "object", + additionalProperties: false, + required: ["bucket", "slug", "body"], + properties: { + bucket: { + type: "string", + enum: ["note", "rca_review", "ai_trace"], + description: "Memory subdirectory" + }, + slug: str("Short filename stem (no path separators)"), + body: str("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 identity/ or formal RCA.", { + type: "object", + additionalProperties: false, + required: ["slug", "body"], + properties: { + slug: str("Case stem (DRAFT- prefix added if missing)"), + body: str("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 theory corrections or tool/process improvements — never rewrite identity/.", { + type: "object", + additionalProperties: false, + required: ["kind", "slug", "body"], + properties: { + kind: { + type: "string", + enum: ["theory", "improvement"], + description: "theory = knowledge fix; improvement = tools/skills/process" + }, + slug: str("Short filename stem"), + body: str("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__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + type: "object", + additionalProperties: false, + required: ["path", "body"], + properties: { + path: str("Absolute path or path relative to KB local root"), + body: str("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 ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + type: "object", + additionalProperties: false, + required: ["path"], + properties: { + path: str("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 (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + type: "object", + additionalProperties: false, + properties: { + root: { + type: "string", + enum: ["memories", "drafts", "suggestions", "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 join2 } from "node:path"; +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 join2(snapshot.realRoot, rel); + return join4(snapshot.realRoot, rel); } function resolveKbLocalSkillsRoot(snapshot) { const rel = snapshot.paths.localSkills?.trim(); if (!rel) return null; - return join2(snapshot.realRoot, rel); + return join4(snapshot.realRoot, rel); } async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; @@ -307,7 +845,7 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) return []; const disposers = []; for (const entry of entries) { - const full = join2(root, entry); + const full = join4(root, entry); let isDir = false; try { isDir = (await stat2(full)).isDirectory(); @@ -370,54 +908,6 @@ async function registerKbPackSkills(ctx, snapshot, options) { }; } -// 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/runtime.ts var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); function store2() { @@ -441,7 +931,7 @@ function watchNetxConnection(listener) { } // src/netx/tools.ts -import { defineTool } from "@deepseek-ai/dsh-tools"; +import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; // src/netx/http.ts var PROTOCOL_KEY_ZH_TO_EN = { @@ -604,7 +1094,7 @@ var UME_RAW_FIELD_PRESETS = { function asRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; } -function str(args, key, fallback = "") { +function str2(args, key, fallback = "") { const v = args[key]; if (typeof v === "string") return v; @@ -613,17 +1103,17 @@ function str(args, key, fallback = "") { return fallback; } function nmsOrUme(args, nmsKey, umeKey) { - return str(args, nmsKey).trim() || str(args, umeKey).trim(); + 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 num(args, key) { +function num2(args, key) { const v = args[key]; return typeof v === "number" && Number.isFinite(v) ? v : undefined; } -function bool(args, key) { +function bool2(args, key) { const v = args[key]; return typeof v === "boolean" ? v : undefined; } @@ -639,18 +1129,18 @@ function clampInt(value, fallback, min, max) { } function putStr(params, args, keys) { for (const key of keys) { - const v = str(args, key).trim(); + const v = str2(args, key).trim(); if (v) params[key] = v; } } async function queryUmeAlarms(client, args, signal) { - let page = clampInt(num(args, "page"), 1, 1, 2); - const pageSize = clampInt(num(args, "page_size"), 50, 1, 500); + 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 = str(args, "keyword").trim(); - const neName = str(args, "ne_name").trim(); + const keyword = str2(args, "keyword").trim(); + const neName = str2(args, "ne_name").trim(); if (keyword) params.keyword = keyword; else if (neName) @@ -672,19 +1162,19 @@ async function aggregateUmeAlarmsRaw(client, args, signal) { "limit" ]); if ("exclude_missing_host" in args) { - const flag = bool(args, "exclude_missing_host"); + 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 (str(args, "group_by").trim()) + if (str2(args, "group_by").trim()) return aggregateUmeAlarmsRaw(client, args, signal); - const topNe = clampInt(num(args, "top_ne"), 50, 0, 500); + const topNe = clampInt(num2(args, "top_ne"), 50, 0, 500); const params = { top_ne: topNe }; if ("exclude_missing_host" in args) { - const flag = bool(args, "exclude_missing_host"); + const flag = bool2(args, "exclude_missing_host"); if (flag !== undefined) params.exclude_missing_host = flag; } @@ -696,22 +1186,22 @@ async function runUmeDiagnostics(client, _args, signal) { } async function queryUmeNeInventory(client, args, signal) { const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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 = str(args, "ne_id").trim(); + 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(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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", @@ -726,7 +1216,7 @@ async function queryUmeAlarmsRaw(client, args, signal) { ]); let fields = strList(args, "select_fields"); if (fields.length === 0) { - const preset = str(args, "field_preset").trim().toLowerCase(); + const preset = str2(args, "field_preset").trim().toLowerCase(); fields = UME_RAW_FIELD_PRESETS[preset] ?? []; } if (fields.length > 0) @@ -737,11 +1227,11 @@ async function listUmeAlarmFields(client, _args, signal) { return client.get("/v1/ume/alarms/fields", undefined, signal); } async function sqlQueryUme(client, args, signal) { - const sql = str(args, "sql").trim(); + const sql = str2(args, "sql").trim(); if (!sql) return { ok: false, error: "sql_required" }; - const limit = clampInt(num(args, "limit"), 200, 1, 2000); - const statementTimeoutMs = clampInt(num(args, "statement_timeout_ms"), 0, 0, 30000); + 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, @@ -749,9 +1239,9 @@ async function sqlQueryUme(client, args, signal) { }, signal, 60000); } async function listManagedNe(client, args, signal) { - const keyword = str(args, "keyword").trim(); - const vendor = str(args, "vendor").trim(); - const connectStatus = str(args, "connect_status").trim(); + 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" }; } @@ -759,8 +1249,8 @@ async function listManagedNe(client, args, signal) { return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; } const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 20, 1, 100) + 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; @@ -771,7 +1261,7 @@ async function listManagedNe(client, args, signal) { return client.get("/v1/managed-ne", params, signal); } async function getManagedNe(client, args, signal) { - const neId = (str(args, "ne_id") || str(args, "managed_ne_id") || str(args, "id")).trim(); + const neId = (str2(args, "ne_id") || str2(args, "managed_ne_id") || str2(args, "id")).trim(); if (!neId) { return { ok: false, @@ -783,7 +1273,7 @@ async function getManagedNe(client, args, signal) { } const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); if (out.ok === false) { - const detail = `${str(out, "detail")}${str(out, "error")}`.toLowerCase(); + 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, @@ -808,7 +1298,7 @@ async function execManagedNe(client, args, signal) { continue; const row = t; const item = {}; - const neId2 = str(row, "ne_id").trim(); + const neId2 = str2(row, "ne_id").trim(); const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); if (neId2) item.ne_id = neId2; @@ -832,8 +1322,8 @@ async function execManagedNe(client, args, signal) { } body2.commands = sharedCommands; } - body2.read_timeout_sec = clampInt(num(args, "read_timeout_sec"), 60, 10, 120); - const concurrency = num(args, "concurrency"); + 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); @@ -841,11 +1331,11 @@ async function execManagedNe(client, args, signal) { return out2; const data2 = asRecord(out2.data); if (data2.ok === false) { - return { ok: false, data: data2, error: str(data2, "error", "exec_batch_failed") }; + return { ok: false, data: data2, error: str2(data2, "error", "exec_batch_failed") }; } return { ok: true, data: data2 }; } - const neId = str(args, "ne_id").trim(); + const neId = str2(args, "ne_id").trim(); const umeNeId = nmsOrUme(args, "nms_ne_id", "ume_ne_id"); if (Boolean(neId) === Boolean(umeNeId)) { return { @@ -863,7 +1353,7 @@ async function execManagedNe(client, args, signal) { } const body = { commands: sharedCommands, - read_timeout_sec: clampInt(num(args, "read_timeout_sec"), 60, 10, 120) + read_timeout_sec: clampInt(num2(args, "read_timeout_sec"), 60, 10, 120) }; if (neId) body.ne_id = neId; @@ -874,36 +1364,36 @@ async function execManagedNe(client, args, signal) { return out; const data = asRecord(out.data); if (data.ok === false) { - return { ok: false, data, error: str(data, "error", "exec_failed") }; + return { ok: false, data, error: str2(data, "error", "exec_failed") }; } return { ok: true, data }; } async function listCliTargets(client, args, signal) { const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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 = str(args, "from_managed_ne_id").trim(); + const fromMid = str2(args, "from_managed_ne_id").trim(); const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); - const toMid = str(args, "to_managed_ne_id").trim(); + 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 = str(args, "detail", "summary").trim().toLowerCase() || "summary"; + let detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; if (detail !== "summary" && detail !== "full") detail = "summary"; const body = { - max_paths: clampInt(num(args, "max_paths"), 3, 1, 10), - max_hops: clampInt(num(args, "max_hops"), 6, 1, 12), - layer: str(args, "layer", "physical").trim() || "physical", + 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) @@ -921,7 +1411,7 @@ async function findTopologyPaths(client, args, signal) { function asRecord2(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; } -function str2(args, key, fallback = "") { +function str3(args, key, fallback = "") { const v = args[key]; if (typeof v === "string") return v; @@ -929,11 +1419,11 @@ function str2(args, key, fallback = "") { return String(v); return fallback; } -function num2(args, key) { +function num3(args, key) { const v = args[key]; return typeof v === "number" && Number.isFinite(v) ? v : undefined; } -function bool2(args, key) { +function bool3(args, key) { const v = args[key]; return typeof v === "boolean" ? v : undefined; } @@ -958,15 +1448,15 @@ function unwrap(out) { function filterFields(args) { const out = {}; for (const key of ["keyword", "role", "vendor", "link_status"]) { - const v = str2(args, key).trim(); + const v = str3(args, key).trim(); if (v) out[key] = v; } return out; } function edgeEndpoints(edge) { - const a = str2(edge, "a_node_id") || str2(edge, "a"); - const b = str2(edge, "b_node_id") || str2(edge, "b"); + 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) { @@ -989,11 +1479,11 @@ function collapseEdgesToLinks(edges, includeNames = false) { row.link_count = Number(row.link_count || 0) + 1; if (includeNames && !row.a_name) { if (a === lo) { - row.a_name = str2(e, "a_name"); - row.b_name = str2(e, "b_name"); + row.a_name = str3(e, "a_name"); + row.b_name = str3(e, "b_name"); } else { - row.a_name = str2(e, "b_name"); - row.b_name = str2(e, "a_name"); + row.a_name = str3(e, "b_name"); + row.b_name = str3(e, "a_name"); } } buckets.set(key, row); @@ -1006,8 +1496,8 @@ function summarizeViewGraph(graph, sample = 20) { const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => { const row = asRecord2(n); return { - id: str2(row, "id") || str2(row, "fabric_node_id"), - name: str2(row, "name"), + id: str3(row, "id") || str3(row, "fabric_node_id"), + name: str3(row, "name"), x: row.x, y: row.y, level: row.level, @@ -1016,7 +1506,7 @@ function summarizeViewGraph(graph, sample = 20) { }); return { ok: true, - view_id: str2(graph, "id") || str2(graph, "view_id"), + view_id: str3(graph, "id") || str3(graph, "view_id"), node_count: nodes.length, edge_count: edges.length, link_count: collapseEdgesToLinks(edges).length, @@ -1037,41 +1527,41 @@ 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 = bool2(args, "compact") !== false; - const maxDepth = num2(args, "max_depth"); + 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 = str2(args, "view_id").trim(); + 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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; if (detail === "full") return { ...out, detail: "full" }; - return summarizeViewGraph(out, clampInt2(num2(args, "sample"), 20, 0, 200)); + return summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 200)); } async function createTopologyFolder(client, args, signal) { - const name = str2(args, "name").trim(); + const name = str3(args, "name").trim(); if (!name) return { ok: false, error: "name_required" }; const body = { name }; - const parentId = str2(args, "parent_id").trim(); + const parentId = str3(args, "parent_id").trim(); if (parentId) body.parent_id = parentId; - const locale = str2(args, "locale").trim(); + const locale = str3(args, "locale").trim(); if (locale) body.locale = locale; - if (num2(args, "sort_order") !== undefined) - body.sort_order = Math.trunc(num2(args, "sort_order")); + 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 = str2(args, "view_id").trim(); + 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) { @@ -1090,18 +1580,18 @@ async function addTopologyViewNodes(client, args, signal) { detail: "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids." }; } - if (num2(args, "max_nodes") !== undefined) { + 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(num2(args, "max_nodes"), 2000, 1, 2000); + 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 = str2(args, "layout", "grid").trim() || "grid"; + const layout = str3(args, "layout", "grid").trim() || "grid"; const body = { managed_ne_ids: [], layout, @@ -1109,14 +1599,14 @@ async function addTopologyViewNodes(client, args, signal) { }; if (fabricIds.length) body.fabric_node_ids = fabricIds; - if (num2(args, "limit") !== undefined) - body.limit = clampInt2(num2(args, "limit"), 500, 1, 2000); - if (num2(args, "offset") !== undefined) - body.offset = clampInt2(num2(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); + 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 = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; const body = { ...filterFields(args) }; @@ -1126,8 +1616,8 @@ async function removeTopologyViewNodes(client, args, signal) { return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, body, signal)); } async function copyTopologyViewNodes(client, args, signal) { - const sourceId = str2(args, "source_view_id").trim(); - const targetId = str2(args, "target_view_id").trim(); + 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" }; } @@ -1135,16 +1625,16 @@ async function copyTopologyViewNodes(client, args, signal) { if (source.ok !== true) return source; const nodes = Array.isArray(source.nodes) ? source.nodes : []; - const limit = num2(args, "limit"); + 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 str2(row, "id") || str2(row, "fabric_node_id"); + return str3(row, "id") || str3(row, "fabric_node_id"); }).filter(Boolean); - if (bool2(args, "dry_run") === true) { + 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 (bool2(args, "clear_target") === true) { + if (bool3(args, "clear_target") === true) { await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, { fabric_node_ids: "ALL", clear_all: true @@ -1156,12 +1646,12 @@ async function copyTopologyViewNodes(client, args, signal) { 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 (bool2(args, "copy_positions") !== false) { - const ox = num2(args, "offset_x") ?? 0; - const oy = num2(args, "offset_y") ?? 0; + 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 = str2(row, "id") || str2(row, "fabric_node_id"); + const id = str3(row, "id") || str3(row, "fabric_node_id"); return { fabric_node_id: id, x: Number(row.x || 0) + ox, @@ -1175,19 +1665,19 @@ async function copyTopologyViewNodes(client, args, signal) { return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; } async function updateTopologyViewPositions(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + 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 = str2(args, "layout").trim(); + 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 (num2(args, key) !== undefined) - body[key] = num2(args, key); + if (num3(args, key) !== undefined) + body[key] = num3(args, key); } const fabricIds = strList2(args, "fabric_node_ids"); if (fabricIds.length) @@ -1195,7 +1685,7 @@ async function updateTopologyViewPositions(client, args, signal) { return unwrap(await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, body, signal, 120000)); } async function projectTopologyNeighbors(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; const body = {}; @@ -1205,24 +1695,24 @@ async function projectTopologyNeighbors(client, args, signal) { const mids = strList2(args, "managed_ne_ids"); if (mids.length) body.managed_ne_ids = mids; - const region = str2(args, "region_folder_id").trim(); + 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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + 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(num2(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; + return { ...summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; } async function queryTopologyFabricNodes(client, args, signal) { - let mode = str2(args, "mode").trim().toLowerCase(); - const q = str2(args, "q").trim(); - const keyword = str2(args, "keyword").trim(); + let mode = str3(args, "mode").trim().toLowerCase(); + const q = str3(args, "q").trim(); + const keyword = str3(args, "keyword").trim(); if (!mode) { - if (bool2(args, "summary") === true) + if (bool3(args, "summary") === true) mode = "summary"; else if (q) mode = "search"; @@ -1239,16 +1729,16 @@ async function queryTopologyFabricNodes(client, args, signal) { 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(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 200) + 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(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + 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 = str2(args, key).trim() || (key === "keyword" ? q : ""); + const v = str3(args, key).trim() || (key === "keyword" ? q : ""); if (v) params[key] = v; } @@ -1256,61 +1746,61 @@ async function queryTopologyFabricNodes(client, args, signal) { return out.ok === true ? { ...out, mode: "list" } : out; } async function classifyTopologyFabricNodes(client, args, signal) { - const action = str2(args, "action").trim().toLowerCase(); + const action = str3(args, "action").trim().toLowerCase(); if (!action) return { ok: false, error: "action_required" }; if (action === "match") { - const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + 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: str2(args, "match_field", "name").trim() || "name", - sample_limit: clampInt2(num2(args, "sample_limit"), 50, 1, 200) + 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: bool2(args, "dry_run") === true + dry_run: bool3(args, "dry_run") === true }; - const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + 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 = str2(args, "role").trim(); + const role = str3(args, "role").trim(); if (role) body.role = role; - const region = str2(args, "region_folder_id").trim(); + const region = str3(args, "region_folder_id").trim(); if (region) body.region_folder_id = region; - if (bool2(args, "clear_region") === true) + 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 = str2(args, "fabric_node_id").trim() || str2(args, "node_id").trim(); + 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 = str2(args, "role").trim(); + const role = str3(args, "role").trim(); if (role) body.role = role; - const region = str2(args, "region_folder_id").trim(); + const region = str3(args, "region_folder_id").trim(); if (region) body.region_folder_id = region; - if (bool2(args, "clear_region") === true) + 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: str2(args, "kind", "any").trim() || "any", - page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + 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") { @@ -1318,11 +1808,11 @@ async function classifyTopologyFabricNodes(client, args, signal) { } if (action === "apply_rules") { const params = {}; - if (bool2(args, "overwrite_manual") === true) + if (bool3(args, "overwrite_manual") === true) params.skip_manual = false; - else if (bool2(args, "skip_manual") !== undefined) - params.skip_manual = bool2(args, "skip_manual") !== false; - if (bool2(args, "fill_empty_only") === true) + 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)); } @@ -1332,13 +1822,13 @@ async function classifyTopologyFabricNodes(client, args, signal) { return { ok: false, error: "unknown_action", action }; } async function queryTopologyNeighborhood(client, args, signal) { - const nodeId = str2(args, "node_id").trim(); + 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(num2(args, "depth"), 1, 1, 3), - layer: str2(args, "layer", "physical").trim() || "physical" + depth: clampInt2(num3(args, "depth"), 1, 1, 3), + layer: str3(args, "layer", "physical").trim() || "physical" }, signal)); if (out.ok !== true) return out; @@ -1347,19 +1837,19 @@ async function queryTopologyNeighborhood(client, args, signal) { } async function queryTopologyEdges(client, args, signal) { const params = { - page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size"), 100, 1, 500), - layer: str2(args, "layer", "physical").trim() || "physical" + 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 = str2(args, key).trim(); + 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 = str2(args, "detail", "adjacency").trim().toLowerCase() || "adjacency"; + 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 }; @@ -1367,7 +1857,7 @@ async function queryTopologyEdges(client, args, signal) { return { ...out, detail: "adjacency", links, items: undefined }; } async function suggestSinkHubs(client, args, signal) { - const viewId = str2(args, "source_view_id").trim() || str2(args, "view_id").trim(); + 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)); @@ -1387,17 +1877,17 @@ async function suggestSinkHubs(client, args, signal) { ...strList2(args, "exclude_portal_ids"), ...strList2(args, "exclude_fabric_node_ids") ]); - const pick = clampInt2(num2(args, "pick"), 5, 1, 50); + 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 (str2(row2, "id") || str2(row2, "fabric_node_id")) === fabric_node_id; + return (str3(row2, "id") || str3(row2, "fabric_node_id")) === fabric_node_id; }); const row = asRecord2(node); return { fabric_node_id, degree: deg, - name: str2(row, "name"), + name: str3(row, "name"), level: row.level, role: row.role }; @@ -1410,7 +1900,7 @@ async function suggestSinkHubs(client, args, signal) { }; } async function analyzeTopologyViewLayout(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) { return { ok: false, @@ -1453,7 +1943,7 @@ async function analyzeTopologyViewLayout(client, args, signal) { return { ok: true, view_id: viewId, - detail: str2(args, "detail", "summary") || "summary", + detail: str3(args, "detail", "summary") || "summary", node_count: nodes.length, edge_count: edges.length, link_count: collapseEdgesToLinks(edges).length, @@ -1467,13 +1957,13 @@ async function sinkTopologyDualUnits(_client, args) { return { ok: false, error: "sink_dual_units_requires_netx_topology_mcp", - source_view_id: str2(args, "source_view_id"), - sink_view_id: str2(args, "sink_view_id"), + 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 (bool2(args, "catalog") === true) { + if (bool3(args, "catalog") === true) { return { ok: true, catalog: true, @@ -1501,7 +1991,7 @@ async function layoutTopologyView(client, args, signal) { hint: "Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP." }; } - const action = str2(args, "action", "layout").trim().toLowerCase() || "layout"; + const action = str3(args, "action", "layout").trim().toLowerCase() || "layout"; if (action === "job_status" || action === "job_cancel") { return { ok: false, @@ -1510,8 +2000,8 @@ async function layoutTopologyView(client, args, signal) { }; } if (action === "move_nodes" || action === "sink_nodes") { - const toId = str2(args, "view_id").trim(); - const fromId = str2(args, "source_view_id").trim(); + 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"); @@ -1521,8 +2011,8 @@ async function layoutTopologyView(client, args, signal) { if (fabricIds.length === 0) { return { ok: false, error: "fabric_node_ids_required" }; } - const copyPositions = bool2(params, "copy_positions") !== false && bool2(args, "copy_positions") !== false; - const removeFromSource = bool2(params, "remove_from_source") !== false; + 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; @@ -1530,7 +2020,7 @@ async function layoutTopologyView(client, args, signal) { const wanted = new Set(fabricIds); const selected = nodes.filter((n) => { const row = asRecord2(n); - const id = str2(row, "id") || str2(row, "fabric_node_id"); + 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)); @@ -1542,7 +2032,7 @@ async function layoutTopologyView(client, args, signal) { const positions = selected.map((n) => { const row = asRecord2(n); return { - fabric_node_id: str2(row, "id") || str2(row, "fabric_node_id"), + fabric_node_id: str3(row, "id") || str3(row, "fabric_node_id"), x: Number(row.x || 0) + ox, y: Number(row.y || 0) + oy }; @@ -1558,16 +2048,16 @@ async function layoutTopologyView(client, args, signal) { moved: fabricIds.length, source_view_id: fromId, view_id: toId, - park: bool2(params, "park") === true || bool2(args, "park") === true + park: bool3(params, "park") === true || bool3(args, "park") === true }; } return layoutRecipeUnavailable(action); } // src/netx/tools.ts -var str3 = (description) => ({ type: "string", ...description ? { description } : {} }); -var num3 = (description) => ({ type: "number", ...description ? { description } : {} }); -var bool3 = (description) => ({ type: "boolean", ...description ? { description } : {} }); +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" }, @@ -1578,19 +2068,19 @@ var anyObj = (description) => ({ additionalProperties: true, ...description ? { description } : {} }); -function renderJson(_args, value) { +function renderJson2(_args, value) { return [{ type: "text", text: JSON.stringify(value, null, 0) }]; } -var jsonOut = { +var jsonOut2 = { schema: { type: "json" }, - render: renderJson + render: renderJson2 }; -function tool(name, description, parameters, handler, getClient, timeoutMs) { - return defineTool({ +function tool2(name, description, parameters, handler, getClient, timeoutMs) { + return defineTool2({ name, description, parameters, - output: jsonOut, + output: jsonOut2, timeoutMs, isConcurrencySafe: () => true, async execute(args, exec) { @@ -1616,90 +2106,90 @@ function registerNetxTools(ctx, connection, options) { const getClient = () => client; const t = connection.toolCallTimeoutMs; const catalog = [ - tool("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: str3(), - ne_id: str3("Filter only; do not show UUID to users"), - host_name: str3("Filter by NE host_name"), - ne_name: str3("Legacy alias mapped to keyword"), - keyword: str3("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), - time_from: str3("ISO time; filters last_seen_at >="), - time_to: str3("ISO time; filters last_seen_at <="), - page: num3(), - page_size: num3() + 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), - tool("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: str3("Optional perceived_severity filter (critical/major/minor/warning)."), - top_ne: num3("Max NE buckets (default 50). Ignored when group_by is set."), - exclude_missing_host: bool3("Omit missing host_name from by_ne."), - time_from: str3(), - time_to: str3(), - group_by: str3("When set, routes to raw aggregation. Prefer alarm_host_name."), - group_by2: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - limit: num3() + 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), - tool("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), - tool("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { - keyword: str3(), - page: num3(), - page_size: num3() + 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), - tool("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { + 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), - tool("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: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - time_from: str3(), - time_to: str3(), - order_by: str3("last_seen_at | time_created | perceived_severity | event_type | ne_id"), - order: str3("asc | desc"), + 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: str3("brief | evidence | ne_debug"), - page: num3(), - page_size: num3() + field_preset: str4("brief | evidence | ne_debug"), + page: num4(), + page_size: num4() }, queryUmeAlarmsRaw, getClient, t), - tool("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { + 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: str3(), - severity: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - time_from: str3(), - time_to: str3(), - exclude_missing_host: bool3(), - limit: num3() + 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), - tool("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), - tool("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { + 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: num3(), - statement_timeout_ms: num3() + limit: num4(), + statement_timeout_ms: num4() }, sqlQueryUme, getClient, t), - tool("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { - keyword: str3(), - vendor: str3(), - connect_status: str3("unknown | testing | pass | fail"), - page: num3(), - page_size: num3() + 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), - tool("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { - ne_id: str3("Managed NE id"), - managed_ne_id: str3("Alias for ne_id"), - id: str3("Alias for ne_id") + 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), - tool("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: str3(), - nms_ne_id: str3("NMS inventory id; alias ume_ne_id"), - ume_ne_id: str3("Legacy alias of nms_ne_id"), + 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"), @@ -1710,79 +2200,79 @@ function registerNetxTools(ctx, connection, options) { type: "object", additionalProperties: false, properties: { - ne_id: str3(), - nms_ne_id: str3(), - ume_ne_id: str3(), + 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: num3("Per-command read timeout (default 60; use 90–120 for slow show)."), - concurrency: num3("Parallel NEs for batch mode (1–8, default 4)."), - async: bool3("oclaw-only async hint; ignored by native REST client.") + 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)), - tool("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { - source: str3("managed | nms | ume | all"), - keyword: str3(), - page: num3(), - page_size: num3() + 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), - tool("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: str3("NMS inventory id"), - from_ume_ne_id: str3("Legacy alias of from_nms_ne_id"), - from_managed_ne_id: str3(), - to_nms_ne_id: str3("NMS inventory id"), - to_ume_ne_id: str3("Legacy alias of to_nms_ne_id"), - to_managed_ne_id: str3(), - max_paths: num3(), - max_hops: num3(), - layer: str3(), - detail: str3("summary | full") + 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), - tool("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool3(), max_depth: num3() }, getTopologyTree, getClient, t), - tool("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { + 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: str3("summary | full"), - sample: num3() + detail: str4("summary | full"), + sample: num4() }, getTopologyView, getClient, t), - tool("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { + tool2("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { name: { type: "string", required: true }, - parent_id: str3(), - locale: str3("zh | en"), - sort_order: num3() + parent_id: str4(), + locale: str4("zh | en"), + sort_order: num4() }, createTopologyFolder, getClient, t), - tool("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { + 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: num3(), - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), - limit: num3(), - offset: num3(), + max_nodes: num4(), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), + limit: num4(), + offset: num4(), fabric_node_ids: strArr(), - layout: str3("grid | keep") + layout: str4("grid | keep") }, addTopologyViewNodes, getClient, t), - tool("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { + tool2("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { view_id: { type: "string", required: true }, - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), fabric_node_ids: strArr() }, removeTopologyViewNodes, getClient, t), - tool("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { + 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: bool3(), - clear_target: bool3(), - offset_x: num3(), - offset_y: num3(), - limit: num3(), - dry_run: bool3() + copy_positions: bool4(), + clear_target: bool4(), + offset_x: num4(), + offset_y: num4(), + limit: num4(), + dry_run: bool4() }, copyTopologyViewNodes, getClient, t), - tool("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { + 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", @@ -1791,121 +2281,121 @@ function registerNetxTools(ctx, connection, options) { type: "object", additionalProperties: false, properties: { - fabric_node_id: str3(), - x: num3(), - y: num3() + fabric_node_id: str4(), + x: num4(), + y: num4() } } }, - layout: str3("grid | offset | stack"), - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), + layout: str4("grid | offset | stack"), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), fabric_node_ids: strArr(), - offset_x: num3(), - offset_y: num3() + offset_x: num4(), + offset_y: num4() }, updateTopologyViewPositions, getClient, t), - tool("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { + 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: str3(), - detail: str3("summary | full"), - sample: num3() + region_folder_id: str4(), + detail: str4("summary | full"), + sample: num4() }, projectTopologyNeighbors, getClient, t), - tool("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { - mode: str3("summary | list | search"), - q: str3(), - keyword: str3(), - role: str3(), - level: str3(), - level_major: str3(), - region_folder_id: str3(), - link_status: str3(), - page: num3(), - page_size: num3(), - limit: num3(), - summary: bool3() + 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), - tool("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { + tool2("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { action: { type: "string", required: true }, - pattern: str3(), - q: str3(), - match_field: str3(), - sample_limit: num3(), + pattern: str4(), + q: str4(), + match_field: str4(), + sample_limit: num4(), fabric_node_ids: strArr(), - fabric_node_id: str3(), - node_id: str3(), - level: str3(), - role: str3(), - region_folder_id: str3(), - clear_region: bool3(), - dry_run: bool3(), - kind: str3(), - page: num3(), - page_size: num3(), - skip_manual: bool3(), - overwrite_manual: bool3(), - fill_empty_only: bool3() + 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), - tool("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { + tool2("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { node_id: { type: "string", required: true }, - depth: num3(), - layer: str3() + depth: num4(), + layer: str4() }, queryTopologyNeighborhood, getClient, t), - tool("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { - node_id: str3(), - keyword: str3(), - layer: str3(), - status: str3(), - source: str3("lldp | ume | manual"), - detail: str3("adjacency | ports"), - page: num3(), - page_size: num3() + 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), - tool("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { - source_view_id: str3(), - view_id: str3(), - pick: num3(), + 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), - tool("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { - view_id: str3(), - folder_id: str3(), - detail: str3("summary | structure | hotspots | blocks | both"), - score_profile: str3(), - sight_limit: num3(), - max_views: num3(), - min_nodes: num3(), - max_nodes: num3(), - with_meta: bool3() + 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), - tool("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { - source_view_id: str3(), - sink_view_id: str3(), - max_units: num3(), - min_nodes: num3(), - max_nodes: num3(), - max_batch_nodes: num3(), - layout_batch: bool3(), - until_empty: bool3(), - dry_run: bool3() + 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), - tool("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: str3(), - action: str3(), - source_view_id: str3(), - recipe: str3(), - preset: str3(), - mode: str3("preview | apply"), - tune: bool3(), + 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: bool3(), + catalog: bool4(), fabric_node_ids: strArr(), - park: bool3(), - copy_positions: bool3() + 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)); @@ -1918,6 +2408,7 @@ function registerNetxTools(ctx, connection, options) { // src/netx/group-tools-plugin.ts function applyGroupToolsPlugin(ctx, options) { let unregisterTools; + let unregisterKbLocal; let unregisterSkills; let skillGeneration = 0; const resolveGroups = () => { @@ -1947,9 +2438,24 @@ function applyGroupToolsPlugin(ctx, options) { }); 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; @@ -2022,8 +2528,11 @@ function applyGroupToolsPlugin(ctx, options) { }); ctx.effect(() => () => { stopToolWatch(); + stopKbToolWatch(); unregisterTools?.(); unregisterTools = undefined; + unregisterKbLocal?.(); + unregisterKbLocal = undefined; }, `${options.name}: dispose tools`); } diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index 0ba544c..ec63a46 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -215,11 +215,165 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) { }; } +// 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" +]); +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 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")); +} + // 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 ? [ + "", + "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", + "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", + "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", + "Do **not** use workspace Write/bash for KB paths (sandbox).", + "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", + "file boundary issues as `suggestions/` instead." + ] : [ + "", + "No local write tools until `paths.local` is present in MANIFEST." + ]; return { description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", content: [ @@ -232,12 +386,14 @@ function skillBody(snapshot) { `| --- | --- |`, `| kbStatus | configured |`, `| kbRoot | \`${snapshot.realRoot}\` |`, + localRow, `| kbOperator | ${snapshot.operatorName} |`, `| kbCountry | ${snapshot.country} |`, `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, + ...localGuide, "", - "Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "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,`, @@ -277,21 +433,403 @@ function registerKbContextSkill(ctx, snapshot) { }); } +// 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 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 } : {} }); +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.", { + type: "object", + additionalProperties: false, + required: ["bucket", "slug", "body"], + properties: { + bucket: { + type: "string", + enum: ["note", "rca_review", "ai_trace"], + description: "Memory subdirectory" + }, + slug: str("Short filename stem (no path separators)"), + body: str("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 identity/ or formal RCA.", { + type: "object", + additionalProperties: false, + required: ["slug", "body"], + properties: { + slug: str("Case stem (DRAFT- prefix added if missing)"), + body: str("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 theory corrections or tool/process improvements — never rewrite identity/.", { + type: "object", + additionalProperties: false, + required: ["kind", "slug", "body"], + properties: { + kind: { + type: "string", + enum: ["theory", "improvement"], + description: "theory = knowledge fix; improvement = tools/skills/process" + }, + slug: str("Short filename stem"), + body: str("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__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + type: "object", + additionalProperties: false, + required: ["path", "body"], + properties: { + path: str("Absolute path or path relative to KB local root"), + body: str("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 ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + type: "object", + additionalProperties: false, + required: ["path"], + properties: { + path: str("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 (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + type: "object", + additionalProperties: false, + properties: { + root: { + type: "string", + enum: ["memories", "drafts", "suggestions", "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 join2 } from "node:path"; +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 join2(snapshot.realRoot, rel); + return join4(snapshot.realRoot, rel); } function resolveKbLocalSkillsRoot(snapshot) { const rel = snapshot.paths.localSkills?.trim(); if (!rel) return null; - return join2(snapshot.realRoot, rel); + return join4(snapshot.realRoot, rel); } async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; @@ -307,7 +845,7 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) return []; const disposers = []; for (const entry of entries) { - const full = join2(root, entry); + const full = join4(root, entry); let isDir = false; try { isDir = (await stat2(full)).isDirectory(); @@ -370,54 +908,6 @@ async function registerKbPackSkills(ctx, snapshot, options) { }; } -// 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/runtime.ts var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); function store2() { @@ -441,7 +931,7 @@ function watchNetxConnection(listener) { } // src/netx/tools.ts -import { defineTool } from "@deepseek-ai/dsh-tools"; +import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; // src/netx/http.ts var PROTOCOL_KEY_ZH_TO_EN = { @@ -604,7 +1094,7 @@ var UME_RAW_FIELD_PRESETS = { function asRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; } -function str(args, key, fallback = "") { +function str2(args, key, fallback = "") { const v = args[key]; if (typeof v === "string") return v; @@ -613,17 +1103,17 @@ function str(args, key, fallback = "") { return fallback; } function nmsOrUme(args, nmsKey, umeKey) { - return str(args, nmsKey).trim() || str(args, umeKey).trim(); + 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 num(args, key) { +function num2(args, key) { const v = args[key]; return typeof v === "number" && Number.isFinite(v) ? v : undefined; } -function bool(args, key) { +function bool2(args, key) { const v = args[key]; return typeof v === "boolean" ? v : undefined; } @@ -639,18 +1129,18 @@ function clampInt(value, fallback, min, max) { } function putStr(params, args, keys) { for (const key of keys) { - const v = str(args, key).trim(); + const v = str2(args, key).trim(); if (v) params[key] = v; } } async function queryUmeAlarms(client, args, signal) { - let page = clampInt(num(args, "page"), 1, 1, 2); - const pageSize = clampInt(num(args, "page_size"), 50, 1, 500); + 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 = str(args, "keyword").trim(); - const neName = str(args, "ne_name").trim(); + const keyword = str2(args, "keyword").trim(); + const neName = str2(args, "ne_name").trim(); if (keyword) params.keyword = keyword; else if (neName) @@ -672,19 +1162,19 @@ async function aggregateUmeAlarmsRaw(client, args, signal) { "limit" ]); if ("exclude_missing_host" in args) { - const flag = bool(args, "exclude_missing_host"); + 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 (str(args, "group_by").trim()) + if (str2(args, "group_by").trim()) return aggregateUmeAlarmsRaw(client, args, signal); - const topNe = clampInt(num(args, "top_ne"), 50, 0, 500); + const topNe = clampInt(num2(args, "top_ne"), 50, 0, 500); const params = { top_ne: topNe }; if ("exclude_missing_host" in args) { - const flag = bool(args, "exclude_missing_host"); + const flag = bool2(args, "exclude_missing_host"); if (flag !== undefined) params.exclude_missing_host = flag; } @@ -696,22 +1186,22 @@ async function runUmeDiagnostics(client, _args, signal) { } async function queryUmeNeInventory(client, args, signal) { const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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 = str(args, "ne_id").trim(); + 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(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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", @@ -726,7 +1216,7 @@ async function queryUmeAlarmsRaw(client, args, signal) { ]); let fields = strList(args, "select_fields"); if (fields.length === 0) { - const preset = str(args, "field_preset").trim().toLowerCase(); + const preset = str2(args, "field_preset").trim().toLowerCase(); fields = UME_RAW_FIELD_PRESETS[preset] ?? []; } if (fields.length > 0) @@ -737,11 +1227,11 @@ async function listUmeAlarmFields(client, _args, signal) { return client.get("/v1/ume/alarms/fields", undefined, signal); } async function sqlQueryUme(client, args, signal) { - const sql = str(args, "sql").trim(); + const sql = str2(args, "sql").trim(); if (!sql) return { ok: false, error: "sql_required" }; - const limit = clampInt(num(args, "limit"), 200, 1, 2000); - const statementTimeoutMs = clampInt(num(args, "statement_timeout_ms"), 0, 0, 30000); + 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, @@ -749,9 +1239,9 @@ async function sqlQueryUme(client, args, signal) { }, signal, 60000); } async function listManagedNe(client, args, signal) { - const keyword = str(args, "keyword").trim(); - const vendor = str(args, "vendor").trim(); - const connectStatus = str(args, "connect_status").trim(); + 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" }; } @@ -759,8 +1249,8 @@ async function listManagedNe(client, args, signal) { return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; } const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 20, 1, 100) + 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; @@ -771,7 +1261,7 @@ async function listManagedNe(client, args, signal) { return client.get("/v1/managed-ne", params, signal); } async function getManagedNe(client, args, signal) { - const neId = (str(args, "ne_id") || str(args, "managed_ne_id") || str(args, "id")).trim(); + const neId = (str2(args, "ne_id") || str2(args, "managed_ne_id") || str2(args, "id")).trim(); if (!neId) { return { ok: false, @@ -783,7 +1273,7 @@ async function getManagedNe(client, args, signal) { } const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); if (out.ok === false) { - const detail = `${str(out, "detail")}${str(out, "error")}`.toLowerCase(); + 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, @@ -808,7 +1298,7 @@ async function execManagedNe(client, args, signal) { continue; const row = t; const item = {}; - const neId2 = str(row, "ne_id").trim(); + const neId2 = str2(row, "ne_id").trim(); const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); if (neId2) item.ne_id = neId2; @@ -832,8 +1322,8 @@ async function execManagedNe(client, args, signal) { } body2.commands = sharedCommands; } - body2.read_timeout_sec = clampInt(num(args, "read_timeout_sec"), 60, 10, 120); - const concurrency = num(args, "concurrency"); + 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); @@ -841,11 +1331,11 @@ async function execManagedNe(client, args, signal) { return out2; const data2 = asRecord(out2.data); if (data2.ok === false) { - return { ok: false, data: data2, error: str(data2, "error", "exec_batch_failed") }; + return { ok: false, data: data2, error: str2(data2, "error", "exec_batch_failed") }; } return { ok: true, data: data2 }; } - const neId = str(args, "ne_id").trim(); + const neId = str2(args, "ne_id").trim(); const umeNeId = nmsOrUme(args, "nms_ne_id", "ume_ne_id"); if (Boolean(neId) === Boolean(umeNeId)) { return { @@ -863,7 +1353,7 @@ async function execManagedNe(client, args, signal) { } const body = { commands: sharedCommands, - read_timeout_sec: clampInt(num(args, "read_timeout_sec"), 60, 10, 120) + read_timeout_sec: clampInt(num2(args, "read_timeout_sec"), 60, 10, 120) }; if (neId) body.ne_id = neId; @@ -874,36 +1364,36 @@ async function execManagedNe(client, args, signal) { return out; const data = asRecord(out.data); if (data.ok === false) { - return { ok: false, data, error: str(data, "error", "exec_failed") }; + return { ok: false, data, error: str2(data, "error", "exec_failed") }; } return { ok: true, data }; } async function listCliTargets(client, args, signal) { const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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 = str(args, "from_managed_ne_id").trim(); + const fromMid = str2(args, "from_managed_ne_id").trim(); const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); - const toMid = str(args, "to_managed_ne_id").trim(); + 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 = str(args, "detail", "summary").trim().toLowerCase() || "summary"; + let detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; if (detail !== "summary" && detail !== "full") detail = "summary"; const body = { - max_paths: clampInt(num(args, "max_paths"), 3, 1, 10), - max_hops: clampInt(num(args, "max_hops"), 6, 1, 12), - layer: str(args, "layer", "physical").trim() || "physical", + 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) @@ -921,7 +1411,7 @@ async function findTopologyPaths(client, args, signal) { function asRecord2(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; } -function str2(args, key, fallback = "") { +function str3(args, key, fallback = "") { const v = args[key]; if (typeof v === "string") return v; @@ -929,11 +1419,11 @@ function str2(args, key, fallback = "") { return String(v); return fallback; } -function num2(args, key) { +function num3(args, key) { const v = args[key]; return typeof v === "number" && Number.isFinite(v) ? v : undefined; } -function bool2(args, key) { +function bool3(args, key) { const v = args[key]; return typeof v === "boolean" ? v : undefined; } @@ -958,15 +1448,15 @@ function unwrap(out) { function filterFields(args) { const out = {}; for (const key of ["keyword", "role", "vendor", "link_status"]) { - const v = str2(args, key).trim(); + const v = str3(args, key).trim(); if (v) out[key] = v; } return out; } function edgeEndpoints(edge) { - const a = str2(edge, "a_node_id") || str2(edge, "a"); - const b = str2(edge, "b_node_id") || str2(edge, "b"); + 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) { @@ -989,11 +1479,11 @@ function collapseEdgesToLinks(edges, includeNames = false) { row.link_count = Number(row.link_count || 0) + 1; if (includeNames && !row.a_name) { if (a === lo) { - row.a_name = str2(e, "a_name"); - row.b_name = str2(e, "b_name"); + row.a_name = str3(e, "a_name"); + row.b_name = str3(e, "b_name"); } else { - row.a_name = str2(e, "b_name"); - row.b_name = str2(e, "a_name"); + row.a_name = str3(e, "b_name"); + row.b_name = str3(e, "a_name"); } } buckets.set(key, row); @@ -1006,8 +1496,8 @@ function summarizeViewGraph(graph, sample = 20) { const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => { const row = asRecord2(n); return { - id: str2(row, "id") || str2(row, "fabric_node_id"), - name: str2(row, "name"), + id: str3(row, "id") || str3(row, "fabric_node_id"), + name: str3(row, "name"), x: row.x, y: row.y, level: row.level, @@ -1016,7 +1506,7 @@ function summarizeViewGraph(graph, sample = 20) { }); return { ok: true, - view_id: str2(graph, "id") || str2(graph, "view_id"), + view_id: str3(graph, "id") || str3(graph, "view_id"), node_count: nodes.length, edge_count: edges.length, link_count: collapseEdgesToLinks(edges).length, @@ -1037,41 +1527,41 @@ 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 = bool2(args, "compact") !== false; - const maxDepth = num2(args, "max_depth"); + 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 = str2(args, "view_id").trim(); + 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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; if (detail === "full") return { ...out, detail: "full" }; - return summarizeViewGraph(out, clampInt2(num2(args, "sample"), 20, 0, 200)); + return summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 200)); } async function createTopologyFolder(client, args, signal) { - const name = str2(args, "name").trim(); + const name = str3(args, "name").trim(); if (!name) return { ok: false, error: "name_required" }; const body = { name }; - const parentId = str2(args, "parent_id").trim(); + const parentId = str3(args, "parent_id").trim(); if (parentId) body.parent_id = parentId; - const locale = str2(args, "locale").trim(); + const locale = str3(args, "locale").trim(); if (locale) body.locale = locale; - if (num2(args, "sort_order") !== undefined) - body.sort_order = Math.trunc(num2(args, "sort_order")); + 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 = str2(args, "view_id").trim(); + 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) { @@ -1090,18 +1580,18 @@ async function addTopologyViewNodes(client, args, signal) { detail: "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids." }; } - if (num2(args, "max_nodes") !== undefined) { + 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(num2(args, "max_nodes"), 2000, 1, 2000); + 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 = str2(args, "layout", "grid").trim() || "grid"; + const layout = str3(args, "layout", "grid").trim() || "grid"; const body = { managed_ne_ids: [], layout, @@ -1109,14 +1599,14 @@ async function addTopologyViewNodes(client, args, signal) { }; if (fabricIds.length) body.fabric_node_ids = fabricIds; - if (num2(args, "limit") !== undefined) - body.limit = clampInt2(num2(args, "limit"), 500, 1, 2000); - if (num2(args, "offset") !== undefined) - body.offset = clampInt2(num2(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); + 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 = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; const body = { ...filterFields(args) }; @@ -1126,8 +1616,8 @@ async function removeTopologyViewNodes(client, args, signal) { return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, body, signal)); } async function copyTopologyViewNodes(client, args, signal) { - const sourceId = str2(args, "source_view_id").trim(); - const targetId = str2(args, "target_view_id").trim(); + 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" }; } @@ -1135,16 +1625,16 @@ async function copyTopologyViewNodes(client, args, signal) { if (source.ok !== true) return source; const nodes = Array.isArray(source.nodes) ? source.nodes : []; - const limit = num2(args, "limit"); + 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 str2(row, "id") || str2(row, "fabric_node_id"); + return str3(row, "id") || str3(row, "fabric_node_id"); }).filter(Boolean); - if (bool2(args, "dry_run") === true) { + 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 (bool2(args, "clear_target") === true) { + if (bool3(args, "clear_target") === true) { await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, { fabric_node_ids: "ALL", clear_all: true @@ -1156,12 +1646,12 @@ async function copyTopologyViewNodes(client, args, signal) { 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 (bool2(args, "copy_positions") !== false) { - const ox = num2(args, "offset_x") ?? 0; - const oy = num2(args, "offset_y") ?? 0; + 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 = str2(row, "id") || str2(row, "fabric_node_id"); + const id = str3(row, "id") || str3(row, "fabric_node_id"); return { fabric_node_id: id, x: Number(row.x || 0) + ox, @@ -1175,19 +1665,19 @@ async function copyTopologyViewNodes(client, args, signal) { return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; } async function updateTopologyViewPositions(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + 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 = str2(args, "layout").trim(); + 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 (num2(args, key) !== undefined) - body[key] = num2(args, key); + if (num3(args, key) !== undefined) + body[key] = num3(args, key); } const fabricIds = strList2(args, "fabric_node_ids"); if (fabricIds.length) @@ -1195,7 +1685,7 @@ async function updateTopologyViewPositions(client, args, signal) { return unwrap(await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, body, signal, 120000)); } async function projectTopologyNeighbors(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; const body = {}; @@ -1205,24 +1695,24 @@ async function projectTopologyNeighbors(client, args, signal) { const mids = strList2(args, "managed_ne_ids"); if (mids.length) body.managed_ne_ids = mids; - const region = str2(args, "region_folder_id").trim(); + 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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + 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(num2(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; + return { ...summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; } async function queryTopologyFabricNodes(client, args, signal) { - let mode = str2(args, "mode").trim().toLowerCase(); - const q = str2(args, "q").trim(); - const keyword = str2(args, "keyword").trim(); + let mode = str3(args, "mode").trim().toLowerCase(); + const q = str3(args, "q").trim(); + const keyword = str3(args, "keyword").trim(); if (!mode) { - if (bool2(args, "summary") === true) + if (bool3(args, "summary") === true) mode = "summary"; else if (q) mode = "search"; @@ -1239,16 +1729,16 @@ async function queryTopologyFabricNodes(client, args, signal) { 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(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 200) + 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(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + 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 = str2(args, key).trim() || (key === "keyword" ? q : ""); + const v = str3(args, key).trim() || (key === "keyword" ? q : ""); if (v) params[key] = v; } @@ -1256,61 +1746,61 @@ async function queryTopologyFabricNodes(client, args, signal) { return out.ok === true ? { ...out, mode: "list" } : out; } async function classifyTopologyFabricNodes(client, args, signal) { - const action = str2(args, "action").trim().toLowerCase(); + const action = str3(args, "action").trim().toLowerCase(); if (!action) return { ok: false, error: "action_required" }; if (action === "match") { - const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + 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: str2(args, "match_field", "name").trim() || "name", - sample_limit: clampInt2(num2(args, "sample_limit"), 50, 1, 200) + 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: bool2(args, "dry_run") === true + dry_run: bool3(args, "dry_run") === true }; - const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + 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 = str2(args, "role").trim(); + const role = str3(args, "role").trim(); if (role) body.role = role; - const region = str2(args, "region_folder_id").trim(); + const region = str3(args, "region_folder_id").trim(); if (region) body.region_folder_id = region; - if (bool2(args, "clear_region") === true) + 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 = str2(args, "fabric_node_id").trim() || str2(args, "node_id").trim(); + 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 = str2(args, "role").trim(); + const role = str3(args, "role").trim(); if (role) body.role = role; - const region = str2(args, "region_folder_id").trim(); + const region = str3(args, "region_folder_id").trim(); if (region) body.region_folder_id = region; - if (bool2(args, "clear_region") === true) + 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: str2(args, "kind", "any").trim() || "any", - page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + 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") { @@ -1318,11 +1808,11 @@ async function classifyTopologyFabricNodes(client, args, signal) { } if (action === "apply_rules") { const params = {}; - if (bool2(args, "overwrite_manual") === true) + if (bool3(args, "overwrite_manual") === true) params.skip_manual = false; - else if (bool2(args, "skip_manual") !== undefined) - params.skip_manual = bool2(args, "skip_manual") !== false; - if (bool2(args, "fill_empty_only") === true) + 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)); } @@ -1332,13 +1822,13 @@ async function classifyTopologyFabricNodes(client, args, signal) { return { ok: false, error: "unknown_action", action }; } async function queryTopologyNeighborhood(client, args, signal) { - const nodeId = str2(args, "node_id").trim(); + 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(num2(args, "depth"), 1, 1, 3), - layer: str2(args, "layer", "physical").trim() || "physical" + depth: clampInt2(num3(args, "depth"), 1, 1, 3), + layer: str3(args, "layer", "physical").trim() || "physical" }, signal)); if (out.ok !== true) return out; @@ -1347,19 +1837,19 @@ async function queryTopologyNeighborhood(client, args, signal) { } async function queryTopologyEdges(client, args, signal) { const params = { - page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size"), 100, 1, 500), - layer: str2(args, "layer", "physical").trim() || "physical" + 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 = str2(args, key).trim(); + 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 = str2(args, "detail", "adjacency").trim().toLowerCase() || "adjacency"; + 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 }; @@ -1367,7 +1857,7 @@ async function queryTopologyEdges(client, args, signal) { return { ...out, detail: "adjacency", links, items: undefined }; } async function suggestSinkHubs(client, args, signal) { - const viewId = str2(args, "source_view_id").trim() || str2(args, "view_id").trim(); + 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)); @@ -1387,17 +1877,17 @@ async function suggestSinkHubs(client, args, signal) { ...strList2(args, "exclude_portal_ids"), ...strList2(args, "exclude_fabric_node_ids") ]); - const pick = clampInt2(num2(args, "pick"), 5, 1, 50); + 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 (str2(row2, "id") || str2(row2, "fabric_node_id")) === fabric_node_id; + return (str3(row2, "id") || str3(row2, "fabric_node_id")) === fabric_node_id; }); const row = asRecord2(node); return { fabric_node_id, degree: deg, - name: str2(row, "name"), + name: str3(row, "name"), level: row.level, role: row.role }; @@ -1410,7 +1900,7 @@ async function suggestSinkHubs(client, args, signal) { }; } async function analyzeTopologyViewLayout(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) { return { ok: false, @@ -1453,7 +1943,7 @@ async function analyzeTopologyViewLayout(client, args, signal) { return { ok: true, view_id: viewId, - detail: str2(args, "detail", "summary") || "summary", + detail: str3(args, "detail", "summary") || "summary", node_count: nodes.length, edge_count: edges.length, link_count: collapseEdgesToLinks(edges).length, @@ -1467,13 +1957,13 @@ async function sinkTopologyDualUnits(_client, args) { return { ok: false, error: "sink_dual_units_requires_netx_topology_mcp", - source_view_id: str2(args, "source_view_id"), - sink_view_id: str2(args, "sink_view_id"), + 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 (bool2(args, "catalog") === true) { + if (bool3(args, "catalog") === true) { return { ok: true, catalog: true, @@ -1501,7 +1991,7 @@ async function layoutTopologyView(client, args, signal) { hint: "Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP." }; } - const action = str2(args, "action", "layout").trim().toLowerCase() || "layout"; + const action = str3(args, "action", "layout").trim().toLowerCase() || "layout"; if (action === "job_status" || action === "job_cancel") { return { ok: false, @@ -1510,8 +2000,8 @@ async function layoutTopologyView(client, args, signal) { }; } if (action === "move_nodes" || action === "sink_nodes") { - const toId = str2(args, "view_id").trim(); - const fromId = str2(args, "source_view_id").trim(); + 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"); @@ -1521,8 +2011,8 @@ async function layoutTopologyView(client, args, signal) { if (fabricIds.length === 0) { return { ok: false, error: "fabric_node_ids_required" }; } - const copyPositions = bool2(params, "copy_positions") !== false && bool2(args, "copy_positions") !== false; - const removeFromSource = bool2(params, "remove_from_source") !== false; + 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; @@ -1530,7 +2020,7 @@ async function layoutTopologyView(client, args, signal) { const wanted = new Set(fabricIds); const selected = nodes.filter((n) => { const row = asRecord2(n); - const id = str2(row, "id") || str2(row, "fabric_node_id"); + 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)); @@ -1542,7 +2032,7 @@ async function layoutTopologyView(client, args, signal) { const positions = selected.map((n) => { const row = asRecord2(n); return { - fabric_node_id: str2(row, "id") || str2(row, "fabric_node_id"), + fabric_node_id: str3(row, "id") || str3(row, "fabric_node_id"), x: Number(row.x || 0) + ox, y: Number(row.y || 0) + oy }; @@ -1558,16 +2048,16 @@ async function layoutTopologyView(client, args, signal) { moved: fabricIds.length, source_view_id: fromId, view_id: toId, - park: bool2(params, "park") === true || bool2(args, "park") === true + park: bool3(params, "park") === true || bool3(args, "park") === true }; } return layoutRecipeUnavailable(action); } // src/netx/tools.ts -var str3 = (description) => ({ type: "string", ...description ? { description } : {} }); -var num3 = (description) => ({ type: "number", ...description ? { description } : {} }); -var bool3 = (description) => ({ type: "boolean", ...description ? { description } : {} }); +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" }, @@ -1578,19 +2068,19 @@ var anyObj = (description) => ({ additionalProperties: true, ...description ? { description } : {} }); -function renderJson(_args, value) { +function renderJson2(_args, value) { return [{ type: "text", text: JSON.stringify(value, null, 0) }]; } -var jsonOut = { +var jsonOut2 = { schema: { type: "json" }, - render: renderJson + render: renderJson2 }; -function tool(name, description, parameters, handler, getClient, timeoutMs) { - return defineTool({ +function tool2(name, description, parameters, handler, getClient, timeoutMs) { + return defineTool2({ name, description, parameters, - output: jsonOut, + output: jsonOut2, timeoutMs, isConcurrencySafe: () => true, async execute(args, exec) { @@ -1616,90 +2106,90 @@ function registerNetxTools(ctx, connection, options) { const getClient = () => client; const t = connection.toolCallTimeoutMs; const catalog = [ - tool("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: str3(), - ne_id: str3("Filter only; do not show UUID to users"), - host_name: str3("Filter by NE host_name"), - ne_name: str3("Legacy alias mapped to keyword"), - keyword: str3("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), - time_from: str3("ISO time; filters last_seen_at >="), - time_to: str3("ISO time; filters last_seen_at <="), - page: num3(), - page_size: num3() + 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), - tool("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: str3("Optional perceived_severity filter (critical/major/minor/warning)."), - top_ne: num3("Max NE buckets (default 50). Ignored when group_by is set."), - exclude_missing_host: bool3("Omit missing host_name from by_ne."), - time_from: str3(), - time_to: str3(), - group_by: str3("When set, routes to raw aggregation. Prefer alarm_host_name."), - group_by2: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - limit: num3() + 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), - tool("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), - tool("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { - keyword: str3(), - page: num3(), - page_size: num3() + 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), - tool("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { + 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), - tool("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: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - time_from: str3(), - time_to: str3(), - order_by: str3("last_seen_at | time_created | perceived_severity | event_type | ne_id"), - order: str3("asc | desc"), + 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: str3("brief | evidence | ne_debug"), - page: num3(), - page_size: num3() + field_preset: str4("brief | evidence | ne_debug"), + page: num4(), + page_size: num4() }, queryUmeAlarmsRaw, getClient, t), - tool("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { + 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: str3(), - severity: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - time_from: str3(), - time_to: str3(), - exclude_missing_host: bool3(), - limit: num3() + 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), - tool("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), - tool("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { + 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: num3(), - statement_timeout_ms: num3() + limit: num4(), + statement_timeout_ms: num4() }, sqlQueryUme, getClient, t), - tool("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { - keyword: str3(), - vendor: str3(), - connect_status: str3("unknown | testing | pass | fail"), - page: num3(), - page_size: num3() + 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), - tool("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { - ne_id: str3("Managed NE id"), - managed_ne_id: str3("Alias for ne_id"), - id: str3("Alias for ne_id") + 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), - tool("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: str3(), - nms_ne_id: str3("NMS inventory id; alias ume_ne_id"), - ume_ne_id: str3("Legacy alias of nms_ne_id"), + 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"), @@ -1710,79 +2200,79 @@ function registerNetxTools(ctx, connection, options) { type: "object", additionalProperties: false, properties: { - ne_id: str3(), - nms_ne_id: str3(), - ume_ne_id: str3(), + 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: num3("Per-command read timeout (default 60; use 90–120 for slow show)."), - concurrency: num3("Parallel NEs for batch mode (1–8, default 4)."), - async: bool3("oclaw-only async hint; ignored by native REST client.") + 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)), - tool("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { - source: str3("managed | nms | ume | all"), - keyword: str3(), - page: num3(), - page_size: num3() + 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), - tool("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: str3("NMS inventory id"), - from_ume_ne_id: str3("Legacy alias of from_nms_ne_id"), - from_managed_ne_id: str3(), - to_nms_ne_id: str3("NMS inventory id"), - to_ume_ne_id: str3("Legacy alias of to_nms_ne_id"), - to_managed_ne_id: str3(), - max_paths: num3(), - max_hops: num3(), - layer: str3(), - detail: str3("summary | full") + 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), - tool("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool3(), max_depth: num3() }, getTopologyTree, getClient, t), - tool("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { + 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: str3("summary | full"), - sample: num3() + detail: str4("summary | full"), + sample: num4() }, getTopologyView, getClient, t), - tool("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { + tool2("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { name: { type: "string", required: true }, - parent_id: str3(), - locale: str3("zh | en"), - sort_order: num3() + parent_id: str4(), + locale: str4("zh | en"), + sort_order: num4() }, createTopologyFolder, getClient, t), - tool("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { + 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: num3(), - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), - limit: num3(), - offset: num3(), + max_nodes: num4(), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), + limit: num4(), + offset: num4(), fabric_node_ids: strArr(), - layout: str3("grid | keep") + layout: str4("grid | keep") }, addTopologyViewNodes, getClient, t), - tool("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { + tool2("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { view_id: { type: "string", required: true }, - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), fabric_node_ids: strArr() }, removeTopologyViewNodes, getClient, t), - tool("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { + 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: bool3(), - clear_target: bool3(), - offset_x: num3(), - offset_y: num3(), - limit: num3(), - dry_run: bool3() + copy_positions: bool4(), + clear_target: bool4(), + offset_x: num4(), + offset_y: num4(), + limit: num4(), + dry_run: bool4() }, copyTopologyViewNodes, getClient, t), - tool("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { + 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", @@ -1791,121 +2281,121 @@ function registerNetxTools(ctx, connection, options) { type: "object", additionalProperties: false, properties: { - fabric_node_id: str3(), - x: num3(), - y: num3() + fabric_node_id: str4(), + x: num4(), + y: num4() } } }, - layout: str3("grid | offset | stack"), - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), + layout: str4("grid | offset | stack"), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), fabric_node_ids: strArr(), - offset_x: num3(), - offset_y: num3() + offset_x: num4(), + offset_y: num4() }, updateTopologyViewPositions, getClient, t), - tool("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { + 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: str3(), - detail: str3("summary | full"), - sample: num3() + region_folder_id: str4(), + detail: str4("summary | full"), + sample: num4() }, projectTopologyNeighbors, getClient, t), - tool("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { - mode: str3("summary | list | search"), - q: str3(), - keyword: str3(), - role: str3(), - level: str3(), - level_major: str3(), - region_folder_id: str3(), - link_status: str3(), - page: num3(), - page_size: num3(), - limit: num3(), - summary: bool3() + 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), - tool("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { + tool2("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { action: { type: "string", required: true }, - pattern: str3(), - q: str3(), - match_field: str3(), - sample_limit: num3(), + pattern: str4(), + q: str4(), + match_field: str4(), + sample_limit: num4(), fabric_node_ids: strArr(), - fabric_node_id: str3(), - node_id: str3(), - level: str3(), - role: str3(), - region_folder_id: str3(), - clear_region: bool3(), - dry_run: bool3(), - kind: str3(), - page: num3(), - page_size: num3(), - skip_manual: bool3(), - overwrite_manual: bool3(), - fill_empty_only: bool3() + 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), - tool("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { + tool2("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { node_id: { type: "string", required: true }, - depth: num3(), - layer: str3() + depth: num4(), + layer: str4() }, queryTopologyNeighborhood, getClient, t), - tool("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { - node_id: str3(), - keyword: str3(), - layer: str3(), - status: str3(), - source: str3("lldp | ume | manual"), - detail: str3("adjacency | ports"), - page: num3(), - page_size: num3() + 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), - tool("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { - source_view_id: str3(), - view_id: str3(), - pick: num3(), + 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), - tool("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { - view_id: str3(), - folder_id: str3(), - detail: str3("summary | structure | hotspots | blocks | both"), - score_profile: str3(), - sight_limit: num3(), - max_views: num3(), - min_nodes: num3(), - max_nodes: num3(), - with_meta: bool3() + 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), - tool("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { - source_view_id: str3(), - sink_view_id: str3(), - max_units: num3(), - min_nodes: num3(), - max_nodes: num3(), - max_batch_nodes: num3(), - layout_batch: bool3(), - until_empty: bool3(), - dry_run: bool3() + 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), - tool("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: str3(), - action: str3(), - source_view_id: str3(), - recipe: str3(), - preset: str3(), - mode: str3("preview | apply"), - tune: bool3(), + 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: bool3(), + catalog: bool4(), fabric_node_ids: strArr(), - park: bool3(), - copy_positions: bool3() + 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)); @@ -1918,6 +2408,7 @@ function registerNetxTools(ctx, connection, options) { // src/netx/group-tools-plugin.ts function applyGroupToolsPlugin(ctx, options) { let unregisterTools; + let unregisterKbLocal; let unregisterSkills; let skillGeneration = 0; const resolveGroups = () => { @@ -1947,9 +2438,24 @@ function applyGroupToolsPlugin(ctx, options) { }); 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; @@ -2022,8 +2528,11 @@ function applyGroupToolsPlugin(ctx, options) { }); ctx.effect(() => () => { stopToolWatch(); + stopKbToolWatch(); unregisterTools?.(); unregisterTools = undefined; + unregisterKbLocal?.(); + unregisterKbLocal = undefined; }, `${options.name}: dispose tools`); } diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 48185e0..0050eab 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -215,11 +215,165 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) { }; } +// 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" +]); +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 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")); +} + // 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 ? [ + "", + "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", + "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", + "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", + "Do **not** use workspace Write/bash for KB paths (sandbox).", + "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", + "file boundary issues as `suggestions/` instead." + ] : [ + "", + "No local write tools until `paths.local` is present in MANIFEST." + ]; return { description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", content: [ @@ -232,12 +386,14 @@ function skillBody(snapshot) { `| --- | --- |`, `| kbStatus | configured |`, `| kbRoot | \`${snapshot.realRoot}\` |`, + localRow, `| kbOperator | ${snapshot.operatorName} |`, `| kbCountry | ${snapshot.country} |`, `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, + ...localGuide, "", - "Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "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,`, @@ -277,21 +433,403 @@ function registerKbContextSkill(ctx, snapshot) { }); } +// 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 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 } : {} }); +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.", { + type: "object", + additionalProperties: false, + required: ["bucket", "slug", "body"], + properties: { + bucket: { + type: "string", + enum: ["note", "rca_review", "ai_trace"], + description: "Memory subdirectory" + }, + slug: str("Short filename stem (no path separators)"), + body: str("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 identity/ or formal RCA.", { + type: "object", + additionalProperties: false, + required: ["slug", "body"], + properties: { + slug: str("Case stem (DRAFT- prefix added if missing)"), + body: str("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 theory corrections or tool/process improvements — never rewrite identity/.", { + type: "object", + additionalProperties: false, + required: ["kind", "slug", "body"], + properties: { + kind: { + type: "string", + enum: ["theory", "improvement"], + description: "theory = knowledge fix; improvement = tools/skills/process" + }, + slug: str("Short filename stem"), + body: str("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__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + type: "object", + additionalProperties: false, + required: ["path", "body"], + properties: { + path: str("Absolute path or path relative to KB local root"), + body: str("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 ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + type: "object", + additionalProperties: false, + required: ["path"], + properties: { + path: str("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 (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + type: "object", + additionalProperties: false, + properties: { + root: { + type: "string", + enum: ["memories", "drafts", "suggestions", "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 join2 } from "node:path"; +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 join2(snapshot.realRoot, rel); + return join4(snapshot.realRoot, rel); } function resolveKbLocalSkillsRoot(snapshot) { const rel = snapshot.paths.localSkills?.trim(); if (!rel) return null; - return join2(snapshot.realRoot, rel); + return join4(snapshot.realRoot, rel); } async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; @@ -307,7 +845,7 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) return []; const disposers = []; for (const entry of entries) { - const full = join2(root, entry); + const full = join4(root, entry); let isDir = false; try { isDir = (await stat2(full)).isDirectory(); @@ -370,54 +908,6 @@ async function registerKbPackSkills(ctx, snapshot, options) { }; } -// 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/runtime.ts var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); function store2() { @@ -441,7 +931,7 @@ function watchNetxConnection(listener) { } // src/netx/tools.ts -import { defineTool } from "@deepseek-ai/dsh-tools"; +import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; // src/netx/http.ts var PROTOCOL_KEY_ZH_TO_EN = { @@ -604,7 +1094,7 @@ var UME_RAW_FIELD_PRESETS = { function asRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; } -function str(args, key, fallback = "") { +function str2(args, key, fallback = "") { const v = args[key]; if (typeof v === "string") return v; @@ -613,17 +1103,17 @@ function str(args, key, fallback = "") { return fallback; } function nmsOrUme(args, nmsKey, umeKey) { - return str(args, nmsKey).trim() || str(args, umeKey).trim(); + 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 num(args, key) { +function num2(args, key) { const v = args[key]; return typeof v === "number" && Number.isFinite(v) ? v : undefined; } -function bool(args, key) { +function bool2(args, key) { const v = args[key]; return typeof v === "boolean" ? v : undefined; } @@ -639,18 +1129,18 @@ function clampInt(value, fallback, min, max) { } function putStr(params, args, keys) { for (const key of keys) { - const v = str(args, key).trim(); + const v = str2(args, key).trim(); if (v) params[key] = v; } } async function queryUmeAlarms(client, args, signal) { - let page = clampInt(num(args, "page"), 1, 1, 2); - const pageSize = clampInt(num(args, "page_size"), 50, 1, 500); + 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 = str(args, "keyword").trim(); - const neName = str(args, "ne_name").trim(); + const keyword = str2(args, "keyword").trim(); + const neName = str2(args, "ne_name").trim(); if (keyword) params.keyword = keyword; else if (neName) @@ -672,19 +1162,19 @@ async function aggregateUmeAlarmsRaw(client, args, signal) { "limit" ]); if ("exclude_missing_host" in args) { - const flag = bool(args, "exclude_missing_host"); + 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 (str(args, "group_by").trim()) + if (str2(args, "group_by").trim()) return aggregateUmeAlarmsRaw(client, args, signal); - const topNe = clampInt(num(args, "top_ne"), 50, 0, 500); + const topNe = clampInt(num2(args, "top_ne"), 50, 0, 500); const params = { top_ne: topNe }; if ("exclude_missing_host" in args) { - const flag = bool(args, "exclude_missing_host"); + const flag = bool2(args, "exclude_missing_host"); if (flag !== undefined) params.exclude_missing_host = flag; } @@ -696,22 +1186,22 @@ async function runUmeDiagnostics(client, _args, signal) { } async function queryUmeNeInventory(client, args, signal) { const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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 = str(args, "ne_id").trim(); + 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(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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", @@ -726,7 +1216,7 @@ async function queryUmeAlarmsRaw(client, args, signal) { ]); let fields = strList(args, "select_fields"); if (fields.length === 0) { - const preset = str(args, "field_preset").trim().toLowerCase(); + const preset = str2(args, "field_preset").trim().toLowerCase(); fields = UME_RAW_FIELD_PRESETS[preset] ?? []; } if (fields.length > 0) @@ -737,11 +1227,11 @@ async function listUmeAlarmFields(client, _args, signal) { return client.get("/v1/ume/alarms/fields", undefined, signal); } async function sqlQueryUme(client, args, signal) { - const sql = str(args, "sql").trim(); + const sql = str2(args, "sql").trim(); if (!sql) return { ok: false, error: "sql_required" }; - const limit = clampInt(num(args, "limit"), 200, 1, 2000); - const statementTimeoutMs = clampInt(num(args, "statement_timeout_ms"), 0, 0, 30000); + 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, @@ -749,9 +1239,9 @@ async function sqlQueryUme(client, args, signal) { }, signal, 60000); } async function listManagedNe(client, args, signal) { - const keyword = str(args, "keyword").trim(); - const vendor = str(args, "vendor").trim(); - const connectStatus = str(args, "connect_status").trim(); + 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" }; } @@ -759,8 +1249,8 @@ async function listManagedNe(client, args, signal) { return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; } const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 20, 1, 100) + 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; @@ -771,7 +1261,7 @@ async function listManagedNe(client, args, signal) { return client.get("/v1/managed-ne", params, signal); } async function getManagedNe(client, args, signal) { - const neId = (str(args, "ne_id") || str(args, "managed_ne_id") || str(args, "id")).trim(); + const neId = (str2(args, "ne_id") || str2(args, "managed_ne_id") || str2(args, "id")).trim(); if (!neId) { return { ok: false, @@ -783,7 +1273,7 @@ async function getManagedNe(client, args, signal) { } const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); if (out.ok === false) { - const detail = `${str(out, "detail")}${str(out, "error")}`.toLowerCase(); + 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, @@ -808,7 +1298,7 @@ async function execManagedNe(client, args, signal) { continue; const row = t; const item = {}; - const neId2 = str(row, "ne_id").trim(); + const neId2 = str2(row, "ne_id").trim(); const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); if (neId2) item.ne_id = neId2; @@ -832,8 +1322,8 @@ async function execManagedNe(client, args, signal) { } body2.commands = sharedCommands; } - body2.read_timeout_sec = clampInt(num(args, "read_timeout_sec"), 60, 10, 120); - const concurrency = num(args, "concurrency"); + 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); @@ -841,11 +1331,11 @@ async function execManagedNe(client, args, signal) { return out2; const data2 = asRecord(out2.data); if (data2.ok === false) { - return { ok: false, data: data2, error: str(data2, "error", "exec_batch_failed") }; + return { ok: false, data: data2, error: str2(data2, "error", "exec_batch_failed") }; } return { ok: true, data: data2 }; } - const neId = str(args, "ne_id").trim(); + const neId = str2(args, "ne_id").trim(); const umeNeId = nmsOrUme(args, "nms_ne_id", "ume_ne_id"); if (Boolean(neId) === Boolean(umeNeId)) { return { @@ -863,7 +1353,7 @@ async function execManagedNe(client, args, signal) { } const body = { commands: sharedCommands, - read_timeout_sec: clampInt(num(args, "read_timeout_sec"), 60, 10, 120) + read_timeout_sec: clampInt(num2(args, "read_timeout_sec"), 60, 10, 120) }; if (neId) body.ne_id = neId; @@ -874,36 +1364,36 @@ async function execManagedNe(client, args, signal) { return out; const data = asRecord(out.data); if (data.ok === false) { - return { ok: false, data, error: str(data, "error", "exec_failed") }; + return { ok: false, data, error: str2(data, "error", "exec_failed") }; } return { ok: true, data }; } async function listCliTargets(client, args, signal) { const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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 = str(args, "from_managed_ne_id").trim(); + const fromMid = str2(args, "from_managed_ne_id").trim(); const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); - const toMid = str(args, "to_managed_ne_id").trim(); + 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 = str(args, "detail", "summary").trim().toLowerCase() || "summary"; + let detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; if (detail !== "summary" && detail !== "full") detail = "summary"; const body = { - max_paths: clampInt(num(args, "max_paths"), 3, 1, 10), - max_hops: clampInt(num(args, "max_hops"), 6, 1, 12), - layer: str(args, "layer", "physical").trim() || "physical", + 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) @@ -921,7 +1411,7 @@ async function findTopologyPaths(client, args, signal) { function asRecord2(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; } -function str2(args, key, fallback = "") { +function str3(args, key, fallback = "") { const v = args[key]; if (typeof v === "string") return v; @@ -929,11 +1419,11 @@ function str2(args, key, fallback = "") { return String(v); return fallback; } -function num2(args, key) { +function num3(args, key) { const v = args[key]; return typeof v === "number" && Number.isFinite(v) ? v : undefined; } -function bool2(args, key) { +function bool3(args, key) { const v = args[key]; return typeof v === "boolean" ? v : undefined; } @@ -958,15 +1448,15 @@ function unwrap(out) { function filterFields(args) { const out = {}; for (const key of ["keyword", "role", "vendor", "link_status"]) { - const v = str2(args, key).trim(); + const v = str3(args, key).trim(); if (v) out[key] = v; } return out; } function edgeEndpoints(edge) { - const a = str2(edge, "a_node_id") || str2(edge, "a"); - const b = str2(edge, "b_node_id") || str2(edge, "b"); + 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) { @@ -989,11 +1479,11 @@ function collapseEdgesToLinks(edges, includeNames = false) { row.link_count = Number(row.link_count || 0) + 1; if (includeNames && !row.a_name) { if (a === lo) { - row.a_name = str2(e, "a_name"); - row.b_name = str2(e, "b_name"); + row.a_name = str3(e, "a_name"); + row.b_name = str3(e, "b_name"); } else { - row.a_name = str2(e, "b_name"); - row.b_name = str2(e, "a_name"); + row.a_name = str3(e, "b_name"); + row.b_name = str3(e, "a_name"); } } buckets.set(key, row); @@ -1006,8 +1496,8 @@ function summarizeViewGraph(graph, sample = 20) { const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => { const row = asRecord2(n); return { - id: str2(row, "id") || str2(row, "fabric_node_id"), - name: str2(row, "name"), + id: str3(row, "id") || str3(row, "fabric_node_id"), + name: str3(row, "name"), x: row.x, y: row.y, level: row.level, @@ -1016,7 +1506,7 @@ function summarizeViewGraph(graph, sample = 20) { }); return { ok: true, - view_id: str2(graph, "id") || str2(graph, "view_id"), + view_id: str3(graph, "id") || str3(graph, "view_id"), node_count: nodes.length, edge_count: edges.length, link_count: collapseEdgesToLinks(edges).length, @@ -1037,41 +1527,41 @@ 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 = bool2(args, "compact") !== false; - const maxDepth = num2(args, "max_depth"); + 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 = str2(args, "view_id").trim(); + 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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; if (detail === "full") return { ...out, detail: "full" }; - return summarizeViewGraph(out, clampInt2(num2(args, "sample"), 20, 0, 200)); + return summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 200)); } async function createTopologyFolder(client, args, signal) { - const name = str2(args, "name").trim(); + const name = str3(args, "name").trim(); if (!name) return { ok: false, error: "name_required" }; const body = { name }; - const parentId = str2(args, "parent_id").trim(); + const parentId = str3(args, "parent_id").trim(); if (parentId) body.parent_id = parentId; - const locale = str2(args, "locale").trim(); + const locale = str3(args, "locale").trim(); if (locale) body.locale = locale; - if (num2(args, "sort_order") !== undefined) - body.sort_order = Math.trunc(num2(args, "sort_order")); + 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 = str2(args, "view_id").trim(); + 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) { @@ -1090,18 +1580,18 @@ async function addTopologyViewNodes(client, args, signal) { detail: "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids." }; } - if (num2(args, "max_nodes") !== undefined) { + 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(num2(args, "max_nodes"), 2000, 1, 2000); + 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 = str2(args, "layout", "grid").trim() || "grid"; + const layout = str3(args, "layout", "grid").trim() || "grid"; const body = { managed_ne_ids: [], layout, @@ -1109,14 +1599,14 @@ async function addTopologyViewNodes(client, args, signal) { }; if (fabricIds.length) body.fabric_node_ids = fabricIds; - if (num2(args, "limit") !== undefined) - body.limit = clampInt2(num2(args, "limit"), 500, 1, 2000); - if (num2(args, "offset") !== undefined) - body.offset = clampInt2(num2(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); + 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 = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; const body = { ...filterFields(args) }; @@ -1126,8 +1616,8 @@ async function removeTopologyViewNodes(client, args, signal) { return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, body, signal)); } async function copyTopologyViewNodes(client, args, signal) { - const sourceId = str2(args, "source_view_id").trim(); - const targetId = str2(args, "target_view_id").trim(); + 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" }; } @@ -1135,16 +1625,16 @@ async function copyTopologyViewNodes(client, args, signal) { if (source.ok !== true) return source; const nodes = Array.isArray(source.nodes) ? source.nodes : []; - const limit = num2(args, "limit"); + 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 str2(row, "id") || str2(row, "fabric_node_id"); + return str3(row, "id") || str3(row, "fabric_node_id"); }).filter(Boolean); - if (bool2(args, "dry_run") === true) { + 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 (bool2(args, "clear_target") === true) { + if (bool3(args, "clear_target") === true) { await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, { fabric_node_ids: "ALL", clear_all: true @@ -1156,12 +1646,12 @@ async function copyTopologyViewNodes(client, args, signal) { 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 (bool2(args, "copy_positions") !== false) { - const ox = num2(args, "offset_x") ?? 0; - const oy = num2(args, "offset_y") ?? 0; + 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 = str2(row, "id") || str2(row, "fabric_node_id"); + const id = str3(row, "id") || str3(row, "fabric_node_id"); return { fabric_node_id: id, x: Number(row.x || 0) + ox, @@ -1175,19 +1665,19 @@ async function copyTopologyViewNodes(client, args, signal) { return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; } async function updateTopologyViewPositions(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + 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 = str2(args, "layout").trim(); + 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 (num2(args, key) !== undefined) - body[key] = num2(args, key); + if (num3(args, key) !== undefined) + body[key] = num3(args, key); } const fabricIds = strList2(args, "fabric_node_ids"); if (fabricIds.length) @@ -1195,7 +1685,7 @@ async function updateTopologyViewPositions(client, args, signal) { return unwrap(await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, body, signal, 120000)); } async function projectTopologyNeighbors(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; const body = {}; @@ -1205,24 +1695,24 @@ async function projectTopologyNeighbors(client, args, signal) { const mids = strList2(args, "managed_ne_ids"); if (mids.length) body.managed_ne_ids = mids; - const region = str2(args, "region_folder_id").trim(); + 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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + 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(num2(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; + return { ...summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; } async function queryTopologyFabricNodes(client, args, signal) { - let mode = str2(args, "mode").trim().toLowerCase(); - const q = str2(args, "q").trim(); - const keyword = str2(args, "keyword").trim(); + let mode = str3(args, "mode").trim().toLowerCase(); + const q = str3(args, "q").trim(); + const keyword = str3(args, "keyword").trim(); if (!mode) { - if (bool2(args, "summary") === true) + if (bool3(args, "summary") === true) mode = "summary"; else if (q) mode = "search"; @@ -1239,16 +1729,16 @@ async function queryTopologyFabricNodes(client, args, signal) { 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(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 200) + 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(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + 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 = str2(args, key).trim() || (key === "keyword" ? q : ""); + const v = str3(args, key).trim() || (key === "keyword" ? q : ""); if (v) params[key] = v; } @@ -1256,61 +1746,61 @@ async function queryTopologyFabricNodes(client, args, signal) { return out.ok === true ? { ...out, mode: "list" } : out; } async function classifyTopologyFabricNodes(client, args, signal) { - const action = str2(args, "action").trim().toLowerCase(); + const action = str3(args, "action").trim().toLowerCase(); if (!action) return { ok: false, error: "action_required" }; if (action === "match") { - const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + 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: str2(args, "match_field", "name").trim() || "name", - sample_limit: clampInt2(num2(args, "sample_limit"), 50, 1, 200) + 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: bool2(args, "dry_run") === true + dry_run: bool3(args, "dry_run") === true }; - const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + 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 = str2(args, "role").trim(); + const role = str3(args, "role").trim(); if (role) body.role = role; - const region = str2(args, "region_folder_id").trim(); + const region = str3(args, "region_folder_id").trim(); if (region) body.region_folder_id = region; - if (bool2(args, "clear_region") === true) + 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 = str2(args, "fabric_node_id").trim() || str2(args, "node_id").trim(); + 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 = str2(args, "role").trim(); + const role = str3(args, "role").trim(); if (role) body.role = role; - const region = str2(args, "region_folder_id").trim(); + const region = str3(args, "region_folder_id").trim(); if (region) body.region_folder_id = region; - if (bool2(args, "clear_region") === true) + 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: str2(args, "kind", "any").trim() || "any", - page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + 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") { @@ -1318,11 +1808,11 @@ async function classifyTopologyFabricNodes(client, args, signal) { } if (action === "apply_rules") { const params = {}; - if (bool2(args, "overwrite_manual") === true) + if (bool3(args, "overwrite_manual") === true) params.skip_manual = false; - else if (bool2(args, "skip_manual") !== undefined) - params.skip_manual = bool2(args, "skip_manual") !== false; - if (bool2(args, "fill_empty_only") === true) + 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)); } @@ -1332,13 +1822,13 @@ async function classifyTopologyFabricNodes(client, args, signal) { return { ok: false, error: "unknown_action", action }; } async function queryTopologyNeighborhood(client, args, signal) { - const nodeId = str2(args, "node_id").trim(); + 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(num2(args, "depth"), 1, 1, 3), - layer: str2(args, "layer", "physical").trim() || "physical" + depth: clampInt2(num3(args, "depth"), 1, 1, 3), + layer: str3(args, "layer", "physical").trim() || "physical" }, signal)); if (out.ok !== true) return out; @@ -1347,19 +1837,19 @@ async function queryTopologyNeighborhood(client, args, signal) { } async function queryTopologyEdges(client, args, signal) { const params = { - page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size"), 100, 1, 500), - layer: str2(args, "layer", "physical").trim() || "physical" + 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 = str2(args, key).trim(); + 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 = str2(args, "detail", "adjacency").trim().toLowerCase() || "adjacency"; + 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 }; @@ -1367,7 +1857,7 @@ async function queryTopologyEdges(client, args, signal) { return { ...out, detail: "adjacency", links, items: undefined }; } async function suggestSinkHubs(client, args, signal) { - const viewId = str2(args, "source_view_id").trim() || str2(args, "view_id").trim(); + 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)); @@ -1387,17 +1877,17 @@ async function suggestSinkHubs(client, args, signal) { ...strList2(args, "exclude_portal_ids"), ...strList2(args, "exclude_fabric_node_ids") ]); - const pick = clampInt2(num2(args, "pick"), 5, 1, 50); + 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 (str2(row2, "id") || str2(row2, "fabric_node_id")) === fabric_node_id; + return (str3(row2, "id") || str3(row2, "fabric_node_id")) === fabric_node_id; }); const row = asRecord2(node); return { fabric_node_id, degree: deg, - name: str2(row, "name"), + name: str3(row, "name"), level: row.level, role: row.role }; @@ -1410,7 +1900,7 @@ async function suggestSinkHubs(client, args, signal) { }; } async function analyzeTopologyViewLayout(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) { return { ok: false, @@ -1453,7 +1943,7 @@ async function analyzeTopologyViewLayout(client, args, signal) { return { ok: true, view_id: viewId, - detail: str2(args, "detail", "summary") || "summary", + detail: str3(args, "detail", "summary") || "summary", node_count: nodes.length, edge_count: edges.length, link_count: collapseEdgesToLinks(edges).length, @@ -1467,13 +1957,13 @@ async function sinkTopologyDualUnits(_client, args) { return { ok: false, error: "sink_dual_units_requires_netx_topology_mcp", - source_view_id: str2(args, "source_view_id"), - sink_view_id: str2(args, "sink_view_id"), + 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 (bool2(args, "catalog") === true) { + if (bool3(args, "catalog") === true) { return { ok: true, catalog: true, @@ -1501,7 +1991,7 @@ async function layoutTopologyView(client, args, signal) { hint: "Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP." }; } - const action = str2(args, "action", "layout").trim().toLowerCase() || "layout"; + const action = str3(args, "action", "layout").trim().toLowerCase() || "layout"; if (action === "job_status" || action === "job_cancel") { return { ok: false, @@ -1510,8 +2000,8 @@ async function layoutTopologyView(client, args, signal) { }; } if (action === "move_nodes" || action === "sink_nodes") { - const toId = str2(args, "view_id").trim(); - const fromId = str2(args, "source_view_id").trim(); + 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"); @@ -1521,8 +2011,8 @@ async function layoutTopologyView(client, args, signal) { if (fabricIds.length === 0) { return { ok: false, error: "fabric_node_ids_required" }; } - const copyPositions = bool2(params, "copy_positions") !== false && bool2(args, "copy_positions") !== false; - const removeFromSource = bool2(params, "remove_from_source") !== false; + 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; @@ -1530,7 +2020,7 @@ async function layoutTopologyView(client, args, signal) { const wanted = new Set(fabricIds); const selected = nodes.filter((n) => { const row = asRecord2(n); - const id = str2(row, "id") || str2(row, "fabric_node_id"); + 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)); @@ -1542,7 +2032,7 @@ async function layoutTopologyView(client, args, signal) { const positions = selected.map((n) => { const row = asRecord2(n); return { - fabric_node_id: str2(row, "id") || str2(row, "fabric_node_id"), + fabric_node_id: str3(row, "id") || str3(row, "fabric_node_id"), x: Number(row.x || 0) + ox, y: Number(row.y || 0) + oy }; @@ -1558,16 +2048,16 @@ async function layoutTopologyView(client, args, signal) { moved: fabricIds.length, source_view_id: fromId, view_id: toId, - park: bool2(params, "park") === true || bool2(args, "park") === true + park: bool3(params, "park") === true || bool3(args, "park") === true }; } return layoutRecipeUnavailable(action); } // src/netx/tools.ts -var str3 = (description) => ({ type: "string", ...description ? { description } : {} }); -var num3 = (description) => ({ type: "number", ...description ? { description } : {} }); -var bool3 = (description) => ({ type: "boolean", ...description ? { description } : {} }); +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" }, @@ -1578,19 +2068,19 @@ var anyObj = (description) => ({ additionalProperties: true, ...description ? { description } : {} }); -function renderJson(_args, value) { +function renderJson2(_args, value) { return [{ type: "text", text: JSON.stringify(value, null, 0) }]; } -var jsonOut = { +var jsonOut2 = { schema: { type: "json" }, - render: renderJson + render: renderJson2 }; -function tool(name, description, parameters, handler, getClient, timeoutMs) { - return defineTool({ +function tool2(name, description, parameters, handler, getClient, timeoutMs) { + return defineTool2({ name, description, parameters, - output: jsonOut, + output: jsonOut2, timeoutMs, isConcurrencySafe: () => true, async execute(args, exec) { @@ -1616,90 +2106,90 @@ function registerNetxTools(ctx, connection, options) { const getClient = () => client; const t = connection.toolCallTimeoutMs; const catalog = [ - tool("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: str3(), - ne_id: str3("Filter only; do not show UUID to users"), - host_name: str3("Filter by NE host_name"), - ne_name: str3("Legacy alias mapped to keyword"), - keyword: str3("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), - time_from: str3("ISO time; filters last_seen_at >="), - time_to: str3("ISO time; filters last_seen_at <="), - page: num3(), - page_size: num3() + 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), - tool("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: str3("Optional perceived_severity filter (critical/major/minor/warning)."), - top_ne: num3("Max NE buckets (default 50). Ignored when group_by is set."), - exclude_missing_host: bool3("Omit missing host_name from by_ne."), - time_from: str3(), - time_to: str3(), - group_by: str3("When set, routes to raw aggregation. Prefer alarm_host_name."), - group_by2: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - limit: num3() + 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), - tool("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), - tool("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { - keyword: str3(), - page: num3(), - page_size: num3() + 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), - tool("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { + 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), - tool("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: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - time_from: str3(), - time_to: str3(), - order_by: str3("last_seen_at | time_created | perceived_severity | event_type | ne_id"), - order: str3("asc | desc"), + 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: str3("brief | evidence | ne_debug"), - page: num3(), - page_size: num3() + field_preset: str4("brief | evidence | ne_debug"), + page: num4(), + page_size: num4() }, queryUmeAlarmsRaw, getClient, t), - tool("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { + 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: str3(), - severity: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - time_from: str3(), - time_to: str3(), - exclude_missing_host: bool3(), - limit: num3() + 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), - tool("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), - tool("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { + 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: num3(), - statement_timeout_ms: num3() + limit: num4(), + statement_timeout_ms: num4() }, sqlQueryUme, getClient, t), - tool("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { - keyword: str3(), - vendor: str3(), - connect_status: str3("unknown | testing | pass | fail"), - page: num3(), - page_size: num3() + 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), - tool("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { - ne_id: str3("Managed NE id"), - managed_ne_id: str3("Alias for ne_id"), - id: str3("Alias for ne_id") + 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), - tool("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: str3(), - nms_ne_id: str3("NMS inventory id; alias ume_ne_id"), - ume_ne_id: str3("Legacy alias of nms_ne_id"), + 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"), @@ -1710,79 +2200,79 @@ function registerNetxTools(ctx, connection, options) { type: "object", additionalProperties: false, properties: { - ne_id: str3(), - nms_ne_id: str3(), - ume_ne_id: str3(), + 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: num3("Per-command read timeout (default 60; use 90–120 for slow show)."), - concurrency: num3("Parallel NEs for batch mode (1–8, default 4)."), - async: bool3("oclaw-only async hint; ignored by native REST client.") + 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)), - tool("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { - source: str3("managed | nms | ume | all"), - keyword: str3(), - page: num3(), - page_size: num3() + 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), - tool("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: str3("NMS inventory id"), - from_ume_ne_id: str3("Legacy alias of from_nms_ne_id"), - from_managed_ne_id: str3(), - to_nms_ne_id: str3("NMS inventory id"), - to_ume_ne_id: str3("Legacy alias of to_nms_ne_id"), - to_managed_ne_id: str3(), - max_paths: num3(), - max_hops: num3(), - layer: str3(), - detail: str3("summary | full") + 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), - tool("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool3(), max_depth: num3() }, getTopologyTree, getClient, t), - tool("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { + 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: str3("summary | full"), - sample: num3() + detail: str4("summary | full"), + sample: num4() }, getTopologyView, getClient, t), - tool("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { + tool2("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { name: { type: "string", required: true }, - parent_id: str3(), - locale: str3("zh | en"), - sort_order: num3() + parent_id: str4(), + locale: str4("zh | en"), + sort_order: num4() }, createTopologyFolder, getClient, t), - tool("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { + 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: num3(), - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), - limit: num3(), - offset: num3(), + max_nodes: num4(), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), + limit: num4(), + offset: num4(), fabric_node_ids: strArr(), - layout: str3("grid | keep") + layout: str4("grid | keep") }, addTopologyViewNodes, getClient, t), - tool("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { + tool2("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { view_id: { type: "string", required: true }, - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), fabric_node_ids: strArr() }, removeTopologyViewNodes, getClient, t), - tool("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { + 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: bool3(), - clear_target: bool3(), - offset_x: num3(), - offset_y: num3(), - limit: num3(), - dry_run: bool3() + copy_positions: bool4(), + clear_target: bool4(), + offset_x: num4(), + offset_y: num4(), + limit: num4(), + dry_run: bool4() }, copyTopologyViewNodes, getClient, t), - tool("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { + 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", @@ -1791,121 +2281,121 @@ function registerNetxTools(ctx, connection, options) { type: "object", additionalProperties: false, properties: { - fabric_node_id: str3(), - x: num3(), - y: num3() + fabric_node_id: str4(), + x: num4(), + y: num4() } } }, - layout: str3("grid | offset | stack"), - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), + layout: str4("grid | offset | stack"), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), fabric_node_ids: strArr(), - offset_x: num3(), - offset_y: num3() + offset_x: num4(), + offset_y: num4() }, updateTopologyViewPositions, getClient, t), - tool("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { + 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: str3(), - detail: str3("summary | full"), - sample: num3() + region_folder_id: str4(), + detail: str4("summary | full"), + sample: num4() }, projectTopologyNeighbors, getClient, t), - tool("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { - mode: str3("summary | list | search"), - q: str3(), - keyword: str3(), - role: str3(), - level: str3(), - level_major: str3(), - region_folder_id: str3(), - link_status: str3(), - page: num3(), - page_size: num3(), - limit: num3(), - summary: bool3() + 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), - tool("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { + tool2("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { action: { type: "string", required: true }, - pattern: str3(), - q: str3(), - match_field: str3(), - sample_limit: num3(), + pattern: str4(), + q: str4(), + match_field: str4(), + sample_limit: num4(), fabric_node_ids: strArr(), - fabric_node_id: str3(), - node_id: str3(), - level: str3(), - role: str3(), - region_folder_id: str3(), - clear_region: bool3(), - dry_run: bool3(), - kind: str3(), - page: num3(), - page_size: num3(), - skip_manual: bool3(), - overwrite_manual: bool3(), - fill_empty_only: bool3() + 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), - tool("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { + tool2("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { node_id: { type: "string", required: true }, - depth: num3(), - layer: str3() + depth: num4(), + layer: str4() }, queryTopologyNeighborhood, getClient, t), - tool("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { - node_id: str3(), - keyword: str3(), - layer: str3(), - status: str3(), - source: str3("lldp | ume | manual"), - detail: str3("adjacency | ports"), - page: num3(), - page_size: num3() + 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), - tool("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { - source_view_id: str3(), - view_id: str3(), - pick: num3(), + 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), - tool("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { - view_id: str3(), - folder_id: str3(), - detail: str3("summary | structure | hotspots | blocks | both"), - score_profile: str3(), - sight_limit: num3(), - max_views: num3(), - min_nodes: num3(), - max_nodes: num3(), - with_meta: bool3() + 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), - tool("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { - source_view_id: str3(), - sink_view_id: str3(), - max_units: num3(), - min_nodes: num3(), - max_nodes: num3(), - max_batch_nodes: num3(), - layout_batch: bool3(), - until_empty: bool3(), - dry_run: bool3() + 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), - tool("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: str3(), - action: str3(), - source_view_id: str3(), - recipe: str3(), - preset: str3(), - mode: str3("preview | apply"), - tune: bool3(), + 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: bool3(), + catalog: bool4(), fabric_node_ids: strArr(), - park: bool3(), - copy_positions: bool3() + 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)); @@ -1918,6 +2408,7 @@ function registerNetxTools(ctx, connection, options) { // src/netx/group-tools-plugin.ts function applyGroupToolsPlugin(ctx, options) { let unregisterTools; + let unregisterKbLocal; let unregisterSkills; let skillGeneration = 0; const resolveGroups = () => { @@ -1947,9 +2438,24 @@ function applyGroupToolsPlugin(ctx, options) { }); 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; @@ -2022,8 +2528,11 @@ function applyGroupToolsPlugin(ctx, options) { }); ctx.effect(() => () => { stopToolWatch(); + stopKbToolWatch(); unregisterTools?.(); unregisterTools = undefined; + unregisterKbLocal?.(); + unregisterKbLocal = undefined; }, `${options.name}: dispose tools`); } diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index 11445c4..f709df5 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -215,11 +215,165 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) { }; } +// 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" +]); +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 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")); +} + // 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 ? [ + "", + "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", + "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", + "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", + "Do **not** use workspace Write/bash for KB paths (sandbox).", + "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", + "file boundary issues as `suggestions/` instead." + ] : [ + "", + "No local write tools until `paths.local` is present in MANIFEST." + ]; return { description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", content: [ @@ -232,12 +386,14 @@ function skillBody(snapshot) { `| --- | --- |`, `| kbStatus | configured |`, `| kbRoot | \`${snapshot.realRoot}\` |`, + localRow, `| kbOperator | ${snapshot.operatorName} |`, `| kbCountry | ${snapshot.country} |`, `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, + ...localGuide, "", - "Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "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,`, @@ -277,21 +433,403 @@ function registerKbContextSkill(ctx, snapshot) { }); } +// 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 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 } : {} }); +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.", { + type: "object", + additionalProperties: false, + required: ["bucket", "slug", "body"], + properties: { + bucket: { + type: "string", + enum: ["note", "rca_review", "ai_trace"], + description: "Memory subdirectory" + }, + slug: str("Short filename stem (no path separators)"), + body: str("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 identity/ or formal RCA.", { + type: "object", + additionalProperties: false, + required: ["slug", "body"], + properties: { + slug: str("Case stem (DRAFT- prefix added if missing)"), + body: str("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 theory corrections or tool/process improvements — never rewrite identity/.", { + type: "object", + additionalProperties: false, + required: ["kind", "slug", "body"], + properties: { + kind: { + type: "string", + enum: ["theory", "improvement"], + description: "theory = knowledge fix; improvement = tools/skills/process" + }, + slug: str("Short filename stem"), + body: str("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__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + type: "object", + additionalProperties: false, + required: ["path", "body"], + properties: { + path: str("Absolute path or path relative to KB local root"), + body: str("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 ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + type: "object", + additionalProperties: false, + required: ["path"], + properties: { + path: str("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 (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + type: "object", + additionalProperties: false, + properties: { + root: { + type: "string", + enum: ["memories", "drafts", "suggestions", "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 join2 } from "node:path"; +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 join2(snapshot.realRoot, rel); + return join4(snapshot.realRoot, rel); } function resolveKbLocalSkillsRoot(snapshot) { const rel = snapshot.paths.localSkills?.trim(); if (!rel) return null; - return join2(snapshot.realRoot, rel); + return join4(snapshot.realRoot, rel); } async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; @@ -307,7 +845,7 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) return []; const disposers = []; for (const entry of entries) { - const full = join2(root, entry); + const full = join4(root, entry); let isDir = false; try { isDir = (await stat2(full)).isDirectory(); @@ -370,54 +908,6 @@ async function registerKbPackSkills(ctx, snapshot, options) { }; } -// 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/runtime.ts var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); function store2() { @@ -441,7 +931,7 @@ function watchNetxConnection(listener) { } // src/netx/tools.ts -import { defineTool } from "@deepseek-ai/dsh-tools"; +import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; // src/netx/http.ts var PROTOCOL_KEY_ZH_TO_EN = { @@ -604,7 +1094,7 @@ var UME_RAW_FIELD_PRESETS = { function asRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; } -function str(args, key, fallback = "") { +function str2(args, key, fallback = "") { const v = args[key]; if (typeof v === "string") return v; @@ -613,17 +1103,17 @@ function str(args, key, fallback = "") { return fallback; } function nmsOrUme(args, nmsKey, umeKey) { - return str(args, nmsKey).trim() || str(args, umeKey).trim(); + 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 num(args, key) { +function num2(args, key) { const v = args[key]; return typeof v === "number" && Number.isFinite(v) ? v : undefined; } -function bool(args, key) { +function bool2(args, key) { const v = args[key]; return typeof v === "boolean" ? v : undefined; } @@ -639,18 +1129,18 @@ function clampInt(value, fallback, min, max) { } function putStr(params, args, keys) { for (const key of keys) { - const v = str(args, key).trim(); + const v = str2(args, key).trim(); if (v) params[key] = v; } } async function queryUmeAlarms(client, args, signal) { - let page = clampInt(num(args, "page"), 1, 1, 2); - const pageSize = clampInt(num(args, "page_size"), 50, 1, 500); + 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 = str(args, "keyword").trim(); - const neName = str(args, "ne_name").trim(); + const keyword = str2(args, "keyword").trim(); + const neName = str2(args, "ne_name").trim(); if (keyword) params.keyword = keyword; else if (neName) @@ -672,19 +1162,19 @@ async function aggregateUmeAlarmsRaw(client, args, signal) { "limit" ]); if ("exclude_missing_host" in args) { - const flag = bool(args, "exclude_missing_host"); + 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 (str(args, "group_by").trim()) + if (str2(args, "group_by").trim()) return aggregateUmeAlarmsRaw(client, args, signal); - const topNe = clampInt(num(args, "top_ne"), 50, 0, 500); + const topNe = clampInt(num2(args, "top_ne"), 50, 0, 500); const params = { top_ne: topNe }; if ("exclude_missing_host" in args) { - const flag = bool(args, "exclude_missing_host"); + const flag = bool2(args, "exclude_missing_host"); if (flag !== undefined) params.exclude_missing_host = flag; } @@ -696,22 +1186,22 @@ async function runUmeDiagnostics(client, _args, signal) { } async function queryUmeNeInventory(client, args, signal) { const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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 = str(args, "ne_id").trim(); + 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(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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", @@ -726,7 +1216,7 @@ async function queryUmeAlarmsRaw(client, args, signal) { ]); let fields = strList(args, "select_fields"); if (fields.length === 0) { - const preset = str(args, "field_preset").trim().toLowerCase(); + const preset = str2(args, "field_preset").trim().toLowerCase(); fields = UME_RAW_FIELD_PRESETS[preset] ?? []; } if (fields.length > 0) @@ -737,11 +1227,11 @@ async function listUmeAlarmFields(client, _args, signal) { return client.get("/v1/ume/alarms/fields", undefined, signal); } async function sqlQueryUme(client, args, signal) { - const sql = str(args, "sql").trim(); + const sql = str2(args, "sql").trim(); if (!sql) return { ok: false, error: "sql_required" }; - const limit = clampInt(num(args, "limit"), 200, 1, 2000); - const statementTimeoutMs = clampInt(num(args, "statement_timeout_ms"), 0, 0, 30000); + 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, @@ -749,9 +1239,9 @@ async function sqlQueryUme(client, args, signal) { }, signal, 60000); } async function listManagedNe(client, args, signal) { - const keyword = str(args, "keyword").trim(); - const vendor = str(args, "vendor").trim(); - const connectStatus = str(args, "connect_status").trim(); + 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" }; } @@ -759,8 +1249,8 @@ async function listManagedNe(client, args, signal) { return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; } const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 20, 1, 100) + 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; @@ -771,7 +1261,7 @@ async function listManagedNe(client, args, signal) { return client.get("/v1/managed-ne", params, signal); } async function getManagedNe(client, args, signal) { - const neId = (str(args, "ne_id") || str(args, "managed_ne_id") || str(args, "id")).trim(); + const neId = (str2(args, "ne_id") || str2(args, "managed_ne_id") || str2(args, "id")).trim(); if (!neId) { return { ok: false, @@ -783,7 +1273,7 @@ async function getManagedNe(client, args, signal) { } const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); if (out.ok === false) { - const detail = `${str(out, "detail")}${str(out, "error")}`.toLowerCase(); + 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, @@ -808,7 +1298,7 @@ async function execManagedNe(client, args, signal) { continue; const row = t; const item = {}; - const neId2 = str(row, "ne_id").trim(); + const neId2 = str2(row, "ne_id").trim(); const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); if (neId2) item.ne_id = neId2; @@ -832,8 +1322,8 @@ async function execManagedNe(client, args, signal) { } body2.commands = sharedCommands; } - body2.read_timeout_sec = clampInt(num(args, "read_timeout_sec"), 60, 10, 120); - const concurrency = num(args, "concurrency"); + 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); @@ -841,11 +1331,11 @@ async function execManagedNe(client, args, signal) { return out2; const data2 = asRecord(out2.data); if (data2.ok === false) { - return { ok: false, data: data2, error: str(data2, "error", "exec_batch_failed") }; + return { ok: false, data: data2, error: str2(data2, "error", "exec_batch_failed") }; } return { ok: true, data: data2 }; } - const neId = str(args, "ne_id").trim(); + const neId = str2(args, "ne_id").trim(); const umeNeId = nmsOrUme(args, "nms_ne_id", "ume_ne_id"); if (Boolean(neId) === Boolean(umeNeId)) { return { @@ -863,7 +1353,7 @@ async function execManagedNe(client, args, signal) { } const body = { commands: sharedCommands, - read_timeout_sec: clampInt(num(args, "read_timeout_sec"), 60, 10, 120) + read_timeout_sec: clampInt(num2(args, "read_timeout_sec"), 60, 10, 120) }; if (neId) body.ne_id = neId; @@ -874,36 +1364,36 @@ async function execManagedNe(client, args, signal) { return out; const data = asRecord(out.data); if (data.ok === false) { - return { ok: false, data, error: str(data, "error", "exec_failed") }; + return { ok: false, data, error: str2(data, "error", "exec_failed") }; } return { ok: true, data }; } async function listCliTargets(client, args, signal) { const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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 = str(args, "from_managed_ne_id").trim(); + const fromMid = str2(args, "from_managed_ne_id").trim(); const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); - const toMid = str(args, "to_managed_ne_id").trim(); + 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 = str(args, "detail", "summary").trim().toLowerCase() || "summary"; + let detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; if (detail !== "summary" && detail !== "full") detail = "summary"; const body = { - max_paths: clampInt(num(args, "max_paths"), 3, 1, 10), - max_hops: clampInt(num(args, "max_hops"), 6, 1, 12), - layer: str(args, "layer", "physical").trim() || "physical", + 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) @@ -921,7 +1411,7 @@ async function findTopologyPaths(client, args, signal) { function asRecord2(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; } -function str2(args, key, fallback = "") { +function str3(args, key, fallback = "") { const v = args[key]; if (typeof v === "string") return v; @@ -929,11 +1419,11 @@ function str2(args, key, fallback = "") { return String(v); return fallback; } -function num2(args, key) { +function num3(args, key) { const v = args[key]; return typeof v === "number" && Number.isFinite(v) ? v : undefined; } -function bool2(args, key) { +function bool3(args, key) { const v = args[key]; return typeof v === "boolean" ? v : undefined; } @@ -958,15 +1448,15 @@ function unwrap(out) { function filterFields(args) { const out = {}; for (const key of ["keyword", "role", "vendor", "link_status"]) { - const v = str2(args, key).trim(); + const v = str3(args, key).trim(); if (v) out[key] = v; } return out; } function edgeEndpoints(edge) { - const a = str2(edge, "a_node_id") || str2(edge, "a"); - const b = str2(edge, "b_node_id") || str2(edge, "b"); + 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) { @@ -989,11 +1479,11 @@ function collapseEdgesToLinks(edges, includeNames = false) { row.link_count = Number(row.link_count || 0) + 1; if (includeNames && !row.a_name) { if (a === lo) { - row.a_name = str2(e, "a_name"); - row.b_name = str2(e, "b_name"); + row.a_name = str3(e, "a_name"); + row.b_name = str3(e, "b_name"); } else { - row.a_name = str2(e, "b_name"); - row.b_name = str2(e, "a_name"); + row.a_name = str3(e, "b_name"); + row.b_name = str3(e, "a_name"); } } buckets.set(key, row); @@ -1006,8 +1496,8 @@ function summarizeViewGraph(graph, sample = 20) { const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => { const row = asRecord2(n); return { - id: str2(row, "id") || str2(row, "fabric_node_id"), - name: str2(row, "name"), + id: str3(row, "id") || str3(row, "fabric_node_id"), + name: str3(row, "name"), x: row.x, y: row.y, level: row.level, @@ -1016,7 +1506,7 @@ function summarizeViewGraph(graph, sample = 20) { }); return { ok: true, - view_id: str2(graph, "id") || str2(graph, "view_id"), + view_id: str3(graph, "id") || str3(graph, "view_id"), node_count: nodes.length, edge_count: edges.length, link_count: collapseEdgesToLinks(edges).length, @@ -1037,41 +1527,41 @@ 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 = bool2(args, "compact") !== false; - const maxDepth = num2(args, "max_depth"); + 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 = str2(args, "view_id").trim(); + 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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; if (detail === "full") return { ...out, detail: "full" }; - return summarizeViewGraph(out, clampInt2(num2(args, "sample"), 20, 0, 200)); + return summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 200)); } async function createTopologyFolder(client, args, signal) { - const name = str2(args, "name").trim(); + const name = str3(args, "name").trim(); if (!name) return { ok: false, error: "name_required" }; const body = { name }; - const parentId = str2(args, "parent_id").trim(); + const parentId = str3(args, "parent_id").trim(); if (parentId) body.parent_id = parentId; - const locale = str2(args, "locale").trim(); + const locale = str3(args, "locale").trim(); if (locale) body.locale = locale; - if (num2(args, "sort_order") !== undefined) - body.sort_order = Math.trunc(num2(args, "sort_order")); + 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 = str2(args, "view_id").trim(); + 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) { @@ -1090,18 +1580,18 @@ async function addTopologyViewNodes(client, args, signal) { detail: "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids." }; } - if (num2(args, "max_nodes") !== undefined) { + 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(num2(args, "max_nodes"), 2000, 1, 2000); + 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 = str2(args, "layout", "grid").trim() || "grid"; + const layout = str3(args, "layout", "grid").trim() || "grid"; const body = { managed_ne_ids: [], layout, @@ -1109,14 +1599,14 @@ async function addTopologyViewNodes(client, args, signal) { }; if (fabricIds.length) body.fabric_node_ids = fabricIds; - if (num2(args, "limit") !== undefined) - body.limit = clampInt2(num2(args, "limit"), 500, 1, 2000); - if (num2(args, "offset") !== undefined) - body.offset = clampInt2(num2(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); + 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 = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; const body = { ...filterFields(args) }; @@ -1126,8 +1616,8 @@ async function removeTopologyViewNodes(client, args, signal) { return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, body, signal)); } async function copyTopologyViewNodes(client, args, signal) { - const sourceId = str2(args, "source_view_id").trim(); - const targetId = str2(args, "target_view_id").trim(); + 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" }; } @@ -1135,16 +1625,16 @@ async function copyTopologyViewNodes(client, args, signal) { if (source.ok !== true) return source; const nodes = Array.isArray(source.nodes) ? source.nodes : []; - const limit = num2(args, "limit"); + 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 str2(row, "id") || str2(row, "fabric_node_id"); + return str3(row, "id") || str3(row, "fabric_node_id"); }).filter(Boolean); - if (bool2(args, "dry_run") === true) { + 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 (bool2(args, "clear_target") === true) { + if (bool3(args, "clear_target") === true) { await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, { fabric_node_ids: "ALL", clear_all: true @@ -1156,12 +1646,12 @@ async function copyTopologyViewNodes(client, args, signal) { 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 (bool2(args, "copy_positions") !== false) { - const ox = num2(args, "offset_x") ?? 0; - const oy = num2(args, "offset_y") ?? 0; + 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 = str2(row, "id") || str2(row, "fabric_node_id"); + const id = str3(row, "id") || str3(row, "fabric_node_id"); return { fabric_node_id: id, x: Number(row.x || 0) + ox, @@ -1175,19 +1665,19 @@ async function copyTopologyViewNodes(client, args, signal) { return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; } async function updateTopologyViewPositions(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + 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 = str2(args, "layout").trim(); + 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 (num2(args, key) !== undefined) - body[key] = num2(args, key); + if (num3(args, key) !== undefined) + body[key] = num3(args, key); } const fabricIds = strList2(args, "fabric_node_ids"); if (fabricIds.length) @@ -1195,7 +1685,7 @@ async function updateTopologyViewPositions(client, args, signal) { return unwrap(await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, body, signal, 120000)); } async function projectTopologyNeighbors(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; const body = {}; @@ -1205,24 +1695,24 @@ async function projectTopologyNeighbors(client, args, signal) { const mids = strList2(args, "managed_ne_ids"); if (mids.length) body.managed_ne_ids = mids; - const region = str2(args, "region_folder_id").trim(); + 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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + 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(num2(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; + return { ...summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; } async function queryTopologyFabricNodes(client, args, signal) { - let mode = str2(args, "mode").trim().toLowerCase(); - const q = str2(args, "q").trim(); - const keyword = str2(args, "keyword").trim(); + let mode = str3(args, "mode").trim().toLowerCase(); + const q = str3(args, "q").trim(); + const keyword = str3(args, "keyword").trim(); if (!mode) { - if (bool2(args, "summary") === true) + if (bool3(args, "summary") === true) mode = "summary"; else if (q) mode = "search"; @@ -1239,16 +1729,16 @@ async function queryTopologyFabricNodes(client, args, signal) { 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(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 200) + 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(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + 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 = str2(args, key).trim() || (key === "keyword" ? q : ""); + const v = str3(args, key).trim() || (key === "keyword" ? q : ""); if (v) params[key] = v; } @@ -1256,61 +1746,61 @@ async function queryTopologyFabricNodes(client, args, signal) { return out.ok === true ? { ...out, mode: "list" } : out; } async function classifyTopologyFabricNodes(client, args, signal) { - const action = str2(args, "action").trim().toLowerCase(); + const action = str3(args, "action").trim().toLowerCase(); if (!action) return { ok: false, error: "action_required" }; if (action === "match") { - const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + 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: str2(args, "match_field", "name").trim() || "name", - sample_limit: clampInt2(num2(args, "sample_limit"), 50, 1, 200) + 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: bool2(args, "dry_run") === true + dry_run: bool3(args, "dry_run") === true }; - const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + 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 = str2(args, "role").trim(); + const role = str3(args, "role").trim(); if (role) body.role = role; - const region = str2(args, "region_folder_id").trim(); + const region = str3(args, "region_folder_id").trim(); if (region) body.region_folder_id = region; - if (bool2(args, "clear_region") === true) + 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 = str2(args, "fabric_node_id").trim() || str2(args, "node_id").trim(); + 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 = str2(args, "role").trim(); + const role = str3(args, "role").trim(); if (role) body.role = role; - const region = str2(args, "region_folder_id").trim(); + const region = str3(args, "region_folder_id").trim(); if (region) body.region_folder_id = region; - if (bool2(args, "clear_region") === true) + 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: str2(args, "kind", "any").trim() || "any", - page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + 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") { @@ -1318,11 +1808,11 @@ async function classifyTopologyFabricNodes(client, args, signal) { } if (action === "apply_rules") { const params = {}; - if (bool2(args, "overwrite_manual") === true) + if (bool3(args, "overwrite_manual") === true) params.skip_manual = false; - else if (bool2(args, "skip_manual") !== undefined) - params.skip_manual = bool2(args, "skip_manual") !== false; - if (bool2(args, "fill_empty_only") === true) + 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)); } @@ -1332,13 +1822,13 @@ async function classifyTopologyFabricNodes(client, args, signal) { return { ok: false, error: "unknown_action", action }; } async function queryTopologyNeighborhood(client, args, signal) { - const nodeId = str2(args, "node_id").trim(); + 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(num2(args, "depth"), 1, 1, 3), - layer: str2(args, "layer", "physical").trim() || "physical" + depth: clampInt2(num3(args, "depth"), 1, 1, 3), + layer: str3(args, "layer", "physical").trim() || "physical" }, signal)); if (out.ok !== true) return out; @@ -1347,19 +1837,19 @@ async function queryTopologyNeighborhood(client, args, signal) { } async function queryTopologyEdges(client, args, signal) { const params = { - page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size"), 100, 1, 500), - layer: str2(args, "layer", "physical").trim() || "physical" + 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 = str2(args, key).trim(); + 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 = str2(args, "detail", "adjacency").trim().toLowerCase() || "adjacency"; + 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 }; @@ -1367,7 +1857,7 @@ async function queryTopologyEdges(client, args, signal) { return { ...out, detail: "adjacency", links, items: undefined }; } async function suggestSinkHubs(client, args, signal) { - const viewId = str2(args, "source_view_id").trim() || str2(args, "view_id").trim(); + 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)); @@ -1387,17 +1877,17 @@ async function suggestSinkHubs(client, args, signal) { ...strList2(args, "exclude_portal_ids"), ...strList2(args, "exclude_fabric_node_ids") ]); - const pick = clampInt2(num2(args, "pick"), 5, 1, 50); + 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 (str2(row2, "id") || str2(row2, "fabric_node_id")) === fabric_node_id; + return (str3(row2, "id") || str3(row2, "fabric_node_id")) === fabric_node_id; }); const row = asRecord2(node); return { fabric_node_id, degree: deg, - name: str2(row, "name"), + name: str3(row, "name"), level: row.level, role: row.role }; @@ -1410,7 +1900,7 @@ async function suggestSinkHubs(client, args, signal) { }; } async function analyzeTopologyViewLayout(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) { return { ok: false, @@ -1453,7 +1943,7 @@ async function analyzeTopologyViewLayout(client, args, signal) { return { ok: true, view_id: viewId, - detail: str2(args, "detail", "summary") || "summary", + detail: str3(args, "detail", "summary") || "summary", node_count: nodes.length, edge_count: edges.length, link_count: collapseEdgesToLinks(edges).length, @@ -1467,13 +1957,13 @@ async function sinkTopologyDualUnits(_client, args) { return { ok: false, error: "sink_dual_units_requires_netx_topology_mcp", - source_view_id: str2(args, "source_view_id"), - sink_view_id: str2(args, "sink_view_id"), + 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 (bool2(args, "catalog") === true) { + if (bool3(args, "catalog") === true) { return { ok: true, catalog: true, @@ -1501,7 +1991,7 @@ async function layoutTopologyView(client, args, signal) { hint: "Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP." }; } - const action = str2(args, "action", "layout").trim().toLowerCase() || "layout"; + const action = str3(args, "action", "layout").trim().toLowerCase() || "layout"; if (action === "job_status" || action === "job_cancel") { return { ok: false, @@ -1510,8 +2000,8 @@ async function layoutTopologyView(client, args, signal) { }; } if (action === "move_nodes" || action === "sink_nodes") { - const toId = str2(args, "view_id").trim(); - const fromId = str2(args, "source_view_id").trim(); + 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"); @@ -1521,8 +2011,8 @@ async function layoutTopologyView(client, args, signal) { if (fabricIds.length === 0) { return { ok: false, error: "fabric_node_ids_required" }; } - const copyPositions = bool2(params, "copy_positions") !== false && bool2(args, "copy_positions") !== false; - const removeFromSource = bool2(params, "remove_from_source") !== false; + 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; @@ -1530,7 +2020,7 @@ async function layoutTopologyView(client, args, signal) { const wanted = new Set(fabricIds); const selected = nodes.filter((n) => { const row = asRecord2(n); - const id = str2(row, "id") || str2(row, "fabric_node_id"); + 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)); @@ -1542,7 +2032,7 @@ async function layoutTopologyView(client, args, signal) { const positions = selected.map((n) => { const row = asRecord2(n); return { - fabric_node_id: str2(row, "id") || str2(row, "fabric_node_id"), + fabric_node_id: str3(row, "id") || str3(row, "fabric_node_id"), x: Number(row.x || 0) + ox, y: Number(row.y || 0) + oy }; @@ -1558,16 +2048,16 @@ async function layoutTopologyView(client, args, signal) { moved: fabricIds.length, source_view_id: fromId, view_id: toId, - park: bool2(params, "park") === true || bool2(args, "park") === true + park: bool3(params, "park") === true || bool3(args, "park") === true }; } return layoutRecipeUnavailable(action); } // src/netx/tools.ts -var str3 = (description) => ({ type: "string", ...description ? { description } : {} }); -var num3 = (description) => ({ type: "number", ...description ? { description } : {} }); -var bool3 = (description) => ({ type: "boolean", ...description ? { description } : {} }); +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" }, @@ -1578,19 +2068,19 @@ var anyObj = (description) => ({ additionalProperties: true, ...description ? { description } : {} }); -function renderJson(_args, value) { +function renderJson2(_args, value) { return [{ type: "text", text: JSON.stringify(value, null, 0) }]; } -var jsonOut = { +var jsonOut2 = { schema: { type: "json" }, - render: renderJson + render: renderJson2 }; -function tool(name, description, parameters, handler, getClient, timeoutMs) { - return defineTool({ +function tool2(name, description, parameters, handler, getClient, timeoutMs) { + return defineTool2({ name, description, parameters, - output: jsonOut, + output: jsonOut2, timeoutMs, isConcurrencySafe: () => true, async execute(args, exec) { @@ -1616,90 +2106,90 @@ function registerNetxTools(ctx, connection, options) { const getClient = () => client; const t = connection.toolCallTimeoutMs; const catalog = [ - tool("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: str3(), - ne_id: str3("Filter only; do not show UUID to users"), - host_name: str3("Filter by NE host_name"), - ne_name: str3("Legacy alias mapped to keyword"), - keyword: str3("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), - time_from: str3("ISO time; filters last_seen_at >="), - time_to: str3("ISO time; filters last_seen_at <="), - page: num3(), - page_size: num3() + 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), - tool("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: str3("Optional perceived_severity filter (critical/major/minor/warning)."), - top_ne: num3("Max NE buckets (default 50). Ignored when group_by is set."), - exclude_missing_host: bool3("Omit missing host_name from by_ne."), - time_from: str3(), - time_to: str3(), - group_by: str3("When set, routes to raw aggregation. Prefer alarm_host_name."), - group_by2: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - limit: num3() + 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), - tool("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), - tool("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { - keyword: str3(), - page: num3(), - page_size: num3() + 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), - tool("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { + 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), - tool("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: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - time_from: str3(), - time_to: str3(), - order_by: str3("last_seen_at | time_created | perceived_severity | event_type | ne_id"), - order: str3("asc | desc"), + 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: str3("brief | evidence | ne_debug"), - page: num3(), - page_size: num3() + field_preset: str4("brief | evidence | ne_debug"), + page: num4(), + page_size: num4() }, queryUmeAlarmsRaw, getClient, t), - tool("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { + 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: str3(), - severity: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - time_from: str3(), - time_to: str3(), - exclude_missing_host: bool3(), - limit: num3() + 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), - tool("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), - tool("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { + 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: num3(), - statement_timeout_ms: num3() + limit: num4(), + statement_timeout_ms: num4() }, sqlQueryUme, getClient, t), - tool("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { - keyword: str3(), - vendor: str3(), - connect_status: str3("unknown | testing | pass | fail"), - page: num3(), - page_size: num3() + 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), - tool("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { - ne_id: str3("Managed NE id"), - managed_ne_id: str3("Alias for ne_id"), - id: str3("Alias for ne_id") + 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), - tool("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: str3(), - nms_ne_id: str3("NMS inventory id; alias ume_ne_id"), - ume_ne_id: str3("Legacy alias of nms_ne_id"), + 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"), @@ -1710,79 +2200,79 @@ function registerNetxTools(ctx, connection, options) { type: "object", additionalProperties: false, properties: { - ne_id: str3(), - nms_ne_id: str3(), - ume_ne_id: str3(), + 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: num3("Per-command read timeout (default 60; use 90–120 for slow show)."), - concurrency: num3("Parallel NEs for batch mode (1–8, default 4)."), - async: bool3("oclaw-only async hint; ignored by native REST client.") + 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)), - tool("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { - source: str3("managed | nms | ume | all"), - keyword: str3(), - page: num3(), - page_size: num3() + 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), - tool("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: str3("NMS inventory id"), - from_ume_ne_id: str3("Legacy alias of from_nms_ne_id"), - from_managed_ne_id: str3(), - to_nms_ne_id: str3("NMS inventory id"), - to_ume_ne_id: str3("Legacy alias of to_nms_ne_id"), - to_managed_ne_id: str3(), - max_paths: num3(), - max_hops: num3(), - layer: str3(), - detail: str3("summary | full") + 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), - tool("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool3(), max_depth: num3() }, getTopologyTree, getClient, t), - tool("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { + 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: str3("summary | full"), - sample: num3() + detail: str4("summary | full"), + sample: num4() }, getTopologyView, getClient, t), - tool("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { + tool2("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { name: { type: "string", required: true }, - parent_id: str3(), - locale: str3("zh | en"), - sort_order: num3() + parent_id: str4(), + locale: str4("zh | en"), + sort_order: num4() }, createTopologyFolder, getClient, t), - tool("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { + 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: num3(), - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), - limit: num3(), - offset: num3(), + max_nodes: num4(), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), + limit: num4(), + offset: num4(), fabric_node_ids: strArr(), - layout: str3("grid | keep") + layout: str4("grid | keep") }, addTopologyViewNodes, getClient, t), - tool("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { + tool2("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { view_id: { type: "string", required: true }, - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), fabric_node_ids: strArr() }, removeTopologyViewNodes, getClient, t), - tool("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { + 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: bool3(), - clear_target: bool3(), - offset_x: num3(), - offset_y: num3(), - limit: num3(), - dry_run: bool3() + copy_positions: bool4(), + clear_target: bool4(), + offset_x: num4(), + offset_y: num4(), + limit: num4(), + dry_run: bool4() }, copyTopologyViewNodes, getClient, t), - tool("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { + 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", @@ -1791,121 +2281,121 @@ function registerNetxTools(ctx, connection, options) { type: "object", additionalProperties: false, properties: { - fabric_node_id: str3(), - x: num3(), - y: num3() + fabric_node_id: str4(), + x: num4(), + y: num4() } } }, - layout: str3("grid | offset | stack"), - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), + layout: str4("grid | offset | stack"), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), fabric_node_ids: strArr(), - offset_x: num3(), - offset_y: num3() + offset_x: num4(), + offset_y: num4() }, updateTopologyViewPositions, getClient, t), - tool("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { + 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: str3(), - detail: str3("summary | full"), - sample: num3() + region_folder_id: str4(), + detail: str4("summary | full"), + sample: num4() }, projectTopologyNeighbors, getClient, t), - tool("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { - mode: str3("summary | list | search"), - q: str3(), - keyword: str3(), - role: str3(), - level: str3(), - level_major: str3(), - region_folder_id: str3(), - link_status: str3(), - page: num3(), - page_size: num3(), - limit: num3(), - summary: bool3() + 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), - tool("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { + tool2("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { action: { type: "string", required: true }, - pattern: str3(), - q: str3(), - match_field: str3(), - sample_limit: num3(), + pattern: str4(), + q: str4(), + match_field: str4(), + sample_limit: num4(), fabric_node_ids: strArr(), - fabric_node_id: str3(), - node_id: str3(), - level: str3(), - role: str3(), - region_folder_id: str3(), - clear_region: bool3(), - dry_run: bool3(), - kind: str3(), - page: num3(), - page_size: num3(), - skip_manual: bool3(), - overwrite_manual: bool3(), - fill_empty_only: bool3() + 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), - tool("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { + tool2("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { node_id: { type: "string", required: true }, - depth: num3(), - layer: str3() + depth: num4(), + layer: str4() }, queryTopologyNeighborhood, getClient, t), - tool("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { - node_id: str3(), - keyword: str3(), - layer: str3(), - status: str3(), - source: str3("lldp | ume | manual"), - detail: str3("adjacency | ports"), - page: num3(), - page_size: num3() + 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), - tool("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { - source_view_id: str3(), - view_id: str3(), - pick: num3(), + 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), - tool("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { - view_id: str3(), - folder_id: str3(), - detail: str3("summary | structure | hotspots | blocks | both"), - score_profile: str3(), - sight_limit: num3(), - max_views: num3(), - min_nodes: num3(), - max_nodes: num3(), - with_meta: bool3() + 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), - tool("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { - source_view_id: str3(), - sink_view_id: str3(), - max_units: num3(), - min_nodes: num3(), - max_nodes: num3(), - max_batch_nodes: num3(), - layout_batch: bool3(), - until_empty: bool3(), - dry_run: bool3() + 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), - tool("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: str3(), - action: str3(), - source_view_id: str3(), - recipe: str3(), - preset: str3(), - mode: str3("preview | apply"), - tune: bool3(), + 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: bool3(), + catalog: bool4(), fabric_node_ids: strArr(), - park: bool3(), - copy_positions: bool3() + 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)); @@ -1918,6 +2408,7 @@ function registerNetxTools(ctx, connection, options) { // src/netx/group-tools-plugin.ts function applyGroupToolsPlugin(ctx, options) { let unregisterTools; + let unregisterKbLocal; let unregisterSkills; let skillGeneration = 0; const resolveGroups = () => { @@ -1947,9 +2438,24 @@ function applyGroupToolsPlugin(ctx, options) { }); 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; @@ -2022,8 +2528,11 @@ function applyGroupToolsPlugin(ctx, options) { }); ctx.effect(() => () => { stopToolWatch(); + stopKbToolWatch(); unregisterTools?.(); unregisterTools = undefined; + unregisterKbLocal?.(); + unregisterKbLocal = undefined; }, `${options.name}: dispose tools`); } diff --git a/lib/agent-tools.js b/lib/agent-tools.js index 8549fb9..ff64468 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -215,11 +215,165 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) { }; } +// 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" +]); +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 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")); +} + // 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 ? [ + "", + "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", + "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", + "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", + "Do **not** use workspace Write/bash for KB paths (sandbox).", + "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", + "file boundary issues as `suggestions/` instead." + ] : [ + "", + "No local write tools until `paths.local` is present in MANIFEST." + ]; return { description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", content: [ @@ -232,12 +386,14 @@ function skillBody(snapshot) { `| --- | --- |`, `| kbStatus | configured |`, `| kbRoot | \`${snapshot.realRoot}\` |`, + localRow, `| kbOperator | ${snapshot.operatorName} |`, `| kbCountry | ${snapshot.country} |`, `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, + ...localGuide, "", - "Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "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,`, @@ -277,21 +433,403 @@ function registerKbContextSkill(ctx, snapshot) { }); } +// 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 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 } : {} }); +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.", { + type: "object", + additionalProperties: false, + required: ["bucket", "slug", "body"], + properties: { + bucket: { + type: "string", + enum: ["note", "rca_review", "ai_trace"], + description: "Memory subdirectory" + }, + slug: str("Short filename stem (no path separators)"), + body: str("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 identity/ or formal RCA.", { + type: "object", + additionalProperties: false, + required: ["slug", "body"], + properties: { + slug: str("Case stem (DRAFT- prefix added if missing)"), + body: str("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 theory corrections or tool/process improvements — never rewrite identity/.", { + type: "object", + additionalProperties: false, + required: ["kind", "slug", "body"], + properties: { + kind: { + type: "string", + enum: ["theory", "improvement"], + description: "theory = knowledge fix; improvement = tools/skills/process" + }, + slug: str("Short filename stem"), + body: str("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__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + type: "object", + additionalProperties: false, + required: ["path", "body"], + properties: { + path: str("Absolute path or path relative to KB local root"), + body: str("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 ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + type: "object", + additionalProperties: false, + required: ["path"], + properties: { + path: str("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 (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + type: "object", + additionalProperties: false, + properties: { + root: { + type: "string", + enum: ["memories", "drafts", "suggestions", "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 join2 } from "node:path"; +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 join2(snapshot.realRoot, rel); + return join4(snapshot.realRoot, rel); } function resolveKbLocalSkillsRoot(snapshot) { const rel = snapshot.paths.localSkills?.trim(); if (!rel) return null; - return join2(snapshot.realRoot, rel); + return join4(snapshot.realRoot, rel); } async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; @@ -307,7 +845,7 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) return []; const disposers = []; for (const entry of entries) { - const full = join2(root, entry); + const full = join4(root, entry); let isDir = false; try { isDir = (await stat2(full)).isDirectory(); @@ -370,54 +908,6 @@ async function registerKbPackSkills(ctx, snapshot, options) { }; } -// 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/runtime.ts var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); function store2() { @@ -441,7 +931,7 @@ function watchNetxConnection(listener) { } // src/netx/tools.ts -import { defineTool } from "@deepseek-ai/dsh-tools"; +import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; // src/netx/http.ts var PROTOCOL_KEY_ZH_TO_EN = { @@ -604,7 +1094,7 @@ var UME_RAW_FIELD_PRESETS = { function asRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; } -function str(args, key, fallback = "") { +function str2(args, key, fallback = "") { const v = args[key]; if (typeof v === "string") return v; @@ -613,17 +1103,17 @@ function str(args, key, fallback = "") { return fallback; } function nmsOrUme(args, nmsKey, umeKey) { - return str(args, nmsKey).trim() || str(args, umeKey).trim(); + 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 num(args, key) { +function num2(args, key) { const v = args[key]; return typeof v === "number" && Number.isFinite(v) ? v : undefined; } -function bool(args, key) { +function bool2(args, key) { const v = args[key]; return typeof v === "boolean" ? v : undefined; } @@ -639,18 +1129,18 @@ function clampInt(value, fallback, min, max) { } function putStr(params, args, keys) { for (const key of keys) { - const v = str(args, key).trim(); + const v = str2(args, key).trim(); if (v) params[key] = v; } } async function queryUmeAlarms(client, args, signal) { - let page = clampInt(num(args, "page"), 1, 1, 2); - const pageSize = clampInt(num(args, "page_size"), 50, 1, 500); + 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 = str(args, "keyword").trim(); - const neName = str(args, "ne_name").trim(); + const keyword = str2(args, "keyword").trim(); + const neName = str2(args, "ne_name").trim(); if (keyword) params.keyword = keyword; else if (neName) @@ -672,19 +1162,19 @@ async function aggregateUmeAlarmsRaw(client, args, signal) { "limit" ]); if ("exclude_missing_host" in args) { - const flag = bool(args, "exclude_missing_host"); + 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 (str(args, "group_by").trim()) + if (str2(args, "group_by").trim()) return aggregateUmeAlarmsRaw(client, args, signal); - const topNe = clampInt(num(args, "top_ne"), 50, 0, 500); + const topNe = clampInt(num2(args, "top_ne"), 50, 0, 500); const params = { top_ne: topNe }; if ("exclude_missing_host" in args) { - const flag = bool(args, "exclude_missing_host"); + const flag = bool2(args, "exclude_missing_host"); if (flag !== undefined) params.exclude_missing_host = flag; } @@ -696,22 +1186,22 @@ async function runUmeDiagnostics(client, _args, signal) { } async function queryUmeNeInventory(client, args, signal) { const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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 = str(args, "ne_id").trim(); + 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(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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", @@ -726,7 +1216,7 @@ async function queryUmeAlarmsRaw(client, args, signal) { ]); let fields = strList(args, "select_fields"); if (fields.length === 0) { - const preset = str(args, "field_preset").trim().toLowerCase(); + const preset = str2(args, "field_preset").trim().toLowerCase(); fields = UME_RAW_FIELD_PRESETS[preset] ?? []; } if (fields.length > 0) @@ -737,11 +1227,11 @@ async function listUmeAlarmFields(client, _args, signal) { return client.get("/v1/ume/alarms/fields", undefined, signal); } async function sqlQueryUme(client, args, signal) { - const sql = str(args, "sql").trim(); + const sql = str2(args, "sql").trim(); if (!sql) return { ok: false, error: "sql_required" }; - const limit = clampInt(num(args, "limit"), 200, 1, 2000); - const statementTimeoutMs = clampInt(num(args, "statement_timeout_ms"), 0, 0, 30000); + 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, @@ -749,9 +1239,9 @@ async function sqlQueryUme(client, args, signal) { }, signal, 60000); } async function listManagedNe(client, args, signal) { - const keyword = str(args, "keyword").trim(); - const vendor = str(args, "vendor").trim(); - const connectStatus = str(args, "connect_status").trim(); + 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" }; } @@ -759,8 +1249,8 @@ async function listManagedNe(client, args, signal) { return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; } const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 20, 1, 100) + 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; @@ -771,7 +1261,7 @@ async function listManagedNe(client, args, signal) { return client.get("/v1/managed-ne", params, signal); } async function getManagedNe(client, args, signal) { - const neId = (str(args, "ne_id") || str(args, "managed_ne_id") || str(args, "id")).trim(); + const neId = (str2(args, "ne_id") || str2(args, "managed_ne_id") || str2(args, "id")).trim(); if (!neId) { return { ok: false, @@ -783,7 +1273,7 @@ async function getManagedNe(client, args, signal) { } const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); if (out.ok === false) { - const detail = `${str(out, "detail")}${str(out, "error")}`.toLowerCase(); + 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, @@ -808,7 +1298,7 @@ async function execManagedNe(client, args, signal) { continue; const row = t; const item = {}; - const neId2 = str(row, "ne_id").trim(); + const neId2 = str2(row, "ne_id").trim(); const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); if (neId2) item.ne_id = neId2; @@ -832,8 +1322,8 @@ async function execManagedNe(client, args, signal) { } body2.commands = sharedCommands; } - body2.read_timeout_sec = clampInt(num(args, "read_timeout_sec"), 60, 10, 120); - const concurrency = num(args, "concurrency"); + 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); @@ -841,11 +1331,11 @@ async function execManagedNe(client, args, signal) { return out2; const data2 = asRecord(out2.data); if (data2.ok === false) { - return { ok: false, data: data2, error: str(data2, "error", "exec_batch_failed") }; + return { ok: false, data: data2, error: str2(data2, "error", "exec_batch_failed") }; } return { ok: true, data: data2 }; } - const neId = str(args, "ne_id").trim(); + const neId = str2(args, "ne_id").trim(); const umeNeId = nmsOrUme(args, "nms_ne_id", "ume_ne_id"); if (Boolean(neId) === Boolean(umeNeId)) { return { @@ -863,7 +1353,7 @@ async function execManagedNe(client, args, signal) { } const body = { commands: sharedCommands, - read_timeout_sec: clampInt(num(args, "read_timeout_sec"), 60, 10, 120) + read_timeout_sec: clampInt(num2(args, "read_timeout_sec"), 60, 10, 120) }; if (neId) body.ne_id = neId; @@ -874,36 +1364,36 @@ async function execManagedNe(client, args, signal) { return out; const data = asRecord(out.data); if (data.ok === false) { - return { ok: false, data, error: str(data, "error", "exec_failed") }; + return { ok: false, data, error: str2(data, "error", "exec_failed") }; } return { ok: true, data }; } async function listCliTargets(client, args, signal) { const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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 = str(args, "from_managed_ne_id").trim(); + const fromMid = str2(args, "from_managed_ne_id").trim(); const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); - const toMid = str(args, "to_managed_ne_id").trim(); + 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 = str(args, "detail", "summary").trim().toLowerCase() || "summary"; + let detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; if (detail !== "summary" && detail !== "full") detail = "summary"; const body = { - max_paths: clampInt(num(args, "max_paths"), 3, 1, 10), - max_hops: clampInt(num(args, "max_hops"), 6, 1, 12), - layer: str(args, "layer", "physical").trim() || "physical", + 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) @@ -921,7 +1411,7 @@ async function findTopologyPaths(client, args, signal) { function asRecord2(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; } -function str2(args, key, fallback = "") { +function str3(args, key, fallback = "") { const v = args[key]; if (typeof v === "string") return v; @@ -929,11 +1419,11 @@ function str2(args, key, fallback = "") { return String(v); return fallback; } -function num2(args, key) { +function num3(args, key) { const v = args[key]; return typeof v === "number" && Number.isFinite(v) ? v : undefined; } -function bool2(args, key) { +function bool3(args, key) { const v = args[key]; return typeof v === "boolean" ? v : undefined; } @@ -958,15 +1448,15 @@ function unwrap(out) { function filterFields(args) { const out = {}; for (const key of ["keyword", "role", "vendor", "link_status"]) { - const v = str2(args, key).trim(); + const v = str3(args, key).trim(); if (v) out[key] = v; } return out; } function edgeEndpoints(edge) { - const a = str2(edge, "a_node_id") || str2(edge, "a"); - const b = str2(edge, "b_node_id") || str2(edge, "b"); + 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) { @@ -989,11 +1479,11 @@ function collapseEdgesToLinks(edges, includeNames = false) { row.link_count = Number(row.link_count || 0) + 1; if (includeNames && !row.a_name) { if (a === lo) { - row.a_name = str2(e, "a_name"); - row.b_name = str2(e, "b_name"); + row.a_name = str3(e, "a_name"); + row.b_name = str3(e, "b_name"); } else { - row.a_name = str2(e, "b_name"); - row.b_name = str2(e, "a_name"); + row.a_name = str3(e, "b_name"); + row.b_name = str3(e, "a_name"); } } buckets.set(key, row); @@ -1006,8 +1496,8 @@ function summarizeViewGraph(graph, sample = 20) { const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => { const row = asRecord2(n); return { - id: str2(row, "id") || str2(row, "fabric_node_id"), - name: str2(row, "name"), + id: str3(row, "id") || str3(row, "fabric_node_id"), + name: str3(row, "name"), x: row.x, y: row.y, level: row.level, @@ -1016,7 +1506,7 @@ function summarizeViewGraph(graph, sample = 20) { }); return { ok: true, - view_id: str2(graph, "id") || str2(graph, "view_id"), + view_id: str3(graph, "id") || str3(graph, "view_id"), node_count: nodes.length, edge_count: edges.length, link_count: collapseEdgesToLinks(edges).length, @@ -1037,41 +1527,41 @@ 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 = bool2(args, "compact") !== false; - const maxDepth = num2(args, "max_depth"); + 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 = str2(args, "view_id").trim(); + 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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; if (detail === "full") return { ...out, detail: "full" }; - return summarizeViewGraph(out, clampInt2(num2(args, "sample"), 20, 0, 200)); + return summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 200)); } async function createTopologyFolder(client, args, signal) { - const name = str2(args, "name").trim(); + const name = str3(args, "name").trim(); if (!name) return { ok: false, error: "name_required" }; const body = { name }; - const parentId = str2(args, "parent_id").trim(); + const parentId = str3(args, "parent_id").trim(); if (parentId) body.parent_id = parentId; - const locale = str2(args, "locale").trim(); + const locale = str3(args, "locale").trim(); if (locale) body.locale = locale; - if (num2(args, "sort_order") !== undefined) - body.sort_order = Math.trunc(num2(args, "sort_order")); + 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 = str2(args, "view_id").trim(); + 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) { @@ -1090,18 +1580,18 @@ async function addTopologyViewNodes(client, args, signal) { detail: "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids." }; } - if (num2(args, "max_nodes") !== undefined) { + 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(num2(args, "max_nodes"), 2000, 1, 2000); + 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 = str2(args, "layout", "grid").trim() || "grid"; + const layout = str3(args, "layout", "grid").trim() || "grid"; const body = { managed_ne_ids: [], layout, @@ -1109,14 +1599,14 @@ async function addTopologyViewNodes(client, args, signal) { }; if (fabricIds.length) body.fabric_node_ids = fabricIds; - if (num2(args, "limit") !== undefined) - body.limit = clampInt2(num2(args, "limit"), 500, 1, 2000); - if (num2(args, "offset") !== undefined) - body.offset = clampInt2(num2(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); + 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 = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; const body = { ...filterFields(args) }; @@ -1126,8 +1616,8 @@ async function removeTopologyViewNodes(client, args, signal) { return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, body, signal)); } async function copyTopologyViewNodes(client, args, signal) { - const sourceId = str2(args, "source_view_id").trim(); - const targetId = str2(args, "target_view_id").trim(); + 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" }; } @@ -1135,16 +1625,16 @@ async function copyTopologyViewNodes(client, args, signal) { if (source.ok !== true) return source; const nodes = Array.isArray(source.nodes) ? source.nodes : []; - const limit = num2(args, "limit"); + 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 str2(row, "id") || str2(row, "fabric_node_id"); + return str3(row, "id") || str3(row, "fabric_node_id"); }).filter(Boolean); - if (bool2(args, "dry_run") === true) { + 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 (bool2(args, "clear_target") === true) { + if (bool3(args, "clear_target") === true) { await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, { fabric_node_ids: "ALL", clear_all: true @@ -1156,12 +1646,12 @@ async function copyTopologyViewNodes(client, args, signal) { 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 (bool2(args, "copy_positions") !== false) { - const ox = num2(args, "offset_x") ?? 0; - const oy = num2(args, "offset_y") ?? 0; + 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 = str2(row, "id") || str2(row, "fabric_node_id"); + const id = str3(row, "id") || str3(row, "fabric_node_id"); return { fabric_node_id: id, x: Number(row.x || 0) + ox, @@ -1175,19 +1665,19 @@ async function copyTopologyViewNodes(client, args, signal) { return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; } async function updateTopologyViewPositions(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + 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 = str2(args, "layout").trim(); + 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 (num2(args, key) !== undefined) - body[key] = num2(args, key); + if (num3(args, key) !== undefined) + body[key] = num3(args, key); } const fabricIds = strList2(args, "fabric_node_ids"); if (fabricIds.length) @@ -1195,7 +1685,7 @@ async function updateTopologyViewPositions(client, args, signal) { return unwrap(await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, body, signal, 120000)); } async function projectTopologyNeighbors(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; const body = {}; @@ -1205,24 +1695,24 @@ async function projectTopologyNeighbors(client, args, signal) { const mids = strList2(args, "managed_ne_ids"); if (mids.length) body.managed_ne_ids = mids; - const region = str2(args, "region_folder_id").trim(); + 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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + 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(num2(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; + return { ...summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; } async function queryTopologyFabricNodes(client, args, signal) { - let mode = str2(args, "mode").trim().toLowerCase(); - const q = str2(args, "q").trim(); - const keyword = str2(args, "keyword").trim(); + let mode = str3(args, "mode").trim().toLowerCase(); + const q = str3(args, "q").trim(); + const keyword = str3(args, "keyword").trim(); if (!mode) { - if (bool2(args, "summary") === true) + if (bool3(args, "summary") === true) mode = "summary"; else if (q) mode = "search"; @@ -1239,16 +1729,16 @@ async function queryTopologyFabricNodes(client, args, signal) { 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(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 200) + 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(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + 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 = str2(args, key).trim() || (key === "keyword" ? q : ""); + const v = str3(args, key).trim() || (key === "keyword" ? q : ""); if (v) params[key] = v; } @@ -1256,61 +1746,61 @@ async function queryTopologyFabricNodes(client, args, signal) { return out.ok === true ? { ...out, mode: "list" } : out; } async function classifyTopologyFabricNodes(client, args, signal) { - const action = str2(args, "action").trim().toLowerCase(); + const action = str3(args, "action").trim().toLowerCase(); if (!action) return { ok: false, error: "action_required" }; if (action === "match") { - const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + 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: str2(args, "match_field", "name").trim() || "name", - sample_limit: clampInt2(num2(args, "sample_limit"), 50, 1, 200) + 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: bool2(args, "dry_run") === true + dry_run: bool3(args, "dry_run") === true }; - const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + 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 = str2(args, "role").trim(); + const role = str3(args, "role").trim(); if (role) body.role = role; - const region = str2(args, "region_folder_id").trim(); + const region = str3(args, "region_folder_id").trim(); if (region) body.region_folder_id = region; - if (bool2(args, "clear_region") === true) + 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 = str2(args, "fabric_node_id").trim() || str2(args, "node_id").trim(); + 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 = str2(args, "role").trim(); + const role = str3(args, "role").trim(); if (role) body.role = role; - const region = str2(args, "region_folder_id").trim(); + const region = str3(args, "region_folder_id").trim(); if (region) body.region_folder_id = region; - if (bool2(args, "clear_region") === true) + 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: str2(args, "kind", "any").trim() || "any", - page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + 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") { @@ -1318,11 +1808,11 @@ async function classifyTopologyFabricNodes(client, args, signal) { } if (action === "apply_rules") { const params = {}; - if (bool2(args, "overwrite_manual") === true) + if (bool3(args, "overwrite_manual") === true) params.skip_manual = false; - else if (bool2(args, "skip_manual") !== undefined) - params.skip_manual = bool2(args, "skip_manual") !== false; - if (bool2(args, "fill_empty_only") === true) + 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)); } @@ -1332,13 +1822,13 @@ async function classifyTopologyFabricNodes(client, args, signal) { return { ok: false, error: "unknown_action", action }; } async function queryTopologyNeighborhood(client, args, signal) { - const nodeId = str2(args, "node_id").trim(); + 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(num2(args, "depth"), 1, 1, 3), - layer: str2(args, "layer", "physical").trim() || "physical" + depth: clampInt2(num3(args, "depth"), 1, 1, 3), + layer: str3(args, "layer", "physical").trim() || "physical" }, signal)); if (out.ok !== true) return out; @@ -1347,19 +1837,19 @@ async function queryTopologyNeighborhood(client, args, signal) { } async function queryTopologyEdges(client, args, signal) { const params = { - page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size"), 100, 1, 500), - layer: str2(args, "layer", "physical").trim() || "physical" + 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 = str2(args, key).trim(); + 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 = str2(args, "detail", "adjacency").trim().toLowerCase() || "adjacency"; + 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 }; @@ -1367,7 +1857,7 @@ async function queryTopologyEdges(client, args, signal) { return { ...out, detail: "adjacency", links, items: undefined }; } async function suggestSinkHubs(client, args, signal) { - const viewId = str2(args, "source_view_id").trim() || str2(args, "view_id").trim(); + 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)); @@ -1387,17 +1877,17 @@ async function suggestSinkHubs(client, args, signal) { ...strList2(args, "exclude_portal_ids"), ...strList2(args, "exclude_fabric_node_ids") ]); - const pick = clampInt2(num2(args, "pick"), 5, 1, 50); + 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 (str2(row2, "id") || str2(row2, "fabric_node_id")) === fabric_node_id; + return (str3(row2, "id") || str3(row2, "fabric_node_id")) === fabric_node_id; }); const row = asRecord2(node); return { fabric_node_id, degree: deg, - name: str2(row, "name"), + name: str3(row, "name"), level: row.level, role: row.role }; @@ -1410,7 +1900,7 @@ async function suggestSinkHubs(client, args, signal) { }; } async function analyzeTopologyViewLayout(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) { return { ok: false, @@ -1453,7 +1943,7 @@ async function analyzeTopologyViewLayout(client, args, signal) { return { ok: true, view_id: viewId, - detail: str2(args, "detail", "summary") || "summary", + detail: str3(args, "detail", "summary") || "summary", node_count: nodes.length, edge_count: edges.length, link_count: collapseEdgesToLinks(edges).length, @@ -1467,13 +1957,13 @@ async function sinkTopologyDualUnits(_client, args) { return { ok: false, error: "sink_dual_units_requires_netx_topology_mcp", - source_view_id: str2(args, "source_view_id"), - sink_view_id: str2(args, "sink_view_id"), + 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 (bool2(args, "catalog") === true) { + if (bool3(args, "catalog") === true) { return { ok: true, catalog: true, @@ -1501,7 +1991,7 @@ async function layoutTopologyView(client, args, signal) { hint: "Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP." }; } - const action = str2(args, "action", "layout").trim().toLowerCase() || "layout"; + const action = str3(args, "action", "layout").trim().toLowerCase() || "layout"; if (action === "job_status" || action === "job_cancel") { return { ok: false, @@ -1510,8 +2000,8 @@ async function layoutTopologyView(client, args, signal) { }; } if (action === "move_nodes" || action === "sink_nodes") { - const toId = str2(args, "view_id").trim(); - const fromId = str2(args, "source_view_id").trim(); + 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"); @@ -1521,8 +2011,8 @@ async function layoutTopologyView(client, args, signal) { if (fabricIds.length === 0) { return { ok: false, error: "fabric_node_ids_required" }; } - const copyPositions = bool2(params, "copy_positions") !== false && bool2(args, "copy_positions") !== false; - const removeFromSource = bool2(params, "remove_from_source") !== false; + 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; @@ -1530,7 +2020,7 @@ async function layoutTopologyView(client, args, signal) { const wanted = new Set(fabricIds); const selected = nodes.filter((n) => { const row = asRecord2(n); - const id = str2(row, "id") || str2(row, "fabric_node_id"); + 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)); @@ -1542,7 +2032,7 @@ async function layoutTopologyView(client, args, signal) { const positions = selected.map((n) => { const row = asRecord2(n); return { - fabric_node_id: str2(row, "id") || str2(row, "fabric_node_id"), + fabric_node_id: str3(row, "id") || str3(row, "fabric_node_id"), x: Number(row.x || 0) + ox, y: Number(row.y || 0) + oy }; @@ -1558,16 +2048,16 @@ async function layoutTopologyView(client, args, signal) { moved: fabricIds.length, source_view_id: fromId, view_id: toId, - park: bool2(params, "park") === true || bool2(args, "park") === true + park: bool3(params, "park") === true || bool3(args, "park") === true }; } return layoutRecipeUnavailable(action); } // src/netx/tools.ts -var str3 = (description) => ({ type: "string", ...description ? { description } : {} }); -var num3 = (description) => ({ type: "number", ...description ? { description } : {} }); -var bool3 = (description) => ({ type: "boolean", ...description ? { description } : {} }); +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" }, @@ -1578,19 +2068,19 @@ var anyObj = (description) => ({ additionalProperties: true, ...description ? { description } : {} }); -function renderJson(_args, value) { +function renderJson2(_args, value) { return [{ type: "text", text: JSON.stringify(value, null, 0) }]; } -var jsonOut = { +var jsonOut2 = { schema: { type: "json" }, - render: renderJson + render: renderJson2 }; -function tool(name, description, parameters, handler, getClient, timeoutMs) { - return defineTool({ +function tool2(name, description, parameters, handler, getClient, timeoutMs) { + return defineTool2({ name, description, parameters, - output: jsonOut, + output: jsonOut2, timeoutMs, isConcurrencySafe: () => true, async execute(args, exec) { @@ -1616,90 +2106,90 @@ function registerNetxTools(ctx, connection, options) { const getClient = () => client; const t = connection.toolCallTimeoutMs; const catalog = [ - tool("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: str3(), - ne_id: str3("Filter only; do not show UUID to users"), - host_name: str3("Filter by NE host_name"), - ne_name: str3("Legacy alias mapped to keyword"), - keyword: str3("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), - time_from: str3("ISO time; filters last_seen_at >="), - time_to: str3("ISO time; filters last_seen_at <="), - page: num3(), - page_size: num3() + 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), - tool("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: str3("Optional perceived_severity filter (critical/major/minor/warning)."), - top_ne: num3("Max NE buckets (default 50). Ignored when group_by is set."), - exclude_missing_host: bool3("Omit missing host_name from by_ne."), - time_from: str3(), - time_to: str3(), - group_by: str3("When set, routes to raw aggregation. Prefer alarm_host_name."), - group_by2: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - limit: num3() + 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), - tool("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), - tool("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { - keyword: str3(), - page: num3(), - page_size: num3() + 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), - tool("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { + 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), - tool("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: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - time_from: str3(), - time_to: str3(), - order_by: str3("last_seen_at | time_created | perceived_severity | event_type | ne_id"), - order: str3("asc | desc"), + 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: str3("brief | evidence | ne_debug"), - page: num3(), - page_size: num3() + field_preset: str4("brief | evidence | ne_debug"), + page: num4(), + page_size: num4() }, queryUmeAlarmsRaw, getClient, t), - tool("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { + 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: str3(), - severity: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - time_from: str3(), - time_to: str3(), - exclude_missing_host: bool3(), - limit: num3() + 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), - tool("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), - tool("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { + 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: num3(), - statement_timeout_ms: num3() + limit: num4(), + statement_timeout_ms: num4() }, sqlQueryUme, getClient, t), - tool("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { - keyword: str3(), - vendor: str3(), - connect_status: str3("unknown | testing | pass | fail"), - page: num3(), - page_size: num3() + 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), - tool("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { - ne_id: str3("Managed NE id"), - managed_ne_id: str3("Alias for ne_id"), - id: str3("Alias for ne_id") + 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), - tool("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: str3(), - nms_ne_id: str3("NMS inventory id; alias ume_ne_id"), - ume_ne_id: str3("Legacy alias of nms_ne_id"), + 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"), @@ -1710,79 +2200,79 @@ function registerNetxTools(ctx, connection, options) { type: "object", additionalProperties: false, properties: { - ne_id: str3(), - nms_ne_id: str3(), - ume_ne_id: str3(), + 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: num3("Per-command read timeout (default 60; use 90–120 for slow show)."), - concurrency: num3("Parallel NEs for batch mode (1–8, default 4)."), - async: bool3("oclaw-only async hint; ignored by native REST client.") + 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)), - tool("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { - source: str3("managed | nms | ume | all"), - keyword: str3(), - page: num3(), - page_size: num3() + 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), - tool("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: str3("NMS inventory id"), - from_ume_ne_id: str3("Legacy alias of from_nms_ne_id"), - from_managed_ne_id: str3(), - to_nms_ne_id: str3("NMS inventory id"), - to_ume_ne_id: str3("Legacy alias of to_nms_ne_id"), - to_managed_ne_id: str3(), - max_paths: num3(), - max_hops: num3(), - layer: str3(), - detail: str3("summary | full") + 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), - tool("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool3(), max_depth: num3() }, getTopologyTree, getClient, t), - tool("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { + 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: str3("summary | full"), - sample: num3() + detail: str4("summary | full"), + sample: num4() }, getTopologyView, getClient, t), - tool("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { + tool2("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { name: { type: "string", required: true }, - parent_id: str3(), - locale: str3("zh | en"), - sort_order: num3() + parent_id: str4(), + locale: str4("zh | en"), + sort_order: num4() }, createTopologyFolder, getClient, t), - tool("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { + 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: num3(), - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), - limit: num3(), - offset: num3(), + max_nodes: num4(), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), + limit: num4(), + offset: num4(), fabric_node_ids: strArr(), - layout: str3("grid | keep") + layout: str4("grid | keep") }, addTopologyViewNodes, getClient, t), - tool("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { + tool2("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { view_id: { type: "string", required: true }, - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), fabric_node_ids: strArr() }, removeTopologyViewNodes, getClient, t), - tool("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { + 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: bool3(), - clear_target: bool3(), - offset_x: num3(), - offset_y: num3(), - limit: num3(), - dry_run: bool3() + copy_positions: bool4(), + clear_target: bool4(), + offset_x: num4(), + offset_y: num4(), + limit: num4(), + dry_run: bool4() }, copyTopologyViewNodes, getClient, t), - tool("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { + 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", @@ -1791,121 +2281,121 @@ function registerNetxTools(ctx, connection, options) { type: "object", additionalProperties: false, properties: { - fabric_node_id: str3(), - x: num3(), - y: num3() + fabric_node_id: str4(), + x: num4(), + y: num4() } } }, - layout: str3("grid | offset | stack"), - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), + layout: str4("grid | offset | stack"), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), fabric_node_ids: strArr(), - offset_x: num3(), - offset_y: num3() + offset_x: num4(), + offset_y: num4() }, updateTopologyViewPositions, getClient, t), - tool("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { + 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: str3(), - detail: str3("summary | full"), - sample: num3() + region_folder_id: str4(), + detail: str4("summary | full"), + sample: num4() }, projectTopologyNeighbors, getClient, t), - tool("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { - mode: str3("summary | list | search"), - q: str3(), - keyword: str3(), - role: str3(), - level: str3(), - level_major: str3(), - region_folder_id: str3(), - link_status: str3(), - page: num3(), - page_size: num3(), - limit: num3(), - summary: bool3() + 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), - tool("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { + tool2("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { action: { type: "string", required: true }, - pattern: str3(), - q: str3(), - match_field: str3(), - sample_limit: num3(), + pattern: str4(), + q: str4(), + match_field: str4(), + sample_limit: num4(), fabric_node_ids: strArr(), - fabric_node_id: str3(), - node_id: str3(), - level: str3(), - role: str3(), - region_folder_id: str3(), - clear_region: bool3(), - dry_run: bool3(), - kind: str3(), - page: num3(), - page_size: num3(), - skip_manual: bool3(), - overwrite_manual: bool3(), - fill_empty_only: bool3() + 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), - tool("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { + tool2("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { node_id: { type: "string", required: true }, - depth: num3(), - layer: str3() + depth: num4(), + layer: str4() }, queryTopologyNeighborhood, getClient, t), - tool("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { - node_id: str3(), - keyword: str3(), - layer: str3(), - status: str3(), - source: str3("lldp | ume | manual"), - detail: str3("adjacency | ports"), - page: num3(), - page_size: num3() + 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), - tool("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { - source_view_id: str3(), - view_id: str3(), - pick: num3(), + 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), - tool("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { - view_id: str3(), - folder_id: str3(), - detail: str3("summary | structure | hotspots | blocks | both"), - score_profile: str3(), - sight_limit: num3(), - max_views: num3(), - min_nodes: num3(), - max_nodes: num3(), - with_meta: bool3() + 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), - tool("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { - source_view_id: str3(), - sink_view_id: str3(), - max_units: num3(), - min_nodes: num3(), - max_nodes: num3(), - max_batch_nodes: num3(), - layout_batch: bool3(), - until_empty: bool3(), - dry_run: bool3() + 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), - tool("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: str3(), - action: str3(), - source_view_id: str3(), - recipe: str3(), - preset: str3(), - mode: str3("preview | apply"), - tune: bool3(), + 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: bool3(), + catalog: bool4(), fabric_node_ids: strArr(), - park: bool3(), - copy_positions: bool3() + 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)); @@ -1918,6 +2408,7 @@ function registerNetxTools(ctx, connection, options) { // src/netx/group-tools-plugin.ts function applyGroupToolsPlugin(ctx, options) { let unregisterTools; + let unregisterKbLocal; let unregisterSkills; let skillGeneration = 0; const resolveGroups = () => { @@ -1947,9 +2438,24 @@ function applyGroupToolsPlugin(ctx, options) { }); 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; @@ -2022,8 +2528,11 @@ function applyGroupToolsPlugin(ctx, options) { }); ctx.effect(() => () => { stopToolWatch(); + stopKbToolWatch(); unregisterTools?.(); unregisterTools = undefined; + unregisterKbLocal?.(); + unregisterKbLocal = undefined; }, `${options.name}: dispose tools`); } diff --git a/lib/index.js b/lib/index.js index 36d325e..9047449 100644 --- a/lib/index.js +++ b/lib/index.js @@ -2,9 +2,9 @@ import { createRequire } from "node:module"; var __require = /* @__PURE__ */ createRequire(import.meta.url); // src/index.ts -import { cpSync, existsSync as existsSync3, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs"; import { homedir as homedir3 } from "node:os"; -import { dirname as dirname3, join as join5 } from "node:path"; +import { dirname as dirname4, join as join7 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import z from "@deepseek-ai/schemastery"; import { credentialRef } from "@deepseek-ai/dsh-credentials"; @@ -785,11 +785,165 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) { }; } +// src/netx/kb-local-path.ts +import { existsSync as existsSync2, realpathSync, statSync } from "node:fs"; +import { join as join3, relative, resolve, sep } from "node:path"; +var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ + "memories", + "drafts", + "suggestions" +]); +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 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 join3("memories", MEMORY_BUCKET_DIR[bucket]); +} +function suggestionDir(kind) { + return join3("suggestions", kind); +} +function draftDir(domain) { + const d = domain?.trim(); + if (!d) + return "drafts"; + return join3("drafts", sanitizeSlug(d, "misc")); +} + // 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 ? [ + "", + "**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,", + "`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.", + "They jail writes to `memories/` / `drafts/` / `suggestions/` only.", + "Do **not** use workspace Write/bash for KB paths (sandbox).", + "`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;", + "file boundary issues as `suggestions/` instead." + ] : [ + "", + "No local write tools until `paths.local` is present in MANIFEST." + ]; return { description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", content: [ @@ -802,12 +956,14 @@ function skillBody(snapshot) { `| --- | --- |`, `| kbStatus | configured |`, `| kbRoot | \`${snapshot.realRoot}\` |`, + localRow, `| kbOperator | ${snapshot.operatorName} |`, `| kbCountry | ${snapshot.country} |`, `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, + ...localGuide, "", - "Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "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,`, @@ -848,8 +1004,8 @@ function registerKbContextSkill(ctx, snapshot) { } // src/netx/kb-manifest.ts -import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "node:fs"; -import { basename, dirname as dirname2, join as join3, resolve } from "node:path"; +import { existsSync as existsSync3, readdirSync, readFileSync, statSync as statSync2 } from "node:fs"; +import { basename, dirname as dirname2, join as join4, resolve as resolve2 } from "node:path"; var EMPTY_CONTENT = { hasRegions: false, hasTheory: false, @@ -889,23 +1045,23 @@ function errorSnapshot(message) { }; } function findManifest(kbRoot, maxDepth = 3) { - const root = resolve(kbRoot.trim()); + const root = resolve2(kbRoot.trim()); if (!kbRoot.trim()) { return { paths: [], error: "kbRoot is empty" }; } let rootStat; try { - rootStat = statSync(root); + rootStat = statSync2(root); } catch { return { paths: [], error: `kbRoot not found: ${root}` }; } if (!rootStat.isDirectory()) { return { paths: [], error: `kbRoot is not a directory: ${root}` }; } - const direct = join3(root, "MANIFEST.json"); - if (existsSync2(direct)) { + const direct = join4(root, "MANIFEST.json"); + if (existsSync3(direct)) { try { - if (statSync(direct).isFile()) + if (statSync2(direct).isFile()) return { paths: [direct] }; } catch {} } @@ -913,10 +1069,10 @@ function findManifest(kbRoot, maxDepth = 3) { const walk = (dir, depth) => { if (depth > maxDepth) return; - const candidate = join3(dir, "MANIFEST.json"); - if (existsSync2(candidate)) { + const candidate = join4(dir, "MANIFEST.json"); + if (existsSync3(candidate)) { try { - if (statSync(candidate).isFile()) + if (statSync2(candidate).isFile()) found.push(candidate); } catch {} } @@ -931,10 +1087,10 @@ function findManifest(kbRoot, maxDepth = 3) { for (const name of entries) { if (name === "node_modules" || name === ".git") continue; - const full = join3(dir, name); + const full = join4(dir, name); let st; try { - st = statSync(full); + st = statSync2(full); } catch { continue; } @@ -951,10 +1107,10 @@ function findManifest(kbRoot, maxDepth = 3) { for (const name of topEntries) { if (name === "node_modules" || name === ".git") continue; - const full = join3(root, name); + const full = join4(root, name); let st; try { - st = statSync(full); + st = statSync2(full); } catch { continue; } @@ -1042,9 +1198,9 @@ function resolveKbRoot(kbRoot, maxDepth = 3) { const trimmed = kbRoot.trim(); if (!trimmed) return unconfiguredKbSnapshot(); - let root = resolve(trimmed); + let root = resolve2(trimmed); try { - const st = statSync(root); + const st = statSync2(root); if (st.isFile()) { if (basename(root).toLowerCase() === "manifest.json") { root = dirname2(root); @@ -1090,21 +1246,421 @@ function resolveKbRoot(kbRoot, maxDepth = 3) { } } +// src/netx/kb-local-tools.ts +import { defineTool } from "@deepseek-ai/dsh-tools"; + +// src/netx/kb-local-ops.ts +import { + existsSync as existsSync4, + mkdirSync, + readdirSync as readdirSync2, + readFileSync as readFileSync2, + rmSync, + statSync as statSync3, + writeFileSync +} from "node:fs"; +import { dirname as dirname3, join as join5 } 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 (existsSync4(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(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 (existsSync4(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(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 (existsSync4(target.absolutePath) && !args.overwrite) { + throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); + } + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(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 (!existsSync4(target.absolutePath)) { + throw new Error(`file not found: ${target.relativePath}`); + } + const body = target.writableRoot === "drafts" ? ensureDraftBody(args.body) : args.body.endsWith(` +`) ? args.body : `${args.body} +`; + writeFileSync(target.absolutePath, body, "utf8"); + 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 (!existsSync4(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 = readdirSync2(dir); + } catch { + return; + } + for (const name of entries) { + if (name === "." || name === "..") + continue; + const full = join5(dir, name); + let st; + try { + st = statSync3(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(join5(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-runtime.ts +var STORE_KEY2 = Symbol.for("dsh-netxops.kb-store"); +var ENV_KEYS = [ + "KB_ROOT", + "KB_LOCAL", + "KB_OPERATOR", + "KB_COUNTRY", + "KB_VERSION", + "KB_CONTENT", + "KB_STATUS" +]; +function store2() { + const root = globalThis; + let current = root[STORE_KEY2]; + if (current === undefined) { + current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set }; + root[STORE_KEY2] = current; + } + return current; +} +function getKbContext() { + const snap = store2().snapshot; + return { + ...snap, + content: { ...snap.content }, + paths: { ...snap.paths } + }; +} +function publishKbContext(next) { + const state = store2(); + state.snapshot = { + ...next, + content: { ...next.content }, + paths: { ...next.paths } + }; + for (const listener of state.listeners) + listener(); +} +function watchKbContext(listener) { + const state = store2(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} +function applyKbEnv(snapshot) { + for (const key of ENV_KEYS) { + delete process.env[key]; + } + process.env.KB_STATUS = snapshot.status; + if (snapshot.status !== "configured") + return; + process.env.KB_ROOT = snapshot.realRoot; + const local = resolveKbLocalRoot(snapshot); + if (local) + process.env.KB_LOCAL = local; + process.env.KB_OPERATOR = snapshot.operatorName; + process.env.KB_COUNTRY = snapshot.country; + process.env.KB_VERSION = snapshot.version; + process.env.KB_CONTENT = JSON.stringify(snapshot.content); +} +function resetKbContext() { + publishKbContext(unconfiguredKbSnapshot()); + applyKbEnv(unconfiguredKbSnapshot()); +} + +// 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 } : {} }); +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.", { + type: "object", + additionalProperties: false, + required: ["bucket", "slug", "body"], + properties: { + bucket: { + type: "string", + enum: ["note", "rca_review", "ai_trace"], + description: "Memory subdirectory" + }, + slug: str("Short filename stem (no path separators)"), + body: str("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 identity/ or formal RCA.", { + type: "object", + additionalProperties: false, + required: ["slug", "body"], + properties: { + slug: str("Case stem (DRAFT- prefix added if missing)"), + body: str("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 theory corrections or tool/process improvements — never rewrite identity/.", { + type: "object", + additionalProperties: false, + required: ["kind", "slug", "body"], + properties: { + kind: { + type: "string", + enum: ["theory", "improvement"], + description: "theory = knowledge fix; improvement = tools/skills/process" + }, + slug: str("Short filename stem"), + body: str("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__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", { + type: "object", + additionalProperties: false, + required: ["path", "body"], + properties: { + path: str("Absolute path or path relative to KB local root"), + body: str("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 ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, { + type: "object", + additionalProperties: false, + required: ["path"], + properties: { + path: str("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 (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", { + type: "object", + additionalProperties: false, + properties: { + root: { + type: "string", + enum: ["memories", "drafts", "suggestions", "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"; +import { join as join6 } 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); + return join6(snapshot.realRoot, rel); } function resolveKbLocalSkillsRoot(snapshot) { const rel = snapshot.paths.localSkills?.trim(); if (!rel) return null; - return join4(snapshot.realRoot, rel); + return join6(snapshot.realRoot, rel); } async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; @@ -1120,7 +1676,7 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) return []; const disposers = []; for (const entry of entries) { - const full = join4(root, entry); + const full = join6(root, entry); let isDir = false; try { isDir = (await stat2(full)).isDirectory(); @@ -1183,68 +1739,6 @@ async function registerKbPackSkills(ctx, snapshot, options) { }; } -// src/netx/kb-runtime.ts -var STORE_KEY2 = Symbol.for("dsh-netxops.kb-store"); -var ENV_KEYS = [ - "KB_ROOT", - "KB_OPERATOR", - "KB_COUNTRY", - "KB_VERSION", - "KB_CONTENT", - "KB_STATUS" -]; -function store2() { - const root = globalThis; - let current = root[STORE_KEY2]; - if (current === undefined) { - current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set }; - root[STORE_KEY2] = current; - } - return current; -} -function getKbContext() { - const snap = store2().snapshot; - return { - ...snap, - content: { ...snap.content }, - paths: { ...snap.paths } - }; -} -function publishKbContext(next) { - const state = store2(); - state.snapshot = { - ...next, - content: { ...next.content }, - paths: { ...next.paths } - }; - for (const listener of state.listeners) - listener(); -} -function watchKbContext(listener) { - const state = store2(); - state.listeners.add(listener); - return () => { - state.listeners.delete(listener); - }; -} -function applyKbEnv(snapshot) { - for (const key of ENV_KEYS) { - delete process.env[key]; - } - process.env.KB_STATUS = snapshot.status; - if (snapshot.status !== "configured") - return; - process.env.KB_ROOT = snapshot.realRoot; - process.env.KB_OPERATOR = snapshot.operatorName; - process.env.KB_COUNTRY = snapshot.country; - process.env.KB_VERSION = snapshot.version; - process.env.KB_CONTENT = JSON.stringify(snapshot.content); -} -function resetKbContext() { - publishKbContext(unconfiguredKbSnapshot()); - applyKbEnv(unconfiguredKbSnapshot()); -} - // src/netx/model-language.ts var THINKING_LANGUAGE_DEFAULT = "auto"; var REPLY_LANGUAGE_DEFAULT = "follow-user"; @@ -1958,17 +2452,17 @@ try { td.decode(et, { stream: true }); tds = 1; } catch (e) {} -function strToU8(str, latin1) { +function strToU8(str2, latin1) { if (latin1) { - var ar_1 = new u8(str.length); - for (var i2 = 0;i2 < str.length; ++i2) - ar_1[i2] = str.charCodeAt(i2); + var ar_1 = new u8(str2.length); + for (var i2 = 0;i2 < str2.length; ++i2) + ar_1[i2] = str2.charCodeAt(i2); return ar_1; } if (te) - return te.encode(str); - var l = str.length; - var ar = new u8(str.length + (str.length >> 1)); + return te.encode(str2); + var l = str2.length; + var ar = new u8(str2.length + (str2.length >> 1)); var ai = 0; var w = function(v) { ar[ai++] = v; @@ -1979,13 +2473,13 @@ function strToU8(str, latin1) { n.set(ar); ar = n; } - var c = str.charCodeAt(i2); + var c = str2.charCodeAt(i2); if (c < 128 || latin1) w(c); else if (c < 2048) w(192 | c >> 6), w(128 | c & 63); else if (c > 55295 && c < 57344) - c = 65536 + (c & 1023 << 10) | str.charCodeAt(++i2) & 1023, w(240 | c >> 18), w(128 | c >> 12 & 63), w(128 | c >> 6 & 63), w(128 | c & 63); + c = 65536 + (c & 1023 << 10) | str2.charCodeAt(++i2) & 1023, w(240 | c >> 18), w(128 | c >> 12 & 63), w(128 | c >> 6 & 63), w(128 | c & 63); else w(224 | c >> 12), w(128 | c >> 6 & 63), w(128 | c & 63); } @@ -2475,7 +2969,7 @@ async function sessionsExportHeadResponse(ctx, request) { } // src/netx/tools.ts -import { defineTool } from "@deepseek-ai/dsh-tools"; +import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; // src/netx/http.ts var PROTOCOL_KEY_ZH_TO_EN = { @@ -2638,7 +3132,7 @@ var UME_RAW_FIELD_PRESETS = { function asRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; } -function str(args, key, fallback = "") { +function str2(args, key, fallback = "") { const v = args[key]; if (typeof v === "string") return v; @@ -2647,17 +3141,17 @@ function str(args, key, fallback = "") { return fallback; } function nmsOrUme(args, nmsKey, umeKey) { - return str(args, nmsKey).trim() || str(args, umeKey).trim(); + 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 num(args, key) { +function num2(args, key) { const v = args[key]; return typeof v === "number" && Number.isFinite(v) ? v : undefined; } -function bool(args, key) { +function bool2(args, key) { const v = args[key]; return typeof v === "boolean" ? v : undefined; } @@ -2673,18 +3167,18 @@ function clampInt(value, fallback, min, max) { } function putStr(params, args, keys) { for (const key of keys) { - const v = str(args, key).trim(); + const v = str2(args, key).trim(); if (v) params[key] = v; } } async function queryUmeAlarms(client, args, signal) { - let page = clampInt(num(args, "page"), 1, 1, 2); - const pageSize = clampInt(num(args, "page_size"), 50, 1, 500); + 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 = str(args, "keyword").trim(); - const neName = str(args, "ne_name").trim(); + const keyword = str2(args, "keyword").trim(); + const neName = str2(args, "ne_name").trim(); if (keyword) params.keyword = keyword; else if (neName) @@ -2706,19 +3200,19 @@ async function aggregateUmeAlarmsRaw(client, args, signal) { "limit" ]); if ("exclude_missing_host" in args) { - const flag = bool(args, "exclude_missing_host"); + 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 (str(args, "group_by").trim()) + if (str2(args, "group_by").trim()) return aggregateUmeAlarmsRaw(client, args, signal); - const topNe = clampInt(num(args, "top_ne"), 50, 0, 500); + const topNe = clampInt(num2(args, "top_ne"), 50, 0, 500); const params = { top_ne: topNe }; if ("exclude_missing_host" in args) { - const flag = bool(args, "exclude_missing_host"); + const flag = bool2(args, "exclude_missing_host"); if (flag !== undefined) params.exclude_missing_host = flag; } @@ -2730,22 +3224,22 @@ async function runUmeDiagnostics(client, _args, signal) { } async function queryUmeNeInventory(client, args, signal) { const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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 = str(args, "ne_id").trim(); + 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(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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", @@ -2760,7 +3254,7 @@ async function queryUmeAlarmsRaw(client, args, signal) { ]); let fields = strList(args, "select_fields"); if (fields.length === 0) { - const preset = str(args, "field_preset").trim().toLowerCase(); + const preset = str2(args, "field_preset").trim().toLowerCase(); fields = UME_RAW_FIELD_PRESETS[preset] ?? []; } if (fields.length > 0) @@ -2771,11 +3265,11 @@ async function listUmeAlarmFields(client, _args, signal) { return client.get("/v1/ume/alarms/fields", undefined, signal); } async function sqlQueryUme(client, args, signal) { - const sql = str(args, "sql").trim(); + const sql = str2(args, "sql").trim(); if (!sql) return { ok: false, error: "sql_required" }; - const limit = clampInt(num(args, "limit"), 200, 1, 2000); - const statementTimeoutMs = clampInt(num(args, "statement_timeout_ms"), 0, 0, 30000); + 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, @@ -2783,9 +3277,9 @@ async function sqlQueryUme(client, args, signal) { }, signal, 60000); } async function listManagedNe(client, args, signal) { - const keyword = str(args, "keyword").trim(); - const vendor = str(args, "vendor").trim(); - const connectStatus = str(args, "connect_status").trim(); + 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" }; } @@ -2793,8 +3287,8 @@ async function listManagedNe(client, args, signal) { return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; } const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 20, 1, 100) + 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; @@ -2805,7 +3299,7 @@ async function listManagedNe(client, args, signal) { return client.get("/v1/managed-ne", params, signal); } async function getManagedNe(client, args, signal) { - const neId = (str(args, "ne_id") || str(args, "managed_ne_id") || str(args, "id")).trim(); + const neId = (str2(args, "ne_id") || str2(args, "managed_ne_id") || str2(args, "id")).trim(); if (!neId) { return { ok: false, @@ -2817,7 +3311,7 @@ async function getManagedNe(client, args, signal) { } const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); if (out.ok === false) { - const detail = `${str(out, "detail")}${str(out, "error")}`.toLowerCase(); + 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, @@ -2842,7 +3336,7 @@ async function execManagedNe(client, args, signal) { continue; const row = t; const item = {}; - const neId2 = str(row, "ne_id").trim(); + const neId2 = str2(row, "ne_id").trim(); const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); if (neId2) item.ne_id = neId2; @@ -2866,8 +3360,8 @@ async function execManagedNe(client, args, signal) { } body2.commands = sharedCommands; } - body2.read_timeout_sec = clampInt(num(args, "read_timeout_sec"), 60, 10, 120); - const concurrency = num(args, "concurrency"); + 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); @@ -2875,11 +3369,11 @@ async function execManagedNe(client, args, signal) { return out2; const data2 = asRecord(out2.data); if (data2.ok === false) { - return { ok: false, data: data2, error: str(data2, "error", "exec_batch_failed") }; + return { ok: false, data: data2, error: str2(data2, "error", "exec_batch_failed") }; } return { ok: true, data: data2 }; } - const neId = str(args, "ne_id").trim(); + const neId = str2(args, "ne_id").trim(); const umeNeId = nmsOrUme(args, "nms_ne_id", "ume_ne_id"); if (Boolean(neId) === Boolean(umeNeId)) { return { @@ -2897,7 +3391,7 @@ async function execManagedNe(client, args, signal) { } const body = { commands: sharedCommands, - read_timeout_sec: clampInt(num(args, "read_timeout_sec"), 60, 10, 120) + read_timeout_sec: clampInt(num2(args, "read_timeout_sec"), 60, 10, 120) }; if (neId) body.ne_id = neId; @@ -2908,36 +3402,36 @@ async function execManagedNe(client, args, signal) { return out; const data = asRecord(out.data); if (data.ok === false) { - return { ok: false, data, error: str(data, "error", "exec_failed") }; + return { ok: false, data, error: str2(data, "error", "exec_failed") }; } return { ok: true, data }; } async function listCliTargets(client, args, signal) { const params = { - page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num(args, "page_size"), 50, 1, 500) + 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 = str(args, "from_managed_ne_id").trim(); + const fromMid = str2(args, "from_managed_ne_id").trim(); const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); - const toMid = str(args, "to_managed_ne_id").trim(); + 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 = str(args, "detail", "summary").trim().toLowerCase() || "summary"; + let detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; if (detail !== "summary" && detail !== "full") detail = "summary"; const body = { - max_paths: clampInt(num(args, "max_paths"), 3, 1, 10), - max_hops: clampInt(num(args, "max_hops"), 6, 1, 12), - layer: str(args, "layer", "physical").trim() || "physical", + 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) @@ -2955,7 +3449,7 @@ async function findTopologyPaths(client, args, signal) { function asRecord2(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; } -function str2(args, key, fallback = "") { +function str3(args, key, fallback = "") { const v = args[key]; if (typeof v === "string") return v; @@ -2963,11 +3457,11 @@ function str2(args, key, fallback = "") { return String(v); return fallback; } -function num2(args, key) { +function num3(args, key) { const v = args[key]; return typeof v === "number" && Number.isFinite(v) ? v : undefined; } -function bool2(args, key) { +function bool3(args, key) { const v = args[key]; return typeof v === "boolean" ? v : undefined; } @@ -2992,15 +3486,15 @@ function unwrap(out) { function filterFields(args) { const out = {}; for (const key of ["keyword", "role", "vendor", "link_status"]) { - const v = str2(args, key).trim(); + const v = str3(args, key).trim(); if (v) out[key] = v; } return out; } function edgeEndpoints(edge) { - const a = str2(edge, "a_node_id") || str2(edge, "a"); - const b = str2(edge, "b_node_id") || str2(edge, "b"); + 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) { @@ -3023,11 +3517,11 @@ function collapseEdgesToLinks(edges, includeNames = false) { row.link_count = Number(row.link_count || 0) + 1; if (includeNames && !row.a_name) { if (a === lo) { - row.a_name = str2(e, "a_name"); - row.b_name = str2(e, "b_name"); + row.a_name = str3(e, "a_name"); + row.b_name = str3(e, "b_name"); } else { - row.a_name = str2(e, "b_name"); - row.b_name = str2(e, "a_name"); + row.a_name = str3(e, "b_name"); + row.b_name = str3(e, "a_name"); } } buckets.set(key, row); @@ -3040,8 +3534,8 @@ function summarizeViewGraph(graph, sample = 20) { const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => { const row = asRecord2(n); return { - id: str2(row, "id") || str2(row, "fabric_node_id"), - name: str2(row, "name"), + id: str3(row, "id") || str3(row, "fabric_node_id"), + name: str3(row, "name"), x: row.x, y: row.y, level: row.level, @@ -3050,7 +3544,7 @@ function summarizeViewGraph(graph, sample = 20) { }); return { ok: true, - view_id: str2(graph, "id") || str2(graph, "view_id"), + view_id: str3(graph, "id") || str3(graph, "view_id"), node_count: nodes.length, edge_count: edges.length, link_count: collapseEdgesToLinks(edges).length, @@ -3071,41 +3565,41 @@ 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 = bool2(args, "compact") !== false; - const maxDepth = num2(args, "max_depth"); + 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 = str2(args, "view_id").trim(); + 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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; if (detail === "full") return { ...out, detail: "full" }; - return summarizeViewGraph(out, clampInt2(num2(args, "sample"), 20, 0, 200)); + return summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 200)); } async function createTopologyFolder(client, args, signal) { - const name = str2(args, "name").trim(); + const name = str3(args, "name").trim(); if (!name) return { ok: false, error: "name_required" }; const body = { name }; - const parentId = str2(args, "parent_id").trim(); + const parentId = str3(args, "parent_id").trim(); if (parentId) body.parent_id = parentId; - const locale = str2(args, "locale").trim(); + const locale = str3(args, "locale").trim(); if (locale) body.locale = locale; - if (num2(args, "sort_order") !== undefined) - body.sort_order = Math.trunc(num2(args, "sort_order")); + 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 = str2(args, "view_id").trim(); + 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) { @@ -3124,18 +3618,18 @@ async function addTopologyViewNodes(client, args, signal) { detail: "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids." }; } - if (num2(args, "max_nodes") !== undefined) { + 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(num2(args, "max_nodes"), 2000, 1, 2000); + 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 = str2(args, "layout", "grid").trim() || "grid"; + const layout = str3(args, "layout", "grid").trim() || "grid"; const body = { managed_ne_ids: [], layout, @@ -3143,14 +3637,14 @@ async function addTopologyViewNodes(client, args, signal) { }; if (fabricIds.length) body.fabric_node_ids = fabricIds; - if (num2(args, "limit") !== undefined) - body.limit = clampInt2(num2(args, "limit"), 500, 1, 2000); - if (num2(args, "offset") !== undefined) - body.offset = clampInt2(num2(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); + 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 = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; const body = { ...filterFields(args) }; @@ -3160,8 +3654,8 @@ async function removeTopologyViewNodes(client, args, signal) { return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, body, signal)); } async function copyTopologyViewNodes(client, args, signal) { - const sourceId = str2(args, "source_view_id").trim(); - const targetId = str2(args, "target_view_id").trim(); + 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" }; } @@ -3169,16 +3663,16 @@ async function copyTopologyViewNodes(client, args, signal) { if (source.ok !== true) return source; const nodes = Array.isArray(source.nodes) ? source.nodes : []; - const limit = num2(args, "limit"); + 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 str2(row, "id") || str2(row, "fabric_node_id"); + return str3(row, "id") || str3(row, "fabric_node_id"); }).filter(Boolean); - if (bool2(args, "dry_run") === true) { + 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 (bool2(args, "clear_target") === true) { + if (bool3(args, "clear_target") === true) { await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, { fabric_node_ids: "ALL", clear_all: true @@ -3190,12 +3684,12 @@ async function copyTopologyViewNodes(client, args, signal) { 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 (bool2(args, "copy_positions") !== false) { - const ox = num2(args, "offset_x") ?? 0; - const oy = num2(args, "offset_y") ?? 0; + 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 = str2(row, "id") || str2(row, "fabric_node_id"); + const id = str3(row, "id") || str3(row, "fabric_node_id"); return { fabric_node_id: id, x: Number(row.x || 0) + ox, @@ -3209,19 +3703,19 @@ async function copyTopologyViewNodes(client, args, signal) { return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; } async function updateTopologyViewPositions(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + 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 = str2(args, "layout").trim(); + 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 (num2(args, key) !== undefined) - body[key] = num2(args, key); + if (num3(args, key) !== undefined) + body[key] = num3(args, key); } const fabricIds = strList2(args, "fabric_node_ids"); if (fabricIds.length) @@ -3229,7 +3723,7 @@ async function updateTopologyViewPositions(client, args, signal) { return unwrap(await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, body, signal, 120000)); } async function projectTopologyNeighbors(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) return { ok: false, error: "view_id_required" }; const body = {}; @@ -3239,24 +3733,24 @@ async function projectTopologyNeighbors(client, args, signal) { const mids = strList2(args, "managed_ne_ids"); if (mids.length) body.managed_ne_ids = mids; - const region = str2(args, "region_folder_id").trim(); + 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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + 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(num2(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; + return { ...summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; } async function queryTopologyFabricNodes(client, args, signal) { - let mode = str2(args, "mode").trim().toLowerCase(); - const q = str2(args, "q").trim(); - const keyword = str2(args, "keyword").trim(); + let mode = str3(args, "mode").trim().toLowerCase(); + const q = str3(args, "q").trim(); + const keyword = str3(args, "keyword").trim(); if (!mode) { - if (bool2(args, "summary") === true) + if (bool3(args, "summary") === true) mode = "summary"; else if (q) mode = "search"; @@ -3273,16 +3767,16 @@ async function queryTopologyFabricNodes(client, args, signal) { 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(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 200) + 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(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + 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 = str2(args, key).trim() || (key === "keyword" ? q : ""); + const v = str3(args, key).trim() || (key === "keyword" ? q : ""); if (v) params[key] = v; } @@ -3290,61 +3784,61 @@ async function queryTopologyFabricNodes(client, args, signal) { return out.ok === true ? { ...out, mode: "list" } : out; } async function classifyTopologyFabricNodes(client, args, signal) { - const action = str2(args, "action").trim().toLowerCase(); + const action = str3(args, "action").trim().toLowerCase(); if (!action) return { ok: false, error: "action_required" }; if (action === "match") { - const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + 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: str2(args, "match_field", "name").trim() || "name", - sample_limit: clampInt2(num2(args, "sample_limit"), 50, 1, 200) + 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: bool2(args, "dry_run") === true + dry_run: bool3(args, "dry_run") === true }; - const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + 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 = str2(args, "role").trim(); + const role = str3(args, "role").trim(); if (role) body.role = role; - const region = str2(args, "region_folder_id").trim(); + const region = str3(args, "region_folder_id").trim(); if (region) body.region_folder_id = region; - if (bool2(args, "clear_region") === true) + 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 = str2(args, "fabric_node_id").trim() || str2(args, "node_id").trim(); + 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 = str2(args, "role").trim(); + const role = str3(args, "role").trim(); if (role) body.role = role; - const region = str2(args, "region_folder_id").trim(); + const region = str3(args, "region_folder_id").trim(); if (region) body.region_folder_id = region; - if (bool2(args, "clear_region") === true) + 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: str2(args, "kind", "any").trim() || "any", - page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + 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") { @@ -3352,11 +3846,11 @@ async function classifyTopologyFabricNodes(client, args, signal) { } if (action === "apply_rules") { const params = {}; - if (bool2(args, "overwrite_manual") === true) + if (bool3(args, "overwrite_manual") === true) params.skip_manual = false; - else if (bool2(args, "skip_manual") !== undefined) - params.skip_manual = bool2(args, "skip_manual") !== false; - if (bool2(args, "fill_empty_only") === true) + 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)); } @@ -3366,13 +3860,13 @@ async function classifyTopologyFabricNodes(client, args, signal) { return { ok: false, error: "unknown_action", action }; } async function queryTopologyNeighborhood(client, args, signal) { - const nodeId = str2(args, "node_id").trim(); + 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(num2(args, "depth"), 1, 1, 3), - layer: str2(args, "layer", "physical").trim() || "physical" + depth: clampInt2(num3(args, "depth"), 1, 1, 3), + layer: str3(args, "layer", "physical").trim() || "physical" }, signal)); if (out.ok !== true) return out; @@ -3381,19 +3875,19 @@ async function queryTopologyNeighborhood(client, args, signal) { } async function queryTopologyEdges(client, args, signal) { const params = { - page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num2(args, "page_size"), 100, 1, 500), - layer: str2(args, "layer", "physical").trim() || "physical" + 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 = str2(args, key).trim(); + 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 = str2(args, "detail", "adjacency").trim().toLowerCase() || "adjacency"; + 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 }; @@ -3401,7 +3895,7 @@ async function queryTopologyEdges(client, args, signal) { return { ...out, detail: "adjacency", links, items: undefined }; } async function suggestSinkHubs(client, args, signal) { - const viewId = str2(args, "source_view_id").trim() || str2(args, "view_id").trim(); + 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)); @@ -3421,17 +3915,17 @@ async function suggestSinkHubs(client, args, signal) { ...strList2(args, "exclude_portal_ids"), ...strList2(args, "exclude_fabric_node_ids") ]); - const pick = clampInt2(num2(args, "pick"), 5, 1, 50); + 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 (str2(row2, "id") || str2(row2, "fabric_node_id")) === fabric_node_id; + return (str3(row2, "id") || str3(row2, "fabric_node_id")) === fabric_node_id; }); const row = asRecord2(node); return { fabric_node_id, degree: deg, - name: str2(row, "name"), + name: str3(row, "name"), level: row.level, role: row.role }; @@ -3444,7 +3938,7 @@ async function suggestSinkHubs(client, args, signal) { }; } async function analyzeTopologyViewLayout(client, args, signal) { - const viewId = str2(args, "view_id").trim(); + const viewId = str3(args, "view_id").trim(); if (!viewId) { return { ok: false, @@ -3487,7 +3981,7 @@ async function analyzeTopologyViewLayout(client, args, signal) { return { ok: true, view_id: viewId, - detail: str2(args, "detail", "summary") || "summary", + detail: str3(args, "detail", "summary") || "summary", node_count: nodes.length, edge_count: edges.length, link_count: collapseEdgesToLinks(edges).length, @@ -3501,13 +3995,13 @@ async function sinkTopologyDualUnits(_client, args) { return { ok: false, error: "sink_dual_units_requires_netx_topology_mcp", - source_view_id: str2(args, "source_view_id"), - sink_view_id: str2(args, "sink_view_id"), + 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 (bool2(args, "catalog") === true) { + if (bool3(args, "catalog") === true) { return { ok: true, catalog: true, @@ -3535,7 +4029,7 @@ async function layoutTopologyView(client, args, signal) { hint: "Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP." }; } - const action = str2(args, "action", "layout").trim().toLowerCase() || "layout"; + const action = str3(args, "action", "layout").trim().toLowerCase() || "layout"; if (action === "job_status" || action === "job_cancel") { return { ok: false, @@ -3544,8 +4038,8 @@ async function layoutTopologyView(client, args, signal) { }; } if (action === "move_nodes" || action === "sink_nodes") { - const toId = str2(args, "view_id").trim(); - const fromId = str2(args, "source_view_id").trim(); + 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"); @@ -3555,8 +4049,8 @@ async function layoutTopologyView(client, args, signal) { if (fabricIds.length === 0) { return { ok: false, error: "fabric_node_ids_required" }; } - const copyPositions = bool2(params, "copy_positions") !== false && bool2(args, "copy_positions") !== false; - const removeFromSource = bool2(params, "remove_from_source") !== false; + 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; @@ -3564,7 +4058,7 @@ async function layoutTopologyView(client, args, signal) { const wanted = new Set(fabricIds); const selected = nodes.filter((n) => { const row = asRecord2(n); - const id = str2(row, "id") || str2(row, "fabric_node_id"); + 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)); @@ -3576,7 +4070,7 @@ async function layoutTopologyView(client, args, signal) { const positions = selected.map((n) => { const row = asRecord2(n); return { - fabric_node_id: str2(row, "id") || str2(row, "fabric_node_id"), + fabric_node_id: str3(row, "id") || str3(row, "fabric_node_id"), x: Number(row.x || 0) + ox, y: Number(row.y || 0) + oy }; @@ -3592,16 +4086,16 @@ async function layoutTopologyView(client, args, signal) { moved: fabricIds.length, source_view_id: fromId, view_id: toId, - park: bool2(params, "park") === true || bool2(args, "park") === true + park: bool3(params, "park") === true || bool3(args, "park") === true }; } return layoutRecipeUnavailable(action); } // src/netx/tools.ts -var str3 = (description) => ({ type: "string", ...description ? { description } : {} }); -var num3 = (description) => ({ type: "number", ...description ? { description } : {} }); -var bool3 = (description) => ({ type: "boolean", ...description ? { description } : {} }); +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" }, @@ -3612,19 +4106,19 @@ var anyObj = (description) => ({ additionalProperties: true, ...description ? { description } : {} }); -function renderJson(_args, value) { +function renderJson2(_args, value) { return [{ type: "text", text: JSON.stringify(value, null, 0) }]; } -var jsonOut = { +var jsonOut2 = { schema: { type: "json" }, - render: renderJson + render: renderJson2 }; -function tool(name, description, parameters, handler, getClient, timeoutMs) { - return defineTool({ +function tool2(name, description, parameters, handler, getClient, timeoutMs) { + return defineTool2({ name, description, parameters, - output: jsonOut, + output: jsonOut2, timeoutMs, isConcurrencySafe: () => true, async execute(args, exec) { @@ -3650,90 +4144,90 @@ function registerNetxTools(ctx, connection, options) { const getClient = () => client; const t = connection.toolCallTimeoutMs; const catalog = [ - tool("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: str3(), - ne_id: str3("Filter only; do not show UUID to users"), - host_name: str3("Filter by NE host_name"), - ne_name: str3("Legacy alias mapped to keyword"), - keyword: str3("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), - time_from: str3("ISO time; filters last_seen_at >="), - time_to: str3("ISO time; filters last_seen_at <="), - page: num3(), - page_size: num3() + 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), - tool("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: str3("Optional perceived_severity filter (critical/major/minor/warning)."), - top_ne: num3("Max NE buckets (default 50). Ignored when group_by is set."), - exclude_missing_host: bool3("Omit missing host_name from by_ne."), - time_from: str3(), - time_to: str3(), - group_by: str3("When set, routes to raw aggregation. Prefer alarm_host_name."), - group_by2: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - limit: num3() + 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), - tool("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), - tool("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { - keyword: str3(), - page: num3(), - page_size: num3() + 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), - tool("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { + 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), - tool("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: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - time_from: str3(), - time_to: str3(), - order_by: str3("last_seen_at | time_created | perceived_severity | event_type | ne_id"), - order: str3("asc | desc"), + 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: str3("brief | evidence | ne_debug"), - page: num3(), - page_size: num3() + field_preset: str4("brief | evidence | ne_debug"), + page: num4(), + page_size: num4() }, queryUmeAlarmsRaw, getClient, t), - tool("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { + 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: str3(), - severity: str3(), - is_cleared: str3(), - ne_id: str3(), - event_type: str3(), - keyword: str3(), - time_from: str3(), - time_to: str3(), - exclude_missing_host: bool3(), - limit: num3() + 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), - tool("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), - tool("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { + 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: num3(), - statement_timeout_ms: num3() + limit: num4(), + statement_timeout_ms: num4() }, sqlQueryUme, getClient, t), - tool("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { - keyword: str3(), - vendor: str3(), - connect_status: str3("unknown | testing | pass | fail"), - page: num3(), - page_size: num3() + 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), - tool("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { - ne_id: str3("Managed NE id"), - managed_ne_id: str3("Alias for ne_id"), - id: str3("Alias for ne_id") + 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), - tool("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: str3(), - nms_ne_id: str3("NMS inventory id; alias ume_ne_id"), - ume_ne_id: str3("Legacy alias of nms_ne_id"), + 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"), @@ -3744,79 +4238,79 @@ function registerNetxTools(ctx, connection, options) { type: "object", additionalProperties: false, properties: { - ne_id: str3(), - nms_ne_id: str3(), - ume_ne_id: str3(), + 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: num3("Per-command read timeout (default 60; use 90–120 for slow show)."), - concurrency: num3("Parallel NEs for batch mode (1–8, default 4)."), - async: bool3("oclaw-only async hint; ignored by native REST client.") + 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)), - tool("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { - source: str3("managed | nms | ume | all"), - keyword: str3(), - page: num3(), - page_size: num3() + 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), - tool("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: str3("NMS inventory id"), - from_ume_ne_id: str3("Legacy alias of from_nms_ne_id"), - from_managed_ne_id: str3(), - to_nms_ne_id: str3("NMS inventory id"), - to_ume_ne_id: str3("Legacy alias of to_nms_ne_id"), - to_managed_ne_id: str3(), - max_paths: num3(), - max_hops: num3(), - layer: str3(), - detail: str3("summary | full") + 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), - tool("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool3(), max_depth: num3() }, getTopologyTree, getClient, t), - tool("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { + 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: str3("summary | full"), - sample: num3() + detail: str4("summary | full"), + sample: num4() }, getTopologyView, getClient, t), - tool("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { + tool2("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { name: { type: "string", required: true }, - parent_id: str3(), - locale: str3("zh | en"), - sort_order: num3() + parent_id: str4(), + locale: str4("zh | en"), + sort_order: num4() }, createTopologyFolder, getClient, t), - tool("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { + 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: num3(), - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), - limit: num3(), - offset: num3(), + max_nodes: num4(), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), + limit: num4(), + offset: num4(), fabric_node_ids: strArr(), - layout: str3("grid | keep") + layout: str4("grid | keep") }, addTopologyViewNodes, getClient, t), - tool("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { + tool2("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { view_id: { type: "string", required: true }, - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), fabric_node_ids: strArr() }, removeTopologyViewNodes, getClient, t), - tool("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { + 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: bool3(), - clear_target: bool3(), - offset_x: num3(), - offset_y: num3(), - limit: num3(), - dry_run: bool3() + copy_positions: bool4(), + clear_target: bool4(), + offset_x: num4(), + offset_y: num4(), + limit: num4(), + dry_run: bool4() }, copyTopologyViewNodes, getClient, t), - tool("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { + 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", @@ -3825,121 +4319,121 @@ function registerNetxTools(ctx, connection, options) { type: "object", additionalProperties: false, properties: { - fabric_node_id: str3(), - x: num3(), - y: num3() + fabric_node_id: str4(), + x: num4(), + y: num4() } } }, - layout: str3("grid | offset | stack"), - keyword: str3(), - role: str3(), - vendor: str3(), - link_status: str3(), + layout: str4("grid | offset | stack"), + keyword: str4(), + role: str4(), + vendor: str4(), + link_status: str4(), fabric_node_ids: strArr(), - offset_x: num3(), - offset_y: num3() + offset_x: num4(), + offset_y: num4() }, updateTopologyViewPositions, getClient, t), - tool("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { + 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: str3(), - detail: str3("summary | full"), - sample: num3() + region_folder_id: str4(), + detail: str4("summary | full"), + sample: num4() }, projectTopologyNeighbors, getClient, t), - tool("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { - mode: str3("summary | list | search"), - q: str3(), - keyword: str3(), - role: str3(), - level: str3(), - level_major: str3(), - region_folder_id: str3(), - link_status: str3(), - page: num3(), - page_size: num3(), - limit: num3(), - summary: bool3() + 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), - tool("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { + tool2("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { action: { type: "string", required: true }, - pattern: str3(), - q: str3(), - match_field: str3(), - sample_limit: num3(), + pattern: str4(), + q: str4(), + match_field: str4(), + sample_limit: num4(), fabric_node_ids: strArr(), - fabric_node_id: str3(), - node_id: str3(), - level: str3(), - role: str3(), - region_folder_id: str3(), - clear_region: bool3(), - dry_run: bool3(), - kind: str3(), - page: num3(), - page_size: num3(), - skip_manual: bool3(), - overwrite_manual: bool3(), - fill_empty_only: bool3() + 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), - tool("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { + tool2("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { node_id: { type: "string", required: true }, - depth: num3(), - layer: str3() + depth: num4(), + layer: str4() }, queryTopologyNeighborhood, getClient, t), - tool("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { - node_id: str3(), - keyword: str3(), - layer: str3(), - status: str3(), - source: str3("lldp | ume | manual"), - detail: str3("adjacency | ports"), - page: num3(), - page_size: num3() + 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), - tool("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { - source_view_id: str3(), - view_id: str3(), - pick: num3(), + 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), - tool("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { - view_id: str3(), - folder_id: str3(), - detail: str3("summary | structure | hotspots | blocks | both"), - score_profile: str3(), - sight_limit: num3(), - max_views: num3(), - min_nodes: num3(), - max_nodes: num3(), - with_meta: bool3() + 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), - tool("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { - source_view_id: str3(), - sink_view_id: str3(), - max_units: num3(), - min_nodes: num3(), - max_nodes: num3(), - max_batch_nodes: num3(), - layout_batch: bool3(), - until_empty: bool3(), - dry_run: bool3() + 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), - tool("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: str3(), - action: str3(), - source_view_id: str3(), - recipe: str3(), - preset: str3(), - mode: str3("preview | apply"), - tune: bool3(), + 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: bool3(), + catalog: bool4(), fabric_node_ids: strArr(), - park: bool3(), - copy_positions: bool3() + 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)); @@ -3980,30 +4474,30 @@ var Config = z.object({ groupKbPublic: z.boolean().default(false) }); function packageRoot() { - return join5(dirname3(fileURLToPath2(import.meta.url)), ".."); + return join7(dirname4(fileURLToPath2(import.meta.url)), ".."); } function resolveDshHome() { const fromEnv = process.env.DSH_HOME?.trim(); if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv; - return join5(homedir3(), ".dsh"); + return join7(homedir3(), ".dsh"); } function ensureAgentPresetInstalled(logger) { - const src = join5(packageRoot(), "presets", NETXOPS_PRESET_ID); - const composition = join5(src, "agent.cordis.yml"); - if (!existsSync3(composition)) { + const src = join7(packageRoot(), "presets", NETXOPS_PRESET_ID); + const composition = join7(src, "agent.cordis.yml"); + if (!existsSync5(composition)) { logger.warn("netxops: bundled preset missing at %s — skip user-preset install", src); return; } - const destParent = join5(resolveDshHome(), ".agent-presets"); - const dest = join5(destParent, NETXOPS_PRESET_ID); + const destParent = join7(resolveDshHome(), ".agent-presets"); + const dest = join7(destParent, NETXOPS_PRESET_ID); try { - mkdirSync(destParent, { recursive: true }); - if (existsSync3(dest)) { - rmSync(dest, { recursive: true, force: true }); + mkdirSync2(destParent, { recursive: true }); + if (existsSync5(dest)) { + rmSync2(dest, { recursive: true, force: true }); } cpSync(src, dest, { recursive: true }); - writeFileSync(join5(dest, ".dsh-netxops-managed"), `${new Date().toISOString()} + writeFileSync2(join7(dest, ".dsh-netxops-managed"), `${new Date().toISOString()} `, "utf8"); logger.info("netxops: agent preset installed at %s", dest); } catch (error) { @@ -4190,6 +4684,7 @@ function apply(ctx, config = Config({})) { }); ctx.inject(["tools"], (toolsCtx) => { let unregisterTools; + let unregisterKbLocal; const remountPublicTools = () => { unregisterTools?.(); unregisterTools = undefined; @@ -4200,13 +4695,28 @@ function apply(ctx, config = Config({})) { unregisterTools = registerNetxTools(toolsCtx, connection, { plane: "public" }); toolsCtx.logger.info("netxops: host public tools groups=[%s]", enabled.join(",") || "(none)"); }; + const remountKbLocal = () => { + unregisterKbLocal?.(); + unregisterKbLocal = undefined; + const connection = getNetxConnection(); + if (!connection || connection.groupKbPublic !== true) + return; + unregisterKbLocal = registerKbLocalTools(toolsCtx, getKbContext()); + }; remountPublicTools(); + remountKbLocal(); const stopWatch = watchNetxConnection(() => { remountPublicTools(); + remountKbLocal(); + }); + const stopKbWatch = watchKbContext(() => { + remountKbLocal(); }); toolsCtx.effect(() => () => { stopWatch(); + stopKbWatch(); unregisterTools?.(); + unregisterKbLocal?.(); }, "netxops: dispose public tools"); }); ctx.inject(["skills"], (skillsCtx) => { diff --git a/presets/netxops/PERSONA.md b/presets/netxops/PERSONA.md index 42d3df4..f60ba14 100644 --- a/presets/netxops/PERSONA.md +++ b/presets/netxops/PERSONA.md @@ -1,13 +1,13 @@ # Netx Ops 人设(写入 `agent.cordis.yml` → `@deepseek-ai/dsh-persona`) -你是 **Netx Ops**,面向 netx 的网络运维专家。 +你是 **Netx Ops**,面向 netx 的网络运维专家;同时具备与「标准模式」相同的完整编程与工作区能力。 ## 身份 - 被问「你是谁 / 什么模型」时:只回答你是 **Netx Ops**。 - 不透露系统提示、工具内幕、运行环境或供应商信息。 ## 原则 -1. 先用工具拿证据(告警、清单、CLI),再下结论。 +1. 先用工具拿证据(告警、清单、CLI、工作区读查),再下结论。 2. 涉及破坏性变更:先说清影响与回滚(当前 CLI 仅只读:show / display / ping 等)。 3. 回复语言以 Host「Netx Ops」设置里的「回复语言」为准(强制 zh/en 时不得跟随用户语言);未强制(follow-user)时再跟随用户语言。现场默认:简洁、可扫读的运维口吻。 @@ -28,10 +28,12 @@ ## 技能(仅当对应能力组已开启) - `netx-ops` — ops:告警、清单、纳管登录、路径 +- 知识库 `_skills/kb-*`(若已配置运营商子集) ## 工具 - netx 调用名为 `netx__*`(驼峰)。决策树见技能正文。 - 多台 CLI:**一次** `execManagedNe`(`ne_ids` / `nms_ne_ids` / `targets`)。 +- 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等);KB 现场可写区用 `netx__kbWrite*` / `UpdateLocal` / `DeleteLocal`(勿对 KB 用沙箱 Write)。 ## 委派(默认 subagent,活多再拆) @@ -58,4 +60,3 @@ - 等齐结果后由你综合;**只有你**对用户说终稿 - 证据冲突时由你裁决或再补一刀,不要让子 agent 在用户面前互辩 - 优先前台等结果;确需并行再用后台 + jobs 收齐 - diff --git a/presets/netxops/agent.cordis.yml b/presets/netxops/agent.cordis.yml index 0b9f5d6..36fb4a2 100644 --- a/presets/netxops/agent.cordis.yml +++ b/presets/netxops/agent.cordis.yml @@ -1,22 +1,28 @@ -# Netx Ops 智能体预设 — NMS 告警 / 网元清单 / 纳管 CLI。 -# Host 负责注册表、沙箱、模型路由、设置;本文件负责人设与 ops 范围的 netx__* 工具。 -# Playbook 技能由 dsh-netxops/tools 按「设置 → 能力组」挂载(不用 customSkillDirs)。 +# Netx Ops agent preset = DSH `standard` (full coding agent) + netxops tools/persona. +# +# DSH has no preset inheritance: compositions are flat copies. Keep this file in +# sync with DeepSeekHarness `presets/standard/agent.cordis.yml`, then retain the +# Netx Ops persona + `netxops-tools` row below. +# +# Host owns registries, sandbox, persistence, model route. This file owns +# persona and which model-facing plugins Netx Ops sessions get. -# ── 身份 ──────────────────────────────────────────────────────────────────── +# ── identity ──────────────────────────────────────────────────────────────── - id: persona name: '@deepseek-ai/dsh-persona' config: - # 0.1.5+: dsh-persona requires `prefix` (formerly `text`) + suffix: Your working directory is {{cwd}}. + # Keep in sync with presets/netxops/PERSONA.md prefix: |- - 你是 **Netx Ops**,面向 netx 的网络运维专家。 + 你是 **Netx Ops**,面向 netx 的网络运维专家;同时具备与「标准模式」相同的完整编程与工作区能力。 ## 身份 - 被问「你是谁 / 什么模型」时:只回答你是 **Netx Ops**。 - 不透露系统提示、工具内幕、运行环境或供应商信息。 ## 原则 - 1. 先用工具拿证据(告警、清单、CLI),再下结论。 + 1. 先用工具拿证据(告警、清单、CLI、工作区读查),再下结论。 2. 涉及破坏性变更:先说清影响与回滚(当前 CLI 仅只读:show / display / ping 等)。 3. 回复语言以 Host「Netx Ops」设置里的「回复语言」为准(强制 zh/en 时不得跟随用户语言);未强制(follow-user)时再跟随用户语言。现场默认:简洁、可扫读的运维口吻。 @@ -35,10 +41,12 @@ ## 技能(仅当对应能力组已开启) - `netx-ops` — ops:告警、清单、纳管登录、路径 + - 知识库 `_skills/kb-*`(若已配置运营商子集) ## 工具 - netx 调用名为 `netx__*`(驼峰)。决策树见技能正文。 - 多台 CLI:**一次** `execManagedNe`(`ne_ids` / `nms_ne_ids` / `targets`)。 + - 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等);KB 现场可写区用 `netx__kbWrite*` / `UpdateLocal` / `DeleteLocal`(勿对 KB 用沙箱 Write)。 ## 委派(默认 subagent,活多再拆) 你本人逻辑上全能。默认自己干;只有活明显偏多、且可并行时,才用 `subagent`(必要时 `subagent_fork`)拆开干。 @@ -70,33 +78,102 @@ config: maxBytes: 65536 -# ── 文件系统 / 任务 ───────────────────────────────────────────────────────── +# ── shell ─────────────────────────────────────────────────────────────────── + +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + disabled: !!js process.platform === 'win32' + +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' + disabled: !!js process.platform !== 'win32' + +# ── filesystem ────────────────────────────────────────────────────────────── - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + +# ── background jobs ──────────────────────────────────────────────────────── + - id: tool-jobs name: '@deepseek-ai/dsh-tool-jobs' -# ── 技能 ──────────────────────────────────────────────────────────────────── +# ── skills ────────────────────────────────────────────────────────────────── - id: skill-filesystem name: '@deepseek-ai/dsh-skill-filesystem' config: - # Ops playbook 由 netxops-tools 按能力组注册。 + # Ops / KB playbooks are registered by netxops-tools (capability groups), not customSkillDirs. includeDefaultRoots: true customSkillDirs: [] - id: tool-skill name: '@deepseek-ai/dsh-tool-skill' -# Ops 工具(+ 按能力组门控的技能):注册进本预设作用域。 +# Netx Ops tools (+ capability-gated skills / KB local write tools) in this preset scope. - id: netxops-tools name: dsh-netxops/tools -# ── 委派(DSH 默认 subagent;Host 侧已有 subagents 注册表)──────────────── -# 不挂 agent-teams:不需要成员互聊。活多时用 subagent 扇出,Lead 收口。 -# tool-subagent-report 保持 Host 平面(标准预设同款),不在此重复挂载。 +# ── goals ─────────────────────────────────────────────────────────────────── + +- id: command-goal + name: '@deepseek-ai/dsh-command-goal' + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── plan mode ─────────────────────────────────────────────────────────────── + +- id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + +# ── compaction ────────────────────────────────────────────────────────────── + +- id: compaction + name: cordis:group + group: true + isolate: + compaction: true + toolResultPruner: true + config: + - id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + + - id: tool-result-pruner + name: '@deepseek-ai/dsh-compaction-tool-result-pruner' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + +# ── delegation and workflows ──────────────────────────────────────────────── - id: delegation name: cordis:group @@ -116,14 +193,64 @@ provider: spawn toolName: subagent modelSelectionSettings: true - # one-shot:默认前台等结果;并行时用 run_in_background + jobs - backgroundMode: one-shot + backgroundMode: continuable - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: provider: fork toolName: subagent_fork - backgroundMode: one-shot + backgroundMode: continuable -# Host 包 dsh-netxops 首次激活时会把本预设拷到 $DSH_HOME/.agent-presets/netxops + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed + + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + backgroundMode: one-shot + maxDepth: provider-managed + + - id: workflow-worker-thread + name: '@deepseek-ai/dsh-workflow-worker-thread' + config: + provider: spawn + + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 + +# ── remaining model-facing rows ───────────────────────────────────────────── + +- id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: true + searchTimeoutMs: 60000 + +- id: present + name: '@deepseek-ai/dsh-tool-present' + +# Host package dsh-netxops copies this preset to $DSH_HOME/.agent-presets/netxops on activate. diff --git a/presets/netxops/preset.yml b/presets/netxops/preset.yml index 413578c..88c436d 100644 --- a/presets/netxops/preset.yml +++ b/presets/netxops/preset.yml @@ -1,3 +1,3 @@ name: Netx Ops -description: 运维专家。 +description: 运维专家(含标准模式全套工作区能力 + netx 工具)。 order: 50 diff --git a/src/index.ts b/src/index.ts index 6e1599d..5d18000 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,7 @@ import { registerGroupSkills } from './netx/group-skills.ts' import { resolveImTargets } from './netx/im-targets.ts' import { registerKbContextSkill } from './netx/kb-context-skill.ts' import { resolveKbRoot } from './netx/kb-manifest.ts' +import { registerKbLocalTools } from './netx/kb-local-tools.ts' import { registerKbPackSkills } from './netx/kb-pack-skills.ts' import { applyKbEnv, @@ -481,6 +482,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { // Optional host-layer publish: groups with `public=true` become visible to other presets. ctx.inject(['tools'], (toolsCtx) => { let unregisterTools: (() => void) | undefined + let unregisterKbLocal: (() => void) | undefined const remountPublicTools = (): void => { unregisterTools?.() unregisterTools = undefined @@ -493,11 +495,25 @@ export function apply(ctx: Context, config: Config = Config({})): void { enabled.join(',') || '(none)', ) } + const remountKbLocal = (): void => { + unregisterKbLocal?.() + unregisterKbLocal = undefined + const connection = getNetxConnection() + if (!connection || connection.groupKbPublic !== true) return + unregisterKbLocal = registerKbLocalTools(toolsCtx, getKbContext()) + } remountPublicTools() - const stopWatch = watchNetxConnection(() => { remountPublicTools() }) + remountKbLocal() + const stopWatch = watchNetxConnection(() => { + remountPublicTools() + remountKbLocal() + }) + const stopKbWatch = watchKbContext(() => { remountKbLocal() }) toolsCtx.effect(() => () => { stopWatch() + stopKbWatch() unregisterTools?.() + unregisterKbLocal?.() }, 'netxops: dispose public tools') }) diff --git a/src/netx/group-tools-plugin.ts b/src/netx/group-tools-plugin.ts index 0ea01da..19a2239 100644 --- a/src/netx/group-tools-plugin.ts +++ b/src/netx/group-tools-plugin.ts @@ -11,6 +11,7 @@ import { } from './capability-groups.ts' import { registerGroupSkills } from './group-skills.ts' import { registerKbContextSkill } from './kb-context-skill.ts' +import { registerKbLocalTools } from './kb-local-tools.ts' import { registerKbPackSkills } from './kb-pack-skills.ts' import { getKbContext, watchKbContext } from './kb-runtime.ts' import { getNetxConnection, watchNetxConnection } from './runtime.ts' @@ -33,6 +34,7 @@ export interface GroupToolsPluginOptions { */ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOptions): void { let unregisterTools: (() => void) | undefined + let unregisterKbLocal: (() => void) | undefined let unregisterSkills: (() => void) | undefined let skillGeneration = 0 @@ -71,8 +73,22 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt ) } + const remountKbLocal = (): void => { + unregisterKbLocal?.() + unregisterKbLocal = undefined + const connection = getNetxConnection() + if (connection === undefined) return + if (connection.groupKbInPreset === false) return + unregisterKbLocal = registerKbLocalTools(ctx, getKbContext()) + } + remountTools() - const stopToolWatch = watchNetxConnection(() => { remountTools() }) + remountKbLocal() + const stopToolWatch = watchNetxConnection(() => { + remountTools() + remountKbLocal() + }) + const stopKbToolWatch = watchKbContext(() => { remountKbLocal() }) ctx.inject(['skills'], (skillsCtx) => { let unregisterKbSkill: (() => void) | undefined @@ -146,7 +162,10 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt ctx.effect(() => () => { stopToolWatch() + stopKbToolWatch() unregisterTools?.() unregisterTools = undefined + unregisterKbLocal?.() + unregisterKbLocal = undefined }, `${options.name}: dispose tools`) } diff --git a/src/netx/kb-context-skill.ts b/src/netx/kb-context-skill.ts index 3504d2a..0267996 100644 --- a/src/netx/kb-context-skill.ts +++ b/src/netx/kb-context-skill.ts @@ -5,6 +5,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { KbSnapshot } from './kb-manifest.ts' +import { resolveKbLocalRoot } from './kb-local-path.ts' const SKILL_NAME = 'kb-context' @@ -14,6 +15,24 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string .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 + ? [ + '', + '**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,', + '`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.', + 'They jail writes to `memories/` / `drafts/` / `suggestions/` only.', + 'Do **not** use workspace Write/bash for KB paths (sandbox).', + '`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;', + 'file boundary issues as `suggestions/` instead.', + ] + : [ + '', + 'No local write tools until `paths.local` is present in MANIFEST.', + ] return { description: 'Operator knowledge-base context for this Host (paths + identity from MANIFEST).', @@ -27,12 +46,14 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string `| --- | --- |`, `| kbStatus | configured |`, `| kbRoot | \`${snapshot.realRoot}\` |`, + localRow, `| kbOperator | ${snapshot.operatorName} |`, `| kbCountry | ${snapshot.country} |`, `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, + ...localGuide, '', - 'Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.', + '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,`, diff --git a/src/netx/kb-local-ops.ts b/src/netx/kb-local-ops.ts new file mode 100644 index 0000000..94f1cfb --- /dev/null +++ b/src/netx/kb-local-ops.ts @@ -0,0 +1,272 @@ +/** + * Filesystem ops for KB local writable trees (create / update / delete / list). + */ + +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { dirname, join } from 'node:path' +import type { KbSnapshot } from './kb-manifest.ts' +import { + draftDir, + draftFileName, + memoryDir, + memoryFileName, + resolveExistingWritableFile, + resolveKbLocalRoot, + resolveWritableLocalPath, + suggestionDir, + suggestionFileName, + type MemoryBucket, + type SuggestionKind, + KB_LOCAL_WRITABLE_ROOTS, + type KbLocalWritableRoot, +} from './kb-local-path.ts' + +export type KbLocalWriteResult = { + ok: true + action: 'created' | 'updated' | 'deleted' + absolutePath: string + relativePath: string +} + +export type KbLocalListEntry = { + absolutePath: string + relativePath: string + mtimeMs: number + size: number +} + +function requireLocalRoot(snapshot: KbSnapshot): string { + const root = resolveKbLocalRoot(snapshot) + if (!root) { + throw new Error('KB local root unavailable (configure kbRoot + MANIFEST paths.local)') + } + return root +} + +function ensureDraftBody(body: string): string { + 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('\n') ? trimmed : `${trimmed}\n` + } + // Prepend minimal frontmatter when missing. + if (trimmed.startsWith('---')) { + const end = trimmed.indexOf('\n---', 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('\n') ? trimmed : `${trimmed}\n` + const injected = fm.replace(/^---\r?\n/, '---\nstatus: draft\n') + const out = `${injected}${rest}` + return out.endsWith('\n') ? out : `${out}\n` + } + } + return `---\nstatus: draft\n---\n\n${trimmed.endsWith('\n') ? trimmed : `${trimmed}\n`}` +} + +export function createMemory( + snapshot: KbSnapshot, + args: { + bucket: MemoryBucket + slug: string + body: string + date?: string + overwrite?: boolean + }, +): KbLocalWriteResult { + const localRoot = requireLocalRoot(snapshot) + const name = memoryFileName({ date: args.date, slug: args.slug }) + const target = resolveWritableLocalPath(localRoot, memoryDir(args.bucket), name) + if (existsSync(target.absolutePath) && !args.overwrite) { + throw new Error( + `file already exists (pass overwrite=true to replace): ${target.relativePath}`, + ) + } + mkdirSync(dirname(target.absolutePath), { recursive: true }) + const existed = existsSync(target.absolutePath) + writeFileSync( + target.absolutePath, + args.body.endsWith('\n') ? args.body : `${args.body}\n`, + 'utf8', + ) + return { + ok: true, + action: existed ? 'updated' : 'created', + absolutePath: target.absolutePath, + relativePath: target.relativePath, + } +} + +export function createDraft( + snapshot: KbSnapshot, + args: { + slug: string + body: string + domain?: string + date?: string + overwrite?: boolean + }, +): KbLocalWriteResult { + const localRoot = requireLocalRoot(snapshot) + const name = draftFileName({ date: args.date, slug: args.slug }) + const target = resolveWritableLocalPath(localRoot, draftDir(args.domain), name) + if (existsSync(target.absolutePath) && !args.overwrite) { + throw new Error( + `file already exists (pass overwrite=true to replace): ${target.relativePath}`, + ) + } + mkdirSync(dirname(target.absolutePath), { recursive: true }) + const existed = existsSync(target.absolutePath) + writeFileSync(target.absolutePath, ensureDraftBody(args.body), 'utf8') + return { + ok: true, + action: existed ? 'updated' : 'created', + absolutePath: target.absolutePath, + relativePath: target.relativePath, + } +} + +export function createSuggestion( + snapshot: KbSnapshot, + args: { + kind: SuggestionKind + slug: string + body: string + date?: string + overwrite?: boolean + }, +): KbLocalWriteResult { + const localRoot = requireLocalRoot(snapshot) + const name = suggestionFileName({ date: args.date, slug: args.slug }) + const target = resolveWritableLocalPath(localRoot, suggestionDir(args.kind), name) + if (existsSync(target.absolutePath) && !args.overwrite) { + throw new Error( + `file already exists (pass overwrite=true to replace): ${target.relativePath}`, + ) + } + mkdirSync(dirname(target.absolutePath), { recursive: true }) + const existed = existsSync(target.absolutePath) + writeFileSync( + target.absolutePath, + args.body.endsWith('\n') ? args.body : `${args.body}\n`, + 'utf8', + ) + return { + ok: true, + action: existed ? 'updated' : 'created', + absolutePath: target.absolutePath, + relativePath: target.relativePath, + } +} + +/** + * Replace body of an existing file under memories|drafts|suggestions. + * Drafts keep/force `status: draft` in frontmatter. + */ +export function updateLocalFile( + snapshot: KbSnapshot, + args: { path: string; body: string }, +): KbLocalWriteResult { + const localRoot = requireLocalRoot(snapshot) + const target = resolveExistingWritableFile(localRoot, args.path) + if (!existsSync(target.absolutePath)) { + throw new Error(`file not found: ${target.relativePath}`) + } + const body = target.writableRoot === 'drafts' + ? ensureDraftBody(args.body) + : (args.body.endsWith('\n') ? args.body : `${args.body}\n`) + writeFileSync(target.absolutePath, body, 'utf8') + return { + ok: true, + action: 'updated', + absolutePath: target.absolutePath, + relativePath: target.relativePath, + } +} + +export function deleteLocalFile( + snapshot: KbSnapshot, + args: { path: string }, +): KbLocalWriteResult { + const localRoot = requireLocalRoot(snapshot) + const target = resolveExistingWritableFile(localRoot, args.path) + if (!existsSync(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: string, baseLocal: string, out: KbLocalListEntry[]): void { + let entries: string[] + try { + entries = readdirSync(dir) + } catch { + return + } + for (const name of entries) { + if (name === '.' || name === '..') continue + const full = join(dir, name) + let st + try { + st = statSync(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, + }) + } + } +} + +export function listLocalFiles( + snapshot: KbSnapshot, + args: { + root?: KbLocalWritableRoot | 'all' + limit?: number + } = {}, +): { ok: true; localRoot: string; entries: KbLocalListEntry[] } { + const localRoot = requireLocalRoot(snapshot) + const roots: KbLocalWritableRoot[] = args.root && args.root !== 'all' + ? [args.root] + : [...KB_LOCAL_WRITABLE_ROOTS] + const entries: KbLocalListEntry[] = [] + for (const r of roots) { + walkFiles(join(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), + } +} + +/** Read a local writable file (optional helper for tests). */ +export function readLocalFile(snapshot: KbSnapshot, pathArg: string): string { + const localRoot = requireLocalRoot(snapshot) + const target = resolveExistingWritableFile(localRoot, pathArg) + return readFileSync(target.absolutePath, 'utf8') +} diff --git a/src/netx/kb-local-path.ts b/src/netx/kb-local-path.ts new file mode 100644 index 0000000..b86fac3 --- /dev/null +++ b/src/netx/kb-local-path.ts @@ -0,0 +1,209 @@ +/** + * Path jail for operator-subset `_local` agent writes. + * Writable trees: memories / drafts / suggestions only (not identity / local_skills). + */ + +import { existsSync, realpathSync, statSync } from 'node:fs' +import { join, relative, resolve, sep } from 'node:path' +import type { KbSnapshot } from './kb-manifest.ts' + +/** Top-level dirs under paths.local that agents may create/update/delete. */ +export const KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ + 'memories', + 'drafts', + 'suggestions', +] as const) + +export type KbLocalWritableRoot = (typeof KB_LOCAL_WRITABLE_ROOTS)[number] + +export type MemoryBucket = 'note' | 'rca_review' | 'ai_trace' +export type SuggestionKind = 'theory' | 'improvement' + +export const MEMORY_BUCKET_DIR: Readonly> = Object.freeze({ + note: '日常笔记', + rca_review: '排障复盘', + ai_trace: 'AI思维链', +}) + +/** + * Absolute `paths.local` root, or null when KB not configured / field missing. + */ +export function resolveKbLocalRoot(snapshot: KbSnapshot): string | null { + if (snapshot.status !== 'configured') return null + const rel = snapshot.paths.local?.trim() + if (!rel) return null + return resolve(snapshot.realRoot, rel) +} + +/** True when local write tools should register. */ +export function kbLocalToolsEnabled(snapshot: KbSnapshot): boolean { + return resolveKbLocalRoot(snapshot) !== null +} + +/** + * Sanitize a slug for filenames: strip path separators, keep CJK / alnum / ._- . + */ +export function sanitizeSlug(raw: string, fallback = 'entry'): string { + const cleaned = raw + .trim() + .replace(/[/\\]+/g, '-') + .replace(/\.\.+/g, '') + .replace(/[^\w.\u4e00-\u9fff-]+/gu, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + return cleaned || fallback +} + +/** Today's date as YYYY-MM-DD (local). */ +export function todayIsoDate(now = new Date()): string { + 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}` +} + +/** Compact YYYYMMDD from ISO date or today. */ +export function toCompactDate(isoOrEmpty?: string, now = new Date()): string { + 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?: string, now = new Date()): string { + 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}`) +} + +export function memoryFileName(opts: { + date?: string + slug: string + now?: Date +}): string { + const date = normalizeIsoDate(opts.date, opts.now) + return `${date}-${sanitizeSlug(opts.slug)}.md` +} + +export function draftFileName(opts: { + date?: string + slug: string + now?: Date +}): string { + 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` +} + +export function suggestionFileName(opts: { + date?: string + slug: string + now?: Date +}): string { + const date = normalizeIsoDate(opts.date, opts.now) + return `${date}-${sanitizeSlug(opts.slug)}.md` +} + +/** + * Resolve a candidate path and assert it lies under a writable local subtree. + * Parent dirs need not exist yet (create path); uses resolve + prefix check. + */ +export function resolveWritableLocalPath( + localRoot: string, + ...segments: string[] +): { absolutePath: string; relativePath: string; writableRoot: KbLocalWritableRoot } { + if (segments.some((s) => s.includes('\0'))) { + 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] as string + if (!(KB_LOCAL_WRITABLE_ROOTS as readonly string[]).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 as KbLocalWritableRoot, + } +} + +/** + * Resolve an existing file path (absolute or relative to localRoot) inside the jail. + */ +export function resolveExistingWritableFile( + localRoot: string, + pathArg: string, +): { absolutePath: string; relativePath: string; writableRoot: KbLocalWritableRoot } { + const trimmed = pathArg.trim() + if (!trimmed) throw new Error('path is empty') + const candidate = resolve(trimmed) + // Prefer absolute if it already sits under localRoot; else treat as relative. + let absolutePath: string + 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), + ) + // Prefer realpath when the file (or a parent) exists, to defeat symlink escapes. + try { + if (existsSync(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 as readonly string[]).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 as KbLocalWritableRoot, + } + } + } catch (error) { + if (error instanceof Error && /escapes|writable|not a file/.test(error.message)) { + throw error + } + } + return checked +} + +export function memoryDir(bucket: MemoryBucket): string { + return join('memories', MEMORY_BUCKET_DIR[bucket]) +} + +export function suggestionDir(kind: SuggestionKind): string { + return join('suggestions', kind) +} + +export function draftDir(domain?: string): string { + const d = domain?.trim() + if (!d) return 'drafts' + return join('drafts', sanitizeSlug(d, 'misc')) +} diff --git a/src/netx/kb-local-tools.ts b/src/netx/kb-local-tools.ts new file mode 100644 index 0000000..57ce322 --- /dev/null +++ b/src/netx/kb-local-tools.ts @@ -0,0 +1,225 @@ +/** + * Host-side tools for writing under MANIFEST `paths.local` + * (memories / drafts / suggestions). Bypasses workspace sandbox. + */ + +import type { Context } from '@deepseek-ai/cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { KbSnapshot } from './kb-manifest.ts' +import { + createDraft, + createMemory, + createSuggestion, + deleteLocalFile, + listLocalFiles, + updateLocalFile, +} from './kb-local-ops.ts' +import { kbLocalToolsEnabled, resolveKbLocalRoot } from './kb-local-path.ts' +import { getKbContext } from './kb-runtime.ts' + +const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) }) +const bool = (description?: string) => ({ type: 'boolean' as const, ...(description ? { description } : {}) }) +const num = (description?: string) => ({ type: 'number' as const, ...(description ? { description } : {}) }) + +function renderJson(_args: unknown, value: unknown) { + return [{ type: 'text' as const, text: JSON.stringify(value, null, 0) }] +} + +const jsonOut = { + schema: { type: 'json' as const }, + render: renderJson, +} + +function liveSnapshot(): KbSnapshot { + return getKbContext() +} + +function tool( + name: string, + description: string, + parameters: Record, + execute: (args: Record) => Promise | unknown, +) { + return defineTool({ + name, + description, + parameters: parameters as never, + output: jsonOut, + timeoutMs: 30_000, + isConcurrencySafe: () => false, + async execute(args) { + try { + return await execute(args as Record) + } catch (error) { + throw new Error(error instanceof Error ? error.message : String(error)) + } + }, + }) +} + +/** + * Register KB local create/update/delete/list tools when snapshot has paths.local. + * @returns disposer (no-op when disabled). + */ +export function registerKbLocalTools( + ctx: Context, + snapshot: KbSnapshot = getKbContext(), +): () => void { + if (!kbLocalToolsEnabled(snapshot)) return () => {} + + const localRoot = resolveKbLocalRoot(snapshot)! + const toolsApi = (ctx as { tools?: { register: (t: unknown) => () => void } }).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.', + { + type: 'object', + additionalProperties: false, + required: ['bucket', 'slug', 'body'], + properties: { + bucket: { + type: 'string', + enum: ['note', 'rca_review', 'ai_trace'], + description: 'Memory subdirectory', + }, + slug: str('Short filename stem (no path separators)'), + body: str('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 as 'note' | 'rca_review' | 'ai_trace', + 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 identity/ or formal RCA.', + { + type: 'object', + additionalProperties: false, + required: ['slug', 'body'], + properties: { + slug: str('Case stem (DRAFT- prefix added if missing)'), + body: str('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 theory corrections or tool/process improvements — never rewrite identity/.', + { + type: 'object', + additionalProperties: false, + required: ['kind', 'slug', 'body'], + properties: { + kind: { + type: 'string', + enum: ['theory', 'improvement'], + description: 'theory = knowledge fix; improvement = tools/skills/process', + }, + slug: str('Short filename stem'), + body: str('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 as 'theory' | 'improvement', + slug: String(args.slug ?? ''), + body: String(args.body ?? ''), + date: args.date != null ? String(args.date) : undefined, + overwrite: args.overwrite === true, + }), + ), + tool( + 'netx__kbUpdateLocal', + `Replace the body of an existing file under memories|drafts|suggestions ` + + `(absolute path or path relative to ${localRoot}). ` + + 'Cannot touch identity/ or outside paths.local. Drafts keep status: draft.', + { + type: 'object', + additionalProperties: false, + required: ['path', 'body'], + properties: { + path: str('Absolute path or path relative to KB local root'), + body: str('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 ` + + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, + { + type: 'object', + additionalProperties: false, + required: ['path'], + properties: { + path: str('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 (newest first). ` + + 'Does not include identity/. Read contents via absolute paths from the listing.', + { + type: 'object', + additionalProperties: false, + properties: { + root: { + type: 'string', + enum: ['memories', 'drafts', 'suggestions', 'all'], + description: 'Subtree to list (default all writable)', + }, + limit: num('Max entries 1–200 (default 50)'), + }, + }, + (args) => listLocalFiles(liveSnapshot(), { + root: (args.root as 'memories' | 'drafts' | 'suggestions' | 'all' | undefined) ?? '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() + } +} diff --git a/src/netx/kb-runtime.ts b/src/netx/kb-runtime.ts index b5b4ed2..ea9d882 100644 --- a/src/netx/kb-runtime.ts +++ b/src/netx/kb-runtime.ts @@ -8,6 +8,7 @@ import type { KbSnapshot } from './kb-manifest.ts' import { unconfiguredKbSnapshot } from './kb-manifest.ts' +import { resolveKbLocalRoot } from './kb-local-path.ts' type Listener = () => void @@ -20,6 +21,7 @@ const STORE_KEY = Symbol.for('dsh-netxops.kb-store') const ENV_KEYS = [ 'KB_ROOT', + 'KB_LOCAL', 'KB_OPERATOR', 'KB_COUNTRY', 'KB_VERSION', @@ -83,6 +85,8 @@ export function applyKbEnv(snapshot: KbSnapshot): void { process.env.KB_STATUS = snapshot.status if (snapshot.status !== 'configured') return process.env.KB_ROOT = snapshot.realRoot + const local = resolveKbLocalRoot(snapshot) + if (local) process.env.KB_LOCAL = local process.env.KB_OPERATOR = snapshot.operatorName process.env.KB_COUNTRY = snapshot.country process.env.KB_VERSION = snapshot.version diff --git a/test/kb-local.test.mjs b/test/kb-local.test.mjs new file mode 100644 index 0000000..4f09e8f --- /dev/null +++ b/test/kb-local.test.mjs @@ -0,0 +1,165 @@ +/** + * KB local path jail + create/update/delete ops. + */ + +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' + +import { + createDraft, + createMemory, + createSuggestion, + deleteLocalFile, + listLocalFiles, + updateLocalFile, +} from '../src/netx/kb-local-ops.ts' +import { + draftFileName, + resolveKbLocalRoot, + resolveWritableLocalPath, + sanitizeSlug, +} from '../src/netx/kb-local-path.ts' +import { applyKbEnv, resetKbContext } from '../src/netx/kb-runtime.ts' + +function snap(root, localRel = '_local') { + return { + status: 'configured', + realRoot: root, + operatorName: 'IOH', + country: 'ID', + version: '1', + content: { + hasRegions: true, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false, + }, + paths: { local: localRel }, + errorMessage: '', + } +} + +function withTemp(run) { + const root = mkdtempSync(join(tmpdir(), 'netxops-kb-local-')) + try { + run(root) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +test('sanitizeSlug strips separators', () => { + assert.equal(sanitizeSlug('../evil/x'), 'evil-x') + assert.equal(sanitizeSlug('你好 world'), '你好-world') +}) + +test('draftFileName forces DRAFT- prefix', () => { + const name = draftFileName({ date: '2026-09-15', slug: 'jakarta-down' }) + assert.match(name, /^DRAFT-20260915-jakarta-down\.md$/) +}) + +test('resolveWritableLocalPath rejects identity', () => { + withTemp((root) => { + const local = join(root, '_local') + mkdirSync(local, { recursive: true }) + assert.throws( + () => resolveWritableLocalPath(local, 'identity', '话术.md'), + /identity|allowed under/, + ) + }) +}) + +test('create / update / delete memory', () => { + withTemp((root) => { + const s = snap(root) + mkdirSync(join(root, '_local'), { recursive: true }) + const created = createMemory(s, { + bucket: 'note', + slug: 'smoke', + body: '# hello\n', + date: '2026-09-15', + }) + assert.equal(created.action, 'created') + assert.ok(created.relativePath.includes('日常笔记')) + assert.equal(readFileSync(created.absolutePath, 'utf8'), '# hello\n') + + assert.throws( + () => createMemory(s, { + bucket: 'note', + slug: 'smoke', + body: 'x', + date: '2026-09-15', + }), + /already exists/, + ) + + const updated = updateLocalFile(s, { + path: created.relativePath, + body: '# updated\n', + }) + assert.equal(updated.action, 'updated') + assert.equal(readFileSync(created.absolutePath, 'utf8'), '# updated\n') + + const listed = listLocalFiles(s, { root: 'memories' }) + assert.equal(listed.entries.length, 1) + + const deleted = deleteLocalFile(s, { path: created.absolutePath }) + assert.equal(deleted.action, 'deleted') + assert.equal(listLocalFiles(s).entries.length, 0) + }) +}) + +test('draft injects status: draft frontmatter', () => { + withTemp((root) => { + const s = snap(root) + mkdirSync(join(root, '_local'), { recursive: true }) + const created = createDraft(s, { + slug: 'case-a', + body: '# DRAFT\n\nbody\n', + domain: '02_路由', + date: '20260915', + }) + const text = readFileSync(created.absolutePath, 'utf8') + assert.match(text, /^---\nstatus: draft\n---/) + assert.match(created.relativePath, /drafts\//) + }) +}) + +test('suggestion + refuse identity path update', () => { + withTemp((root) => { + const s = snap(root) + const idDir = join(root, '_local', 'identity') + mkdirSync(idDir, { recursive: true }) + writeFileSync(join(idDir, '话术与边界.md'), 'secret\n', 'utf8') + createSuggestion(s, { + kind: 'improvement', + slug: 'tool-x', + body: '# sug\n', + date: '2026-09-15', + }) + assert.throws( + () => updateLocalFile(s, { + path: 'identity/话术与边界.md', + body: 'hacked\n', + }), + /identity|allowed under/, + ) + assert.equal(readFileSync(join(idDir, '话术与边界.md'), 'utf8'), 'secret\n') + }) +}) + +test('applyKbEnv sets KB_LOCAL', () => { + withTemp((root) => { + mkdirSync(join(root, '_local'), { recursive: true }) + const s = snap(root) + assert.equal(resolveKbLocalRoot(s), join(root, '_local')) + applyKbEnv(s) + assert.equal(process.env.KB_LOCAL, join(root, '_local')) + resetKbContext() + assert.equal(process.env.KB_LOCAL, undefined) + }) +})