mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-09 05:10:45 +08:00
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:
parent
fcdd11ac59
commit
2caa3c98d7
26 changed files with 1122 additions and 106 deletions
|
|
@ -30,3 +30,6 @@
|
|||
groupOpsPublic: false
|
||||
groupTopologyInPreset: false
|
||||
groupTopologyPublic: false
|
||||
kbRoot: ''
|
||||
groupKbInPreset: true
|
||||
groupKbPublic: false
|
||||
|
|
|
|||
|
|
@ -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 / 「能否登录」.
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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(() => () => {
|
||||
|
|
|
|||
|
|
@ -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(() => () => {
|
||||
|
|
|
|||
|
|
@ -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(() => () => {
|
||||
|
|
|
|||
|
|
@ -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(() => () => {
|
||||
|
|
|
|||
|
|
@ -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(() => () => {
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
151
lib/index.js
151
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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
52
src/index.ts
52
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<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')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
|
|
|
|||
|
|
@ -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
106
src/netx/kb-pack-skills.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
95
test/kb-pack-skills.test.mjs
Normal file
95
test/kb-pack-skills.test.mjs
Normal 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)
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue