diff --git a/cordis.patch.yml b/cordis.patch.yml index f3653b6..1c44e5b 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -30,3 +30,6 @@ groupOpsPublic: false groupTopologyInPreset: false groupTopologyPublic: false + kbRoot: '' + groupKbInPreset: true + groupKbPublic: false diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 7203f53..de502d0 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -43,7 +43,7 @@ One command, three **direct** profile bundles. Do **not** nest IM/cron under net Optional: enable **关键告警推送** so this DSH dials `ws(s):///v1/integrations/dsh-alarm/ws`. Choose **投递到 DSH 会话** and/or **投递到 WhatsApp / IM**. For IM, install `dsh-im-ops`, create a delivery target, then paste `imBotId` / `imTargetId`. The card header shows live WSS status. Optional: **导出全部会话** downloads `dsh-sessions--.zip` via `GET /api/netxops.sessions.export` (browser download; works for cloud Hosts). Capability groups: leave default for **ops**, or enable **topology** / **对其他预设公开** (new sessions after save). - Optional: **知识库** — set package root (folder with `MANIFEST.json` operator-subset v1.0). Empty = pure netx. See [KNOWLEDGE_BASE.md](KNOWLEDGE_BASE.md). + 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. 3. **New session → preset Netx Ops** → ask e.g. Critical Top / single-host alarms / 「能否登录」. diff --git a/docs/KNOWLEDGE_BASE.md b/docs/KNOWLEDGE_BASE.md index 115514b..f2f8c72 100644 --- a/docs/KNOWLEDGE_BASE.md +++ b/docs/KNOWLEDGE_BASE.md @@ -1,14 +1,21 @@ -# Knowledge base (P1) +# Knowledge base Operator-subset knowledge packages are wired through **netxops settings**, not into the netx API or `netx-ops` skill text. +**Role split** + +| Surface | Responsibility | +|---------|----------------| +| `netx-ops` | Live netx evidence (alarms / inventory / CLI) — **pure netx**, no KB playbooks | +| `kb-context` | Identity annotation from MANIFEST (`KB_*`) | +| `_skills/kb-*` | Operator KB triage / ingest / retrieve / export / package | + ## Setup 1. Settings → Plugins → **Netx Ops** → **知识库 / Knowledge base** -2. Set **知识包根目录** to a folder that contains (or uniquely nests ≤3 levels) a `MANIFEST.json` -3. Save — Host injects env + a `kb-context` skill for new sessions - -Browse uses Host `remote.directoryPicker` when available; otherwise paste an absolute path. +2. Browse (or paste) a folder that contains (or uniquely nests ≤3 levels) a `MANIFEST.json` — browse auto-saves +3. Leave **知识库技能 → 在 Netx Ops 预设中启用** on (default) so `_skills/kb-*` register into Netx Ops sessions +4. Optionally enable **对其他预设公开** to publish those pack skills on the host public skill layer ## MANIFEST v1.0 (plugin checks) @@ -18,7 +25,7 @@ Browse uses Host `remote.directoryPicker` when available; otherwise paste an abs | `packageType` | must be `"operator-subset"` | | `operator.name` / `operator.country` | required non-empty strings | | `version` | required non-empty string | -| `content.*` | object of booleans; missing keys → `false` (`regions` / `theory` / `packet` / `skills` known) | +| `content.*` | booleans; contract keys `hasRegions` / `hasTheory` / `hasPacket` / `hasCommon` / `hasSkills` (missing → `false`). Legacy short keys `regions`/`theory`/`packet`/`skills` map to `has*` when the contract key is absent | Location: `${kbRoot}/MANIFEST.json`, else recurse ≤3 levels and **exactly one** hit. Zero or multiple → error. @@ -28,17 +35,36 @@ Authoritative packaging prose lives in the workspace contract notes (`插件` / | Channel | Fields | |---------|--------| -| `process.env` | `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT` (JSON), `KB_STATUS` | +| `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) | +| Skills from `_skills/` | Registered when `configured` **and** `hasSkills` **and** the inPreset/public toggle for that plane | RPC (channel `/netxops`): `kb.status` (saved snapshot), `kb.resolve` with `{ path }` (preview unsaved paths). +## Pack skill registration + +When `KB_STATUS=configured` and `content.hasSkills=true`, the plugin scans: + +```text +${realRoot}/_skills/*/SKILL.md +``` + +and registers each valid frontmatter skill (`provider: netxops-kb-pack`). + +| Setting | Default | Plane | +|---------|---------|-------| +| `groupKbInPreset` | `true` | Netx Ops preset sessions | +| `groupKbPublic` | `false` | Host public skills (other presets) | + +Invalid or missing `_skills` directories are skipped (ops continues). + ## Degradation matrix | Condition | `KB_STATUS` | Behavior | |-----------|-------------|----------| -| Empty `kbRoot` | `unconfigured` | Pure netx; skill says do not invent an operator | +| Empty `kbRoot` | `unconfigured` | Pure netx; no invented operator; no kb-* packs | | Missing / ambiguous / invalid MANIFEST | `error` | Pure netx; badge shows error detail | -| Valid MANIFEST | `configured` | Paths + operator identity available | +| Valid MANIFEST, `hasSkills=false` | `configured` | Identity only (`kb-context` + env) | +| Valid + `hasSkills` + toggles | `configured` | Identity + `_skills/kb-*` on the enabled plane(s) | -**Never** invent an operator when status is not `configured`. P2 knowledge-base skill packs (case search, etc.) are out of scope for this release. +**Never** invent an operator when status is not `configured`. diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index 19e90dc..a87438a 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -237,7 +237,11 @@ function skillBody(snapshot) { `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, "", - "Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`." + "Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "", + "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from", + `${snapshot.realRoot}/_skills/ when hasSkills is true — use those skills for KB triage;`, + "keep netx-ops for live netx evidence only." ].join(` `) }; @@ -248,7 +252,7 @@ function skillBody(snapshot) { content: [ "## Knowledge base (unavailable)", "", - `kbStatus=\`${snapshot.status}\`. ${reason}`, + `kbStatus=${snapshot.status}. ${reason}`, "", "**Do not invent an operator, country, or KB paths.**", "Use only netx tools / live evidence (alarms, inventory, CLI, topology).", @@ -272,12 +276,77 @@ function registerKbContextSkill(ctx, snapshot) { }); } +// src/netx/kb-pack-skills.ts +import { readdir as readdir2, stat as stat2 } from "node:fs/promises"; +import { join as join2 } from "node:path"; +function kbPackSkillsEnabled(snapshot) { + return snapshot.status === "configured" && snapshot.content.hasSkills === true; +} +async function registerKbPackSkills(ctx, snapshot, options) { + if (!options.enabled || !kbPackSkillsEnabled(snapshot)) { + return () => {}; + } + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const root = join2(snapshot.realRoot, "_skills"); + let entries; + try { + entries = await readdir2(root); + } catch { + ctx.logger.info("netxops: kb pack skills dir missing at %s — skip", root); + return () => {}; + } + const disposers = []; + const provider = options.providerLabel ?? "netxops-kb-pack"; + for (const entry of entries) { + const full = join2(root, entry); + let isDir = false; + try { + isDir = (await stat2(full)).isDirectory(); + } catch { + continue; + } + if (!isDir) + continue; + let skill; + try { + skill = await loadSkillBundle(full); + } catch (error) { + ctx.logger.warn("netxops: kb pack skill load failed at %s: %s", full, error instanceof Error ? error.message : String(error)); + continue; + } + if (!skill) { + ctx.logger.warn("netxops: kb pack skill skipped (invalid SKILL.md): %s", full); + continue; + } + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: skill.content, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider, + source: "custom" + })); + } + if (disposers.length > 0) { + ctx.logger.info("netxops: kb pack skills registered count=%s root=%s provider=%s", disposers.length, root, provider); + } + return () => { + for (const dispose of disposers) + dispose(); + }; +} + // src/netx/kb-manifest.ts var EMPTY_CONTENT = { - regions: false, - theory: false, - packet: false, - skills: false + hasRegions: false, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false }; function unconfiguredKbSnapshot() { return { @@ -1848,6 +1917,8 @@ function applyGroupToolsPlugin(ctx, options) { }); ctx.inject(["skills"], (skillsCtx) => { let unregisterKbSkill; + let unregisterKbPack; + let packGeneration = 0; const remountSkills = () => { const gen = ++skillGeneration; unregisterSkills?.(); @@ -1870,22 +1941,47 @@ function applyGroupToolsPlugin(ctx, options) { unregisterKbSkill = undefined; unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext()); }; + const remountKbPack = () => { + const gen = ++packGeneration; + unregisterKbPack?.(); + unregisterKbPack = undefined; + const connection = getNetxConnection(); + const enabled = connection?.groupKbInPreset !== false; + registerKbPackSkills(skillsCtx, getKbContext(), { + enabled, + providerLabel: `${options.name}-kb-pack` + }).then((dispose) => { + if (gen !== packGeneration) { + dispose(); + return; + } + unregisterKbPack = dispose; + }).catch((error) => { + skillsCtx.logger.warn("%s: kb pack skill register failed: %s", options.name, error); + }); + }; remountSkills(); remountKbSkill(); + remountKbPack(); const stopSkillWatch = watchNetxConnection(() => { remountSkills(); + remountKbPack(); }); const stopKbWatch = watchKbContext(() => { remountKbSkill(); + remountKbPack(); }); skillsCtx.effect(() => () => { skillGeneration += 1; + packGeneration += 1; stopSkillWatch(); stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; unregisterKbSkill?.(); unregisterKbSkill = undefined; + unregisterKbPack?.(); + unregisterKbPack = undefined; }, `${options.name}: dispose skills`); }); ctx.effect(() => () => { diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index 03488ba..cea2afb 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -237,7 +237,11 @@ function skillBody(snapshot) { `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, "", - "Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`." + "Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "", + "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from", + `${snapshot.realRoot}/_skills/ when hasSkills is true — use those skills for KB triage;`, + "keep netx-ops for live netx evidence only." ].join(` `) }; @@ -248,7 +252,7 @@ function skillBody(snapshot) { content: [ "## Knowledge base (unavailable)", "", - `kbStatus=\`${snapshot.status}\`. ${reason}`, + `kbStatus=${snapshot.status}. ${reason}`, "", "**Do not invent an operator, country, or KB paths.**", "Use only netx tools / live evidence (alarms, inventory, CLI, topology).", @@ -272,12 +276,77 @@ function registerKbContextSkill(ctx, snapshot) { }); } +// src/netx/kb-pack-skills.ts +import { readdir as readdir2, stat as stat2 } from "node:fs/promises"; +import { join as join2 } from "node:path"; +function kbPackSkillsEnabled(snapshot) { + return snapshot.status === "configured" && snapshot.content.hasSkills === true; +} +async function registerKbPackSkills(ctx, snapshot, options) { + if (!options.enabled || !kbPackSkillsEnabled(snapshot)) { + return () => {}; + } + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const root = join2(snapshot.realRoot, "_skills"); + let entries; + try { + entries = await readdir2(root); + } catch { + ctx.logger.info("netxops: kb pack skills dir missing at %s — skip", root); + return () => {}; + } + const disposers = []; + const provider = options.providerLabel ?? "netxops-kb-pack"; + for (const entry of entries) { + const full = join2(root, entry); + let isDir = false; + try { + isDir = (await stat2(full)).isDirectory(); + } catch { + continue; + } + if (!isDir) + continue; + let skill; + try { + skill = await loadSkillBundle(full); + } catch (error) { + ctx.logger.warn("netxops: kb pack skill load failed at %s: %s", full, error instanceof Error ? error.message : String(error)); + continue; + } + if (!skill) { + ctx.logger.warn("netxops: kb pack skill skipped (invalid SKILL.md): %s", full); + continue; + } + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: skill.content, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider, + source: "custom" + })); + } + if (disposers.length > 0) { + ctx.logger.info("netxops: kb pack skills registered count=%s root=%s provider=%s", disposers.length, root, provider); + } + return () => { + for (const dispose of disposers) + dispose(); + }; +} + // src/netx/kb-manifest.ts var EMPTY_CONTENT = { - regions: false, - theory: false, - packet: false, - skills: false + hasRegions: false, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false }; function unconfiguredKbSnapshot() { return { @@ -1848,6 +1917,8 @@ function applyGroupToolsPlugin(ctx, options) { }); ctx.inject(["skills"], (skillsCtx) => { let unregisterKbSkill; + let unregisterKbPack; + let packGeneration = 0; const remountSkills = () => { const gen = ++skillGeneration; unregisterSkills?.(); @@ -1870,22 +1941,47 @@ function applyGroupToolsPlugin(ctx, options) { unregisterKbSkill = undefined; unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext()); }; + const remountKbPack = () => { + const gen = ++packGeneration; + unregisterKbPack?.(); + unregisterKbPack = undefined; + const connection = getNetxConnection(); + const enabled = connection?.groupKbInPreset !== false; + registerKbPackSkills(skillsCtx, getKbContext(), { + enabled, + providerLabel: `${options.name}-kb-pack` + }).then((dispose) => { + if (gen !== packGeneration) { + dispose(); + return; + } + unregisterKbPack = dispose; + }).catch((error) => { + skillsCtx.logger.warn("%s: kb pack skill register failed: %s", options.name, error); + }); + }; remountSkills(); remountKbSkill(); + remountKbPack(); const stopSkillWatch = watchNetxConnection(() => { remountSkills(); + remountKbPack(); }); const stopKbWatch = watchKbContext(() => { remountKbSkill(); + remountKbPack(); }); skillsCtx.effect(() => () => { skillGeneration += 1; + packGeneration += 1; stopSkillWatch(); stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; unregisterKbSkill?.(); unregisterKbSkill = undefined; + unregisterKbPack?.(); + unregisterKbPack = undefined; }, `${options.name}: dispose skills`); }); ctx.effect(() => () => { diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 4ba1f4c..6de5804 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -237,7 +237,11 @@ function skillBody(snapshot) { `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, "", - "Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`." + "Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "", + "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from", + `${snapshot.realRoot}/_skills/ when hasSkills is true — use those skills for KB triage;`, + "keep netx-ops for live netx evidence only." ].join(` `) }; @@ -248,7 +252,7 @@ function skillBody(snapshot) { content: [ "## Knowledge base (unavailable)", "", - `kbStatus=\`${snapshot.status}\`. ${reason}`, + `kbStatus=${snapshot.status}. ${reason}`, "", "**Do not invent an operator, country, or KB paths.**", "Use only netx tools / live evidence (alarms, inventory, CLI, topology).", @@ -272,12 +276,77 @@ function registerKbContextSkill(ctx, snapshot) { }); } +// src/netx/kb-pack-skills.ts +import { readdir as readdir2, stat as stat2 } from "node:fs/promises"; +import { join as join2 } from "node:path"; +function kbPackSkillsEnabled(snapshot) { + return snapshot.status === "configured" && snapshot.content.hasSkills === true; +} +async function registerKbPackSkills(ctx, snapshot, options) { + if (!options.enabled || !kbPackSkillsEnabled(snapshot)) { + return () => {}; + } + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const root = join2(snapshot.realRoot, "_skills"); + let entries; + try { + entries = await readdir2(root); + } catch { + ctx.logger.info("netxops: kb pack skills dir missing at %s — skip", root); + return () => {}; + } + const disposers = []; + const provider = options.providerLabel ?? "netxops-kb-pack"; + for (const entry of entries) { + const full = join2(root, entry); + let isDir = false; + try { + isDir = (await stat2(full)).isDirectory(); + } catch { + continue; + } + if (!isDir) + continue; + let skill; + try { + skill = await loadSkillBundle(full); + } catch (error) { + ctx.logger.warn("netxops: kb pack skill load failed at %s: %s", full, error instanceof Error ? error.message : String(error)); + continue; + } + if (!skill) { + ctx.logger.warn("netxops: kb pack skill skipped (invalid SKILL.md): %s", full); + continue; + } + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: skill.content, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider, + source: "custom" + })); + } + if (disposers.length > 0) { + ctx.logger.info("netxops: kb pack skills registered count=%s root=%s provider=%s", disposers.length, root, provider); + } + return () => { + for (const dispose of disposers) + dispose(); + }; +} + // src/netx/kb-manifest.ts var EMPTY_CONTENT = { - regions: false, - theory: false, - packet: false, - skills: false + hasRegions: false, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false }; function unconfiguredKbSnapshot() { return { @@ -1848,6 +1917,8 @@ function applyGroupToolsPlugin(ctx, options) { }); ctx.inject(["skills"], (skillsCtx) => { let unregisterKbSkill; + let unregisterKbPack; + let packGeneration = 0; const remountSkills = () => { const gen = ++skillGeneration; unregisterSkills?.(); @@ -1870,22 +1941,47 @@ function applyGroupToolsPlugin(ctx, options) { unregisterKbSkill = undefined; unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext()); }; + const remountKbPack = () => { + const gen = ++packGeneration; + unregisterKbPack?.(); + unregisterKbPack = undefined; + const connection = getNetxConnection(); + const enabled = connection?.groupKbInPreset !== false; + registerKbPackSkills(skillsCtx, getKbContext(), { + enabled, + providerLabel: `${options.name}-kb-pack` + }).then((dispose) => { + if (gen !== packGeneration) { + dispose(); + return; + } + unregisterKbPack = dispose; + }).catch((error) => { + skillsCtx.logger.warn("%s: kb pack skill register failed: %s", options.name, error); + }); + }; remountSkills(); remountKbSkill(); + remountKbPack(); const stopSkillWatch = watchNetxConnection(() => { remountSkills(); + remountKbPack(); }); const stopKbWatch = watchKbContext(() => { remountKbSkill(); + remountKbPack(); }); skillsCtx.effect(() => () => { skillGeneration += 1; + packGeneration += 1; stopSkillWatch(); stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; unregisterKbSkill?.(); unregisterKbSkill = undefined; + unregisterKbPack?.(); + unregisterKbPack = undefined; }, `${options.name}: dispose skills`); }); ctx.effect(() => () => { diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index 371a8e3..313512b 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -237,7 +237,11 @@ function skillBody(snapshot) { `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, "", - "Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`." + "Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "", + "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from", + `${snapshot.realRoot}/_skills/ when hasSkills is true — use those skills for KB triage;`, + "keep netx-ops for live netx evidence only." ].join(` `) }; @@ -248,7 +252,7 @@ function skillBody(snapshot) { content: [ "## Knowledge base (unavailable)", "", - `kbStatus=\`${snapshot.status}\`. ${reason}`, + `kbStatus=${snapshot.status}. ${reason}`, "", "**Do not invent an operator, country, or KB paths.**", "Use only netx tools / live evidence (alarms, inventory, CLI, topology).", @@ -272,12 +276,77 @@ function registerKbContextSkill(ctx, snapshot) { }); } +// src/netx/kb-pack-skills.ts +import { readdir as readdir2, stat as stat2 } from "node:fs/promises"; +import { join as join2 } from "node:path"; +function kbPackSkillsEnabled(snapshot) { + return snapshot.status === "configured" && snapshot.content.hasSkills === true; +} +async function registerKbPackSkills(ctx, snapshot, options) { + if (!options.enabled || !kbPackSkillsEnabled(snapshot)) { + return () => {}; + } + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const root = join2(snapshot.realRoot, "_skills"); + let entries; + try { + entries = await readdir2(root); + } catch { + ctx.logger.info("netxops: kb pack skills dir missing at %s — skip", root); + return () => {}; + } + const disposers = []; + const provider = options.providerLabel ?? "netxops-kb-pack"; + for (const entry of entries) { + const full = join2(root, entry); + let isDir = false; + try { + isDir = (await stat2(full)).isDirectory(); + } catch { + continue; + } + if (!isDir) + continue; + let skill; + try { + skill = await loadSkillBundle(full); + } catch (error) { + ctx.logger.warn("netxops: kb pack skill load failed at %s: %s", full, error instanceof Error ? error.message : String(error)); + continue; + } + if (!skill) { + ctx.logger.warn("netxops: kb pack skill skipped (invalid SKILL.md): %s", full); + continue; + } + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: skill.content, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider, + source: "custom" + })); + } + if (disposers.length > 0) { + ctx.logger.info("netxops: kb pack skills registered count=%s root=%s provider=%s", disposers.length, root, provider); + } + return () => { + for (const dispose of disposers) + dispose(); + }; +} + // src/netx/kb-manifest.ts var EMPTY_CONTENT = { - regions: false, - theory: false, - packet: false, - skills: false + hasRegions: false, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false }; function unconfiguredKbSnapshot() { return { @@ -1848,6 +1917,8 @@ function applyGroupToolsPlugin(ctx, options) { }); ctx.inject(["skills"], (skillsCtx) => { let unregisterKbSkill; + let unregisterKbPack; + let packGeneration = 0; const remountSkills = () => { const gen = ++skillGeneration; unregisterSkills?.(); @@ -1870,22 +1941,47 @@ function applyGroupToolsPlugin(ctx, options) { unregisterKbSkill = undefined; unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext()); }; + const remountKbPack = () => { + const gen = ++packGeneration; + unregisterKbPack?.(); + unregisterKbPack = undefined; + const connection = getNetxConnection(); + const enabled = connection?.groupKbInPreset !== false; + registerKbPackSkills(skillsCtx, getKbContext(), { + enabled, + providerLabel: `${options.name}-kb-pack` + }).then((dispose) => { + if (gen !== packGeneration) { + dispose(); + return; + } + unregisterKbPack = dispose; + }).catch((error) => { + skillsCtx.logger.warn("%s: kb pack skill register failed: %s", options.name, error); + }); + }; remountSkills(); remountKbSkill(); + remountKbPack(); const stopSkillWatch = watchNetxConnection(() => { remountSkills(); + remountKbPack(); }); const stopKbWatch = watchKbContext(() => { remountKbSkill(); + remountKbPack(); }); skillsCtx.effect(() => () => { skillGeneration += 1; + packGeneration += 1; stopSkillWatch(); stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; unregisterKbSkill?.(); unregisterKbSkill = undefined; + unregisterKbPack?.(); + unregisterKbPack = undefined; }, `${options.name}: dispose skills`); }); ctx.effect(() => () => { diff --git a/lib/agent-tools.js b/lib/agent-tools.js index 82d32c3..9beab03 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -237,7 +237,11 @@ function skillBody(snapshot) { `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, "", - "Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`." + "Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "", + "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from", + `${snapshot.realRoot}/_skills/ when hasSkills is true — use those skills for KB triage;`, + "keep netx-ops for live netx evidence only." ].join(` `) }; @@ -248,7 +252,7 @@ function skillBody(snapshot) { content: [ "## Knowledge base (unavailable)", "", - `kbStatus=\`${snapshot.status}\`. ${reason}`, + `kbStatus=${snapshot.status}. ${reason}`, "", "**Do not invent an operator, country, or KB paths.**", "Use only netx tools / live evidence (alarms, inventory, CLI, topology).", @@ -272,12 +276,77 @@ function registerKbContextSkill(ctx, snapshot) { }); } +// src/netx/kb-pack-skills.ts +import { readdir as readdir2, stat as stat2 } from "node:fs/promises"; +import { join as join2 } from "node:path"; +function kbPackSkillsEnabled(snapshot) { + return snapshot.status === "configured" && snapshot.content.hasSkills === true; +} +async function registerKbPackSkills(ctx, snapshot, options) { + if (!options.enabled || !kbPackSkillsEnabled(snapshot)) { + return () => {}; + } + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const root = join2(snapshot.realRoot, "_skills"); + let entries; + try { + entries = await readdir2(root); + } catch { + ctx.logger.info("netxops: kb pack skills dir missing at %s — skip", root); + return () => {}; + } + const disposers = []; + const provider = options.providerLabel ?? "netxops-kb-pack"; + for (const entry of entries) { + const full = join2(root, entry); + let isDir = false; + try { + isDir = (await stat2(full)).isDirectory(); + } catch { + continue; + } + if (!isDir) + continue; + let skill; + try { + skill = await loadSkillBundle(full); + } catch (error) { + ctx.logger.warn("netxops: kb pack skill load failed at %s: %s", full, error instanceof Error ? error.message : String(error)); + continue; + } + if (!skill) { + ctx.logger.warn("netxops: kb pack skill skipped (invalid SKILL.md): %s", full); + continue; + } + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: skill.content, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider, + source: "custom" + })); + } + if (disposers.length > 0) { + ctx.logger.info("netxops: kb pack skills registered count=%s root=%s provider=%s", disposers.length, root, provider); + } + return () => { + for (const dispose of disposers) + dispose(); + }; +} + // src/netx/kb-manifest.ts var EMPTY_CONTENT = { - regions: false, - theory: false, - packet: false, - skills: false + hasRegions: false, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false }; function unconfiguredKbSnapshot() { return { @@ -1848,6 +1917,8 @@ function applyGroupToolsPlugin(ctx, options) { }); ctx.inject(["skills"], (skillsCtx) => { let unregisterKbSkill; + let unregisterKbPack; + let packGeneration = 0; const remountSkills = () => { const gen = ++skillGeneration; unregisterSkills?.(); @@ -1870,22 +1941,47 @@ function applyGroupToolsPlugin(ctx, options) { unregisterKbSkill = undefined; unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext()); }; + const remountKbPack = () => { + const gen = ++packGeneration; + unregisterKbPack?.(); + unregisterKbPack = undefined; + const connection = getNetxConnection(); + const enabled = connection?.groupKbInPreset !== false; + registerKbPackSkills(skillsCtx, getKbContext(), { + enabled, + providerLabel: `${options.name}-kb-pack` + }).then((dispose) => { + if (gen !== packGeneration) { + dispose(); + return; + } + unregisterKbPack = dispose; + }).catch((error) => { + skillsCtx.logger.warn("%s: kb pack skill register failed: %s", options.name, error); + }); + }; remountSkills(); remountKbSkill(); + remountKbPack(); const stopSkillWatch = watchNetxConnection(() => { remountSkills(); + remountKbPack(); }); const stopKbWatch = watchKbContext(() => { remountKbSkill(); + remountKbPack(); }); skillsCtx.effect(() => () => { skillGeneration += 1; + packGeneration += 1; stopSkillWatch(); stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; unregisterKbSkill?.(); unregisterKbSkill = undefined; + unregisterKbPack?.(); + unregisterKbPack = undefined; }, `${options.name}: dispose skills`); }); ctx.effect(() => () => { diff --git a/lib/client.js b/lib/client.js index e1a5922..1af392b 100644 --- a/lib/client.js +++ b/lib/client.js @@ -569,10 +569,11 @@ var posix = ((p) => (p.posix = p, p))({ resolve, normalize, isAbsolute, join, re // src/netx/kb-manifest.ts var EMPTY_CONTENT = { - regions: false, - theory: false, - packet: false, - skills: false + hasRegions: false, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false }; function unconfiguredKbSnapshot() { return { @@ -1410,6 +1411,20 @@ function NetxopsCard(props) { children: state.kbStatus.realRoot }) : null ] + }), + /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { + title: t("groupKb"), + inPresetLabel: t("groupInPreset"), + publicLabel: t("groupPublic"), + inPreset: state.groupKbInPreset, + published: state.groupKbPublic, + disabled, + onEditInPreset: (checked) => { + props.edit("groupKbInPreset", checked ? "true" : "false"); + }, + onEditPublic: (checked) => { + props.edit("groupKbPublic", checked ? "true" : "false"); + } }) ] }), @@ -1841,6 +1856,8 @@ class NetxopsCardController { booleanField("groupOpsPublic"), booleanField("groupTopologyInPreset"), booleanField("groupTopologyPublic"), + booleanFieldPersistFalse("groupKbInPreset"), + booleanField("groupKbPublic"), booleanField("alarmPushEnabled"), booleanFieldPersistFalse("alarmDeliverDsh"), booleanField("alarmDeliverIm"), @@ -2106,6 +2123,8 @@ class NetxopsCardController { groupOpsPublic: this.form.field("groupOpsPublic"), groupTopologyInPreset: this.form.field("groupTopologyInPreset"), groupTopologyPublic: this.form.field("groupTopologyPublic"), + groupKbInPreset: this.form.field("groupKbInPreset"), + groupKbPublic: this.form.field("groupKbPublic"), alarmPushEnabled: this.form.field("alarmPushEnabled"), alarmDeliverDsh: this.form.field("alarmDeliverDsh"), alarmDeliverIm: this.form.field("alarmDeliverIm"), @@ -2259,10 +2278,11 @@ var en = { nmsProviderHint: "zte-ume", groupOps: "ops", groupTopology: "topology", + groupKb: "Knowledge skills", groupInPreset: "In Netx Ops preset", groupPublic: "Publish to other presets", kbRoot: "Package root", - kbRootHint: "Folder with MANIFEST.json (operator-subset v1.0). Browse auto-saves.", + kbRootHint: "Folder with MANIFEST.json (operator-subset v1.0). Browse auto-saves. Pack skills load from _skills/ when hasSkills.", kbBrowse: "Browse…", kbBrowseUnavailable: "Directory picker unavailable — paste an absolute folder path, then Save.", kbBrowseFailed: "Browse failed: {detail}", @@ -2331,10 +2351,11 @@ var zh = { nmsProviderHint: "zte-ume", groupOps: "ops", groupTopology: "topology", + groupKb: "知识库技能", groupInPreset: "在 Netx Ops 预设中启用", groupPublic: "对其他预设公开", kbRoot: "知识包根目录", - kbRootHint: "含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存。", + kbRootHint: "含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存;hasSkills 时加载 _skills/。", kbBrowse: "浏览…", kbBrowseUnavailable: "目录选择器不可用 — 请粘贴绝对目录路径后点保存。", kbBrowseFailed: "浏览失败:{detail}", diff --git a/lib/index.js b/lib/index.js index dec5043..7cbbbcf 100644 --- a/lib/index.js +++ b/lib/index.js @@ -4,7 +4,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url); // src/index.ts import { cpSync, existsSync as existsSync3, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { homedir as homedir3 } from "node:os"; -import { dirname as dirname3, join as join4 } from "node:path"; +import { dirname as dirname3, join as join5 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import z from "@deepseek-ai/schemastery"; import { credentialRef } from "@deepseek-ai/dsh-credentials"; @@ -807,7 +807,11 @@ function skillBody(snapshot) { `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, "", - "Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`." + "Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "", + "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from", + `${snapshot.realRoot}/_skills/ when hasSkills is true — use those skills for KB triage;`, + "keep netx-ops for live netx evidence only." ].join(` `) }; @@ -818,7 +822,7 @@ function skillBody(snapshot) { content: [ "## Knowledge base (unavailable)", "", - `kbStatus=\`${snapshot.status}\`. ${reason}`, + `kbStatus=${snapshot.status}. ${reason}`, "", "**Do not invent an operator, country, or KB paths.**", "Use only netx tools / live evidence (alarms, inventory, CLI, topology).", @@ -846,10 +850,18 @@ function registerKbContextSkill(ctx, snapshot) { import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "node:fs"; import { basename, dirname as dirname2, join as join3, resolve } from "node:path"; var EMPTY_CONTENT = { - regions: false, - theory: false, - packet: false, - skills: false + hasRegions: false, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false +}; +var LEGACY_CONTENT_KEY = { + regions: "hasRegions", + theory: "hasTheory", + packet: "hasPacket", + skills: "hasSkills", + common: "hasCommon" }; function unconfiguredKbSnapshot() { return { @@ -962,8 +974,17 @@ function parseContent(raw) { if (typeof raw !== "object" || Array.isArray(raw)) { throw new Error("MANIFEST content must be an object"); } - for (const [key, value] of Object.entries(raw)) { - out[key] = value === true; + const row = raw; + const present = new Set(Object.keys(row)); + for (const [key, value] of Object.entries(row)) { + const on = value === true; + const mapped = LEGACY_CONTENT_KEY[key]; + if (mapped !== undefined) { + if (on && !present.has(mapped)) + out[mapped] = true; + continue; + } + out[key] = on; } return out; } @@ -1047,6 +1068,70 @@ function resolveKbRoot(kbRoot, maxDepth = 3) { } } +// src/netx/kb-pack-skills.ts +import { readdir as readdir2, stat as stat2 } from "node:fs/promises"; +import { join as join4 } from "node:path"; +function kbPackSkillsEnabled(snapshot) { + return snapshot.status === "configured" && snapshot.content.hasSkills === true; +} +async function registerKbPackSkills(ctx, snapshot, options) { + if (!options.enabled || !kbPackSkillsEnabled(snapshot)) { + return () => {}; + } + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const root = join4(snapshot.realRoot, "_skills"); + let entries; + try { + entries = await readdir2(root); + } catch { + ctx.logger.info("netxops: kb pack skills dir missing at %s — skip", root); + return () => {}; + } + const disposers = []; + const provider = options.providerLabel ?? "netxops-kb-pack"; + for (const entry of entries) { + const full = join4(root, entry); + let isDir = false; + try { + isDir = (await stat2(full)).isDirectory(); + } catch { + continue; + } + if (!isDir) + continue; + let skill; + try { + skill = await loadSkillBundle(full); + } catch (error) { + ctx.logger.warn("netxops: kb pack skill load failed at %s: %s", full, error instanceof Error ? error.message : String(error)); + continue; + } + if (!skill) { + ctx.logger.warn("netxops: kb pack skill skipped (invalid SKILL.md): %s", full); + continue; + } + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: skill.content, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider, + source: "custom" + })); + } + if (disposers.length > 0) { + ctx.logger.info("netxops: kb pack skills registered count=%s root=%s provider=%s", disposers.length, root, provider); + } + return () => { + for (const dispose of disposers) + dispose(); + }; +} + // src/netx/kb-runtime.ts var STORE_KEY2 = Symbol.for("dsh-netxops.kb-store"); var ENV_KEYS = [ @@ -3709,33 +3794,35 @@ var Config = z.object({ groupOpsPublic: z.boolean().default(false), groupTopologyInPreset: z.boolean().default(false), groupTopologyPublic: z.boolean().default(false), - kbRoot: z.string().default("") + kbRoot: z.string().default(""), + groupKbInPreset: z.boolean().default(true), + groupKbPublic: z.boolean().default(false) }); function packageRoot() { - return join4(dirname3(fileURLToPath2(import.meta.url)), ".."); + return join5(dirname3(fileURLToPath2(import.meta.url)), ".."); } function resolveDshHome() { const fromEnv = process.env.DSH_HOME?.trim(); if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv; - return join4(homedir3(), ".dsh"); + return join5(homedir3(), ".dsh"); } function ensureAgentPresetInstalled(logger) { - const src = join4(packageRoot(), "presets", NETXOPS_PRESET_ID); - const composition = join4(src, "agent.cordis.yml"); + const src = join5(packageRoot(), "presets", NETXOPS_PRESET_ID); + const composition = join5(src, "agent.cordis.yml"); if (!existsSync3(composition)) { logger.warn("netxops: bundled preset missing at %s — skip user-preset install", src); return; } - const destParent = join4(resolveDshHome(), ".agent-presets"); - const dest = join4(destParent, NETXOPS_PRESET_ID); + const destParent = join5(resolveDshHome(), ".agent-presets"); + const dest = join5(destParent, NETXOPS_PRESET_ID); try { mkdirSync(destParent, { recursive: true }); if (existsSync3(dest)) { rmSync(dest, { recursive: true, force: true }); } cpSync(src, dest, { recursive: true }); - writeFileSync(join4(dest, ".dsh-netxops-managed"), `${new Date().toISOString()} + writeFileSync(join5(dest, ".dsh-netxops-managed"), `${new Date().toISOString()} `, "utf8"); logger.info("netxops: agent preset installed at %s", dest); } catch (error) { @@ -3825,7 +3912,9 @@ function apply(ctx, config = Config({})) { token, lang: current.lang, toolCallTimeoutMs: current.toolCallTimeoutMs, - groups + groups, + groupKbInPreset: current.groupKbInPreset !== false, + groupKbPublic: current.groupKbPublic === true }); const kb = resolveKbRoot(current.kbRoot ?? ""); publishKbContext(kb); @@ -3887,7 +3976,9 @@ function apply(ctx, config = Config({})) { ctx.inject(["skills"], (skillsCtx) => { let unregisterSkills; let unregisterKbSkill; + let unregisterKbPack; let generation2 = 0; + let packGeneration = 0; const remountPublicSkills = () => { const gen = ++generation2; unregisterSkills?.(); @@ -3914,20 +4005,44 @@ function apply(ctx, config = Config({})) { unregisterKbSkill = registerKbContextSkill(skillsCtx, snapshot); skillsCtx.logger.info("netxops: kb-context skill status=%s", snapshot.status); }; + const remountKbPack = () => { + const gen = ++packGeneration; + unregisterKbPack?.(); + unregisterKbPack = undefined; + const connection = getNetxConnection(); + const enabled = connection?.groupKbPublic === true; + registerKbPackSkills(skillsCtx, getKbContext(), { + enabled, + providerLabel: "netxops-kb-pack-public" + }).then((dispose) => { + if (gen !== packGeneration) { + dispose(); + return; + } + unregisterKbPack = dispose; + }).catch((error) => { + skillsCtx.logger.warn("netxops: public kb pack skill register failed: %s", error); + }); + }; remountPublicSkills(); remountKbSkill(); + remountKbPack(); const stopWatch = watchNetxConnection(() => { remountPublicSkills(); + remountKbPack(); }); const stopKbWatch = watchKbContext(() => { remountKbSkill(); + remountKbPack(); }); skillsCtx.effect(() => () => { generation2 += 1; + packGeneration += 1; stopWatch(); stopKbWatch(); unregisterSkills?.(); unregisterKbSkill?.(); + unregisterKbPack?.(); }, "netxops: dispose public skills"); }); ctx.inject(["connection"], (connCtx) => { diff --git a/package.json b/package.json index 78e9d1f..f1c0534 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-netxops", - "version": "0.1.29", - "description": "DeepSeek Harness Netx Ops: capability groups ops/topology + multi IM delivery + session export + knowledge-base MANIFEST", + "version": "0.1.30", + "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills", "license": "MIT", "type": "module", "private": false, diff --git a/presets/netxops/skills/README.md b/presets/netxops/skills/README.md index 1e18e80..83d5b5e 100644 --- a/presets/netxops/skills/README.md +++ b/presets/netxops/skills/README.md @@ -1,10 +1,14 @@ -# Mirrored from netx/skills 鈥?do not long-edit here +# Mirrored from netx/skills — do not long-edit here Canonical: `D:\project\chatgpt\netx\skills` -`powershell +```powershell powershell -File .\scripts\sync-skills-from-netx.ps1 -` +``` Runtime prefers `NETX_SKILLS_ROOT` or sibling `../netx/skills`. -Groups: **ops** (`netx-ops`) + **topology** (`netx-topology`). One group 鈫?one skill. +Groups: **ops** (`netx-ops`) + **topology** (`netx-topology`). One group ↔ one skill. + +**Keep netx-ops pure netx** (alarms / inventory / CLI). Knowledge-base triage belongs in +operator-subset `_skills/kb-*` packs registered by the plugin — do **not** write KB linkage +back into `netx-ops`. diff --git a/scripts/sync-skills-from-netx.ps1 b/scripts/sync-skills-from-netx.ps1 index db8e87c..c979546 100644 --- a/scripts/sync-skills-from-netx.ps1 +++ b/scripts/sync-skills-from-netx.ps1 @@ -43,6 +43,10 @@ powershell -File .\scripts\sync-skills-from-netx.ps1 Runtime prefers ``NETX_SKILLS_ROOT`` or sibling ``../netx/skills``. Groups: **ops** (``netx-ops``) + **topology** (``netx-topology``). One group ↔ one skill. + +**Keep netx-ops pure netx** (alarms / inventory / CLI). Knowledge-base triage belongs in +operator-subset ``_skills/kb-*`` packs registered by the plugin — do **not** write KB linkage +back into ``netx-ops``. "@ | Set-Content -Encoding utf8 (Join-Path $dst "README.md") Write-Host "Synced $src -> $dst" diff --git a/src/client/NetxopsCard.tsx b/src/client/NetxopsCard.tsx index 734bb44..3050e5c 100644 --- a/src/client/NetxopsCard.tsx +++ b/src/client/NetxopsCard.tsx @@ -549,6 +549,20 @@ export function NetxopsCard(props: NetxopsCardProps) { ?

