Register KB pack skills from operator-subset _skills (P2).

Load hasSkills packs onto preset/public planes with groupKb toggles; unify content has* keys and keep netx-ops pure.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-13 11:46:31 +08:00
parent fcdd11ac59
commit 2caa3c98d7
26 changed files with 1122 additions and 106 deletions

View file

@ -30,3 +30,6 @@
groupOpsPublic: false
groupTopologyInPreset: false
groupTopologyPublic: false
kbRoot: ''
groupKbInPreset: true
groupKbPublic: false

View file

@ -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)://<apiUrl>/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-<host>-<utc>.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 / 「能否登录」.

View file

@ -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`.

View file

@ -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(() => () => {

View file

@ -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(() => () => {

View file

@ -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(() => () => {

View file

@ -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(() => () => {

View file

@ -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(() => () => {

View file

@ -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}",

View file

@ -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) => {

View file

@ -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,

View file

@ -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`.

View file

@ -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"

View file

@ -549,6 +549,20 @@ export function NetxopsCard(props: NetxopsCardProps) {
? <p className="dsh-nx-hint">{state.kbStatus.realRoot}</p>
: null}
</div>
<CapabilityGroupBlock
title={t('groupKb')}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupKbInPreset}
published={state.groupKbPublic}
disabled={disabled}
onEditInPreset={(checked) => {
props.edit('groupKbInPreset', checked ? 'true' : 'false')
}}
onEditPublic={(checked) => {
props.edit('groupKbPublic', checked ? 'true' : 'false')
}}
/>
</div>
<div className="dsh-nx-settings-card">

View file

@ -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'),

View file

@ -17,6 +17,7 @@ export type NetxopsLocaleKey =
| 'nmsProviderHint'
| 'groupOps'
| 'groupTopology'
| 'groupKb'
| 'groupInPreset'
| 'groupPublic'
| 'kbRoot'
@ -89,10 +90,11 @@ export const en: Record<NetxopsLocaleKey, string> = {
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<NetxopsLocaleKey, string> = {
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}',

View file

@ -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<Config> = z.object({
@ -155,6 +161,8 @@ export const Config: z<Config> = 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')
})

View file

@ -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<ParsedSkill | null> {
/**
* Load one skill directory (`SKILL.md` + frontmatter). Exported for KB pack loader.
*/
export async function loadSkillBundle(dir: string): Promise<ParsedSkill | null> {
const skillPath = join(dir, 'SKILL.md')
let raw: string
try {

View file

@ -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`)
})

View file

@ -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).',

View file

@ -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<string, keyof KbContentFlags> = {
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<string, unknown>)) {
out[key] = value === true
const row = raw as Record<string, unknown>
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
}

106
src/netx/kb-pack-skills.ts Normal file
View file

@ -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()
}
}

View file

@ -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<NetxJson>

View file

@ -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)
})
})

View file

@ -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)
})

View file

@ -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()
})