{state.kbStatus.realRoot}

: null} + { + props.edit('groupKbInPreset', checked ? 'true' : 'false') + }} + onEditPublic={(checked) => { + props.edit('groupKbPublic', checked ? 'true' : 'false') + }} + />
diff --git a/src/client/controller.ts b/src/client/controller.ts index 880f93f..27a8af2 100644 --- a/src/client/controller.ts +++ b/src/client/controller.ts @@ -57,6 +57,10 @@ export interface NetxopsSettings { nmsProvider?: string /** Operator-subset knowledge package root (Host-local path). */ kbRoot?: string + /** Mount `_skills/kb-*` into Netx Ops preset. */ + groupKbInPreset?: boolean + /** Publish `_skills/kb-*` to other presets. */ + groupKbPublic?: boolean } interface CredentialState { @@ -75,6 +79,8 @@ export interface NetxopsCardState extends CardShell { groupTopologyPublic: CardFieldState nmsProvider: CardFieldState kbRoot: CardFieldState + groupKbInPreset: CardFieldState + groupKbPublic: CardFieldState alarmPushEnabled: CardFieldState alarmDeliverDsh: CardFieldState alarmDeliverIm: CardFieldState @@ -172,6 +178,8 @@ export class NetxopsCardController { booleanField('groupOpsPublic'), booleanField('groupTopologyInPreset'), booleanField('groupTopologyPublic'), + booleanFieldPersistFalse('groupKbInPreset'), + booleanField('groupKbPublic'), booleanField('alarmPushEnabled'), booleanFieldPersistFalse('alarmDeliverDsh'), booleanField('alarmDeliverIm'), @@ -519,6 +527,8 @@ export class NetxopsCardController { groupOpsPublic: this.form.field('groupOpsPublic'), groupTopologyInPreset: this.form.field('groupTopologyInPreset'), groupTopologyPublic: this.form.field('groupTopologyPublic'), + groupKbInPreset: this.form.field('groupKbInPreset'), + groupKbPublic: this.form.field('groupKbPublic'), alarmPushEnabled: this.form.field('alarmPushEnabled'), alarmDeliverDsh: this.form.field('alarmDeliverDsh'), alarmDeliverIm: this.form.field('alarmDeliverIm'), diff --git a/src/client/locales.ts b/src/client/locales.ts index d548d2c..b889061 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -17,6 +17,7 @@ export type NetxopsLocaleKey = | 'nmsProviderHint' | 'groupOps' | 'groupTopology' + | 'groupKb' | 'groupInPreset' | 'groupPublic' | 'kbRoot' @@ -89,10 +90,11 @@ export const en: Record = { nmsProviderHint: 'zte-ume', groupOps: 'ops', groupTopology: 'topology', + groupKb: 'Knowledge skills', groupInPreset: 'In Netx Ops preset', groupPublic: 'Publish to other presets', kbRoot: 'Package root', - kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Browse auto-saves.', + kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Browse auto-saves. Pack skills load from _skills/ when hasSkills.', kbBrowse: 'Browse…', kbBrowseUnavailable: 'Directory picker unavailable — paste an absolute folder path, then Save.', kbBrowseFailed: 'Browse failed: {detail}', @@ -162,10 +164,11 @@ export const zh: Record = { nmsProviderHint: 'zte-ume', groupOps: 'ops', groupTopology: 'topology', + groupKb: '知识库技能', groupInPreset: '在 Netx Ops 预设中启用', groupPublic: '对其他预设公开', kbRoot: '知识包根目录', - kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存。', + kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存;hasSkills 时加载 _skills/。', kbBrowse: '浏览…', kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对目录路径后点保存。', kbBrowseFailed: '浏览失败:{detail}', diff --git a/src/index.ts b/src/index.ts index 9c0a168..1a522b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,9 +12,10 @@ * (`GET /api/netxops.sessions.export`) for HQ analysis — including cloud Hosts, * where the browser receives the archive. * - * Knowledge base (P1): settings `kbRoot` → locate/validate MANIFEST.json → - * `process.env.KB_*` + dynamic `kb-context` skill. Unconfigured/error degrades - * to pure netx (no invented operator). + * Knowledge base: settings `kbRoot` → locate/validate MANIFEST.json → + * `process.env.KB_*` + dynamic `kb-context` + optional `_skills/kb-*` packs + * (default Netx Ops preset; optional public). Unconfigured/error degrades to + * pure netx (no invented operator). * * @module dsh-netxops */ @@ -46,6 +47,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 { registerKbPackSkills } from './netx/kb-pack-skills.ts' import { applyKbEnv, getKbContext, @@ -132,9 +134,13 @@ export interface Config { /** * Absolute (or Host-local) path to an operator-subset knowledge package. * Empty = unconfigured. Plugin locates `MANIFEST.json` (≤3 levels) and - * injects `KB_*` + `kb-context` skill. + * injects `KB_*` + `kb-context` skill + optional `_skills` packs. */ kbRoot: string + /** Mount subset `_skills/kb-*` into the Netx Ops preset (default on). */ + groupKbInPreset: boolean + /** Publish subset `_skills/kb-*` to other presets (default off). */ + groupKbPublic: boolean } export const Config: z = z.object({ @@ -155,6 +161,8 @@ export const Config: z = z.object({ groupTopologyInPreset: z.boolean().default(false), groupTopologyPublic: z.boolean().default(false), kbRoot: z.string().default(''), + groupKbInPreset: z.boolean().default(true), + groupKbPublic: z.boolean().default(false), }) /** Package root (parent of `lib/` or `src/` depending on launch). */ @@ -317,6 +325,8 @@ export function apply(ctx: Context, config: Config = Config({})): void { lang: current.lang, toolCallTimeoutMs: current.toolCallTimeoutMs, groups, + groupKbInPreset: current.groupKbInPreset !== false, + groupKbPublic: current.groupKbPublic === true, }) const kb = resolveKbRoot(current.kbRoot ?? '') publishKbContext(kb) @@ -400,7 +410,9 @@ export function apply(ctx: Context, config: Config = Config({})): void { ctx.inject(['skills'], (skillsCtx) => { let unregisterSkills: (() => void) | undefined let unregisterKbSkill: (() => void) | undefined + let unregisterKbPack: (() => void) | undefined let generation = 0 + let packGeneration = 0 const remountPublicSkills = (): void => { const gen = ++generation unregisterSkills?.() @@ -432,16 +444,44 @@ export function apply(ctx: Context, config: Config = Config({})): void { snapshot.status, ) } + const remountKbPack = (): void => { + const gen = ++packGeneration + unregisterKbPack?.() + unregisterKbPack = undefined + const connection = getNetxConnection() + const enabled = connection?.groupKbPublic === true + void registerKbPackSkills(skillsCtx, getKbContext(), { + enabled, + providerLabel: 'netxops-kb-pack-public', + }).then((dispose) => { + if (gen !== packGeneration) { + dispose() + return + } + unregisterKbPack = dispose + }).catch((error) => { + skillsCtx.logger.warn('netxops: public kb pack skill register failed: %s', error) + }) + } remountPublicSkills() remountKbSkill() - const stopWatch = watchNetxConnection(() => { remountPublicSkills() }) - const stopKbWatch = watchKbContext(() => { remountKbSkill() }) + remountKbPack() + const stopWatch = watchNetxConnection(() => { + remountPublicSkills() + remountKbPack() + }) + const stopKbWatch = watchKbContext(() => { + remountKbSkill() + remountKbPack() + }) skillsCtx.effect(() => () => { generation += 1 + packGeneration += 1 stopWatch() stopKbWatch() unregisterSkills?.() unregisterKbSkill?.() + unregisterKbPack?.() }, 'netxops: dispose public skills') }) diff --git a/src/netx/group-skills.ts b/src/netx/group-skills.ts index 3f28441..7cd587a 100644 --- a/src/netx/group-skills.ts +++ b/src/netx/group-skills.ts @@ -21,6 +21,8 @@ interface ParsedSkill { directory: string } +export type { ParsedSkill } + /** * Package `presets/netxops/skills` root, or a shared netx skills checkout. * @@ -95,7 +97,10 @@ function stripQuotes(value: string): string { return value } -async function loadSkillBundle(dir: string): Promise { +/** + * Load one skill directory (`SKILL.md` + frontmatter). Exported for KB pack loader. + */ +export async function loadSkillBundle(dir: string): Promise { const skillPath = join(dir, 'SKILL.md') let raw: string try { diff --git a/src/netx/group-tools-plugin.ts b/src/netx/group-tools-plugin.ts index f679de8..0ea01da 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 { registerKbPackSkills } from './kb-pack-skills.ts' import { getKbContext, watchKbContext } from './kb-runtime.ts' import { getNetxConnection, watchNetxConnection } from './runtime.ts' import { registerNetxTools } from './tools.ts' @@ -75,6 +76,8 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt ctx.inject(['skills'], (skillsCtx) => { let unregisterKbSkill: (() => void) | undefined + let unregisterKbPack: (() => void) | undefined + let packGeneration = 0 const remountSkills = (): void => { const gen = ++skillGeneration unregisterSkills?.() @@ -96,19 +99,48 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt unregisterKbSkill = undefined unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext()) } + const remountKbPack = (): void => { + const gen = ++packGeneration + unregisterKbPack?.() + unregisterKbPack = undefined + const connection = getNetxConnection() + const enabled = connection?.groupKbInPreset !== false + void registerKbPackSkills(skillsCtx, getKbContext(), { + enabled, + providerLabel: `${options.name}-kb-pack`, + }).then((dispose) => { + if (gen !== packGeneration) { + dispose() + return + } + unregisterKbPack = dispose + }).catch((error) => { + skillsCtx.logger.warn('%s: kb pack skill register failed: %s', options.name, error) + }) + } remountSkills() remountKbSkill() - const stopSkillWatch = watchNetxConnection(() => { remountSkills() }) - const stopKbWatch = watchKbContext(() => { remountKbSkill() }) + remountKbPack() + const stopSkillWatch = watchNetxConnection(() => { + remountSkills() + remountKbPack() + }) + const stopKbWatch = watchKbContext(() => { + remountKbSkill() + remountKbPack() + }) skillsCtx.effect(() => () => { skillGeneration += 1 + packGeneration += 1 stopSkillWatch() stopKbWatch() unregisterSkills?.() unregisterSkills = undefined unregisterKbSkill?.() unregisterKbSkill = undefined + unregisterKbPack?.() + unregisterKbPack = undefined }, `${options.name}: dispose skills`) }) diff --git a/src/netx/kb-context-skill.ts b/src/netx/kb-context-skill.ts index 6119775..052776c 100644 --- a/src/netx/kb-context-skill.ts +++ b/src/netx/kb-context-skill.ts @@ -32,7 +32,11 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, '', - 'Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`.', + 'Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.', + '', + 'Business playbooks (kb-troubleshoot, kb-retrieve, …) register from', + `${snapshot.realRoot}/_skills/ when hasSkills is true — use those skills for KB triage;`, + 'keep netx-ops for live netx evidence only.', ].join('\n'), } } @@ -47,7 +51,7 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string content: [ '## Knowledge base (unavailable)', '', - `kbStatus=\`${snapshot.status}\`. ${reason}`, + `kbStatus=${snapshot.status}. ${reason}`, '', '**Do not invent an operator, country, or KB paths.**', 'Use only netx tools / live evidence (alarms, inventory, CLI, topology).', diff --git a/src/netx/kb-manifest.ts b/src/netx/kb-manifest.ts index 08011cb..131e199 100644 --- a/src/netx/kb-manifest.ts +++ b/src/netx/kb-manifest.ts @@ -14,12 +14,13 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' -/** Known content flags; unknown keys are preserved when boolean. */ +/** Known content flags (MANIFEST contract v1.0); unknown boolean keys may be preserved. */ export interface KbContentFlags { - regions: boolean - theory: boolean - packet: boolean - skills: boolean + hasRegions: boolean + hasTheory: boolean + hasPacket: boolean + hasCommon: boolean + hasSkills: boolean [key: string]: boolean } @@ -37,10 +38,20 @@ export interface KbSnapshot { } const EMPTY_CONTENT: KbContentFlags = { - regions: false, - theory: false, - packet: false, - skills: false, + hasRegions: false, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false, +} + +/** Legacy short keys → contract `has*` (one-release compat). */ +const LEGACY_CONTENT_KEY: Record = { + regions: 'hasRegions', + theory: 'hasTheory', + packet: 'hasPacket', + skills: 'hasSkills', + common: 'hasCommon', } /** Empty / unconfigured snapshot (kbRoot blank). */ @@ -165,8 +176,17 @@ function parseContent(raw: unknown): KbContentFlags { if (typeof raw !== 'object' || Array.isArray(raw)) { throw new Error('MANIFEST content must be an object') } - for (const [key, value] of Object.entries(raw as Record)) { - out[key] = value === true + const row = raw as Record + const present = new Set(Object.keys(row)) + for (const [key, value] of Object.entries(row)) { + const on = value === true + const mapped = LEGACY_CONTENT_KEY[key] + if (mapped !== undefined) { + // Short keys only fill has* when the contract key is absent (no dual truth). + if (on && !present.has(mapped)) out[mapped] = true + continue + } + out[key] = on } return out } diff --git a/src/netx/kb-pack-skills.ts b/src/netx/kb-pack-skills.ts new file mode 100644 index 0000000..03183d3 --- /dev/null +++ b/src/netx/kb-pack-skills.ts @@ -0,0 +1,106 @@ +/** + * Register operator-subset knowledge pack skills (_skills dir) into DSH when KB is configured. + */ + +import { readdir, stat } from 'node:fs/promises' +import { join } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import type { KbSnapshot } from './kb-manifest.ts' +import { loadSkillBundle } from './group-skills.ts' + +export type KbSkillPlane = 'preset' | 'public' + +/** True when MANIFEST says the package ships skill packs. */ +export function kbPackSkillsEnabled(snapshot: KbSnapshot): boolean { + return snapshot.status === 'configured' && snapshot.content.hasSkills === true +} + +/** + * Register every valid skill under realRoot/_skills. + * No-op when not configured / hasSkills false / directory missing. + * @returns disposer that unregisters all pack skills. + */ +export async function registerKbPackSkills( + ctx: Context, + snapshot: KbSnapshot, + options: { + enabled: boolean + providerLabel?: string + }, +): Promise<() => void> { + if (!options.enabled || !kbPackSkillsEnabled(snapshot)) { + return () => {} + } + + const skillsApi = (ctx as { skills?: { register: (skill: { + name: string + description: string + content: string + path?: string + resourceBase?: { kind: 'directory'; path: string } + provider?: string + source: string + }) => () => void } }).skills + if (!skillsApi || typeof skillsApi.register !== 'function') { + return () => {} + } + + const root = join(snapshot.realRoot, '_skills') + let entries: string[] + try { + entries = await readdir(root) + } catch { + ctx.logger.info('netxops: kb pack skills dir missing at %s — skip', root) + return () => {} + } + + const disposers: Array<() => void> = [] + const provider = options.providerLabel ?? 'netxops-kb-pack' + for (const entry of entries) { + const full = join(root, entry) + let isDir = false + try { + isDir = (await stat(full)).isDirectory() + } catch { + continue + } + if (!isDir) continue + let skill + try { + skill = await loadSkillBundle(full) + } catch (error) { + ctx.logger.warn( + 'netxops: kb pack skill load failed at %s: %s', + full, + error instanceof Error ? error.message : String(error), + ) + continue + } + if (!skill) { + ctx.logger.warn('netxops: kb pack skill skipped (invalid SKILL.md): %s', full) + continue + } + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: skill.content, + path: skill.path, + resourceBase: { kind: 'directory', path: skill.directory }, + provider, + source: 'custom', + })) + } + + if (disposers.length > 0) { + ctx.logger.info( + 'netxops: kb pack skills registered count=%s root=%s provider=%s', + disposers.length, + root, + provider, + ) + } + + return () => { + for (const dispose of disposers) dispose() + } +} diff --git a/src/netx/tools.ts b/src/netx/tools.ts index b3d8f77..fb255be 100644 --- a/src/netx/tools.ts +++ b/src/netx/tools.ts @@ -22,6 +22,13 @@ export interface NetxToolConnection { toolCallTimeoutMs: number /** Capability-group exposure; omitted → package defaults. */ groups?: NetxCapabilityGroups + /** + * Mount operator-subset `_skills/kb-*` into the Netx Ops preset (default true). + * Gated also by KB configured + content.hasSkills. + */ + groupKbInPreset?: boolean + /** Mount kb pack skills on the host public skill layer (default false). */ + groupKbPublic?: boolean } type Handler = (client: NetxClient, args: NetxJson, signal?: AbortSignal) => Promise diff --git a/test/kb-manifest.test.mjs b/test/kb-manifest.test.mjs index 9fdfd98..79748cb 100644 --- a/test/kb-manifest.test.mjs +++ b/test/kb-manifest.test.mjs @@ -20,7 +20,7 @@ function validManifest(overrides = {}) { packageType: 'operator-subset', operator: { name: 'IOH', country: 'ID' }, version: '2026.09.01', - content: { regions: true, theory: false, packet: true }, + content: { hasRegions: true, hasTheory: false, hasPacket: true }, ...overrides, }, null, 2) } @@ -50,10 +50,12 @@ test('direct MANIFEST at kbRoot', () => { assert.equal(snap.operatorName, 'IOH') assert.equal(snap.country, 'ID') assert.equal(snap.version, '2026.09.01') - assert.equal(snap.content.regions, true) - assert.equal(snap.content.theory, false) - assert.equal(snap.content.packet, true) - assert.equal(snap.content.skills, false) + assert.equal(snap.content.hasRegions, true) + assert.equal(snap.content.hasTheory, false) + assert.equal(snap.content.hasPacket, true) + assert.equal(snap.content.hasSkills, false) + assert.equal(snap.content.hasCommon, false) + assert.equal('regions' in snap.content, false) }) }) @@ -118,11 +120,28 @@ test('wrong schemaVersion / packageType rejected', () => { ) }) +test('legacy short content keys map to has*; no dual truth', () => { + const parsed = parseManifest(validManifest({ content: { regions: true, packet: true } })) + assert.equal(parsed.content.hasRegions, true) + assert.equal(parsed.content.hasPacket, true) + assert.equal(parsed.content.hasTheory, false) + assert.equal('regions' in parsed.content, false) + assert.equal('packet' in parsed.content, false) +}) + +test('contract has* wins over legacy short key', () => { + const parsed = parseManifest(validManifest({ + content: { hasRegions: false, regions: true }, + })) + assert.equal(parsed.content.hasRegions, false) + assert.equal('regions' in parsed.content, false) +}) + test('content missing keys default false; missing content object fails', () => { - const parsed = parseManifest(validManifest({ content: { regions: true } })) - assert.equal(parsed.content.regions, true) - assert.equal(parsed.content.theory, false) - assert.equal(parsed.content.packet, false) + const parsed = parseManifest(validManifest({ content: { hasRegions: true } })) + assert.equal(parsed.content.hasRegions, true) + assert.equal(parsed.content.hasTheory, false) + assert.equal(parsed.content.hasPacket, false) const withoutContent = JSON.stringify({ schemaVersion: '1.0', packageType: 'operator-subset', @@ -170,5 +189,6 @@ test('contract content flags hasRegions etc. are accepted', () => { assert.equal(snap.status, 'configured') assert.equal(snap.content.hasRegions, true) assert.equal(snap.content.hasSkills, true) + assert.equal(snap.content.hasCommon, true) }) }) diff --git a/test/kb-pack-skills.test.mjs b/test/kb-pack-skills.test.mjs new file mode 100644 index 0000000..dfa9119 --- /dev/null +++ b/test/kb-pack-skills.test.mjs @@ -0,0 +1,95 @@ +/** + * KB pack skill loader from realRoot/_skills. + */ + +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' + +import { kbPackSkillsEnabled, registerKbPackSkills } from '../src/netx/kb-pack-skills.ts' + +async function withTemp(run) { + const root = mkdtempSync(join(tmpdir(), 'netxops-kb-pack-')) + try { + await run(root) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +function configuredSnap(realRoot, content = {}) { + return { + status: 'configured', + realRoot, + operatorName: 'IOH', + country: 'ID', + version: '1', + content: { + hasRegions: false, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: true, + ...content, + }, + errorMessage: '', + } +} + +test('kbPackSkillsEnabled requires configured + hasSkills', () => { + assert.equal(kbPackSkillsEnabled(configuredSnap('/x')), true) + assert.equal(kbPackSkillsEnabled(configuredSnap('/x', { hasSkills: false })), false) + assert.equal(kbPackSkillsEnabled({ + ...configuredSnap('/x'), + status: 'unconfigured', + }), false) +}) + +test('registerKbPackSkills loads _skills/*/SKILL.md', async () => { + await withTemp(async (root) => { + const skillDir = join(root, '_skills', 'kb-troubleshoot') + mkdirSync(skillDir, { recursive: true }) + writeFileSync(join(skillDir, 'SKILL.md'), `--- +name: kb-troubleshoot +description: KB triage playbook +--- + +# kb-troubleshoot +`, 'utf8') + + const registered = [] + const ctx = { + logger: { info() {}, warn() {} }, + skills: { + register(skill) { + registered.push(skill) + return () => {} + }, + }, + } + + const dispose = await registerKbPackSkills(ctx, configuredSnap(root), { enabled: true }) + assert.equal(registered.length, 1) + assert.equal(registered[0].name, 'kb-troubleshoot') + assert.equal(registered[0].provider, 'netxops-kb-pack') + dispose() + }) +}) + +test('registerKbPackSkills no-op when disabled or hasSkills false', async () => { + const registered = [] + const ctx = { + logger: { info() {}, warn() {} }, + skills: { + register(skill) { + registered.push(skill) + return () => {} + }, + }, + } + await registerKbPackSkills(ctx, configuredSnap('/nope'), { enabled: false }) + await registerKbPackSkills(ctx, configuredSnap('/nope', { hasSkills: false }), { enabled: true }) + assert.equal(registered.length, 0) +}) diff --git a/test/kb-runtime.test.mjs b/test/kb-runtime.test.mjs index 00e54f2..df45f32 100644 --- a/test/kb-runtime.test.mjs +++ b/test/kb-runtime.test.mjs @@ -25,7 +25,13 @@ test('publishKbContext updates store', () => { operatorName: 'IOH', country: 'ID', version: '1', - content: { regions: true, theory: false, packet: false, skills: false }, + content: { + hasRegions: true, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false, + }, errorMessage: '', }) const snap = getKbContext() @@ -34,6 +40,7 @@ test('publishKbContext updates store', () => { applyKbEnv(snap) assert.equal(process.env.KB_OPERATOR, 'IOH') assert.equal(process.env.KB_COUNTRY, 'ID') - assert.ok(process.env.KB_CONTENT?.includes('"regions":true')) + assert.ok(process.env.KB_CONTENT?.includes('"hasRegions":true')) + assert.equal(process.env.KB_CONTENT?.includes('"regions"'), false) resetKbContext() })