diff --git a/docs/KNOWLEDGE_BASE.md b/docs/KNOWLEDGE_BASE.md index f2f8c72..68b0b14 100644 --- a/docs/KNOWLEDGE_BASE.md +++ b/docs/KNOWLEDGE_BASE.md @@ -8,7 +8,8 @@ Operator-subset knowledge packages are wired through **netxops settings**, not i |---------|----------------| | `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 | +| `_skills/kb-*` | Shared operator KB triage / ingest / retrieve / export / package | +| `paths.localSkills` | Operator-local skills (alongside `_skills/`; same toggles) | ## Setup @@ -26,6 +27,7 @@ Operator-subset knowledge packages are wired through **netxops settings**, not i | `operator.name` / `operator.country` | required non-empty strings | | `version` | required non-empty string | | `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 | +| `paths.*` | optional relative dirs under package root (packaging-generated). Plugin uses `skills` (default `_skills`) and `localSkills`; missing / blank values skipped | Location: `${kbRoot}/MANIFEST.json`, else recurse ≤3 levels and **exactly one** hit. Zero or multiple → error. @@ -37,19 +39,20 @@ Authoritative packaging prose lives in the workspace contract notes (`插件` / |---------|--------| | `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 | +| Skills from pack | 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: +When `KB_STATUS=configured` and `content.hasSkills=true`, the plugin scans (same register rules / toggles / `provider: netxops-kb-pack`): ```text -${realRoot}/_skills/*/SKILL.md +${realRoot}/${paths.skills || '_skills'}/*/SKILL.md +${realRoot}/${paths.localSkills}/*/SKILL.md # only when paths.localSkills is set ``` -and registers each valid frontmatter skill (`provider: netxops-kb-pack`). +`paths.localSkills` missing, blank, or an empty/missing directory → skipped (ops continues). Invalid `SKILL.md` bundles are skipped the same way as under `_skills/`. | Setting | Default | Plane | |---------|---------|-------| @@ -65,6 +68,6 @@ Invalid or missing `_skills` directories are skipped (ops continues). | Empty `kbRoot` | `unconfigured` | Pure netx; no invented operator; no kb-* packs | | Missing / ambiguous / invalid MANIFEST | `error` | Pure netx; badge shows error detail | | Valid MANIFEST, `hasSkills=false` | `configured` | Identity only (`kb-context` + env) | -| Valid + `hasSkills` + toggles | `configured` | Identity + `_skills/kb-*` on the enabled plane(s) | +| Valid + `hasSkills` + toggles | `configured` | Identity + pack skills (`_skills` / `paths.skills`) and optional `paths.localSkills` on the enabled plane(s) | **Never** invent an operator when status is not `configured`. diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index a87438a..115ef0f 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -240,7 +240,8 @@ function skillBody(snapshot) { "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;`, + `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, + "plus paths.localSkills when present — use those skills for KB triage;", "keep netx-ops for live netx evidence only." ].join(` `) @@ -282,24 +283,29 @@ 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"); +function resolveKbSkillsRoot(snapshot) { + const rel = snapshot.paths.skills?.trim() || "_skills"; + return join2(snapshot.realRoot, rel); +} +function resolveKbLocalSkillsRoot(snapshot) { + const rel = snapshot.paths.localSkills?.trim(); + if (!rel) + return null; + return join2(snapshot.realRoot, rel); +} +async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; try { entries = await readdir2(root); } catch { - ctx.logger.info("netxops: kb pack skills dir missing at %s — skip", root); - return () => {}; + if (options.logMissing) { + ctx.logger.info("netxops: kb %s dir missing at %s — skip", options.label, root); + } + return []; } + if (entries.length === 0) + return []; const disposers = []; - const provider = options.providerLabel ?? "netxops-kb-pack"; for (const entry of entries) { const full = join2(root, entry); let isDir = false; @@ -314,11 +320,11 @@ async function registerKbPackSkills(ctx, snapshot, options) { 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)); + ctx.logger.warn("netxops: kb %s skill load failed at %s: %s", options.label, full, error instanceof Error ? error.message : String(error)); continue; } if (!skill) { - ctx.logger.warn("netxops: kb pack skill skipped (invalid SKILL.md): %s", full); + ctx.logger.warn("netxops: kb %s skill skipped (invalid SKILL.md): %s", options.label, full); continue; } disposers.push(skillsApi.register({ @@ -331,8 +337,32 @@ async function registerKbPackSkills(ctx, snapshot, options) { source: "custom" })); } + return disposers; +} +async function registerKbPackSkills(ctx, snapshot, options) { + if (!options.enabled || !kbPackSkillsEnabled(snapshot)) { + return () => {}; + } + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const provider = options.providerLabel ?? "netxops-kb-pack"; + const disposers = []; + const skillsRoot = resolveKbSkillsRoot(snapshot); + disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { + logMissing: true, + label: "pack" + })); + const localRoot = resolveKbLocalSkillsRoot(snapshot); + if (localRoot) { + disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, { + logMissing: false, + label: "local" + })); + } if (disposers.length > 0) { - ctx.logger.info("netxops: kb pack skills registered count=%s root=%s provider=%s", disposers.length, root, provider); + ctx.logger.info("netxops: kb pack skills registered count=%s skillsRoot=%s localSkills=%s provider=%s", disposers.length, skillsRoot, localRoot ?? "(none)", provider); } return () => { for (const dispose of disposers) @@ -356,6 +386,7 @@ function unconfiguredKbSnapshot() { country: "", version: "", content: { ...EMPTY_CONTENT }, + paths: {}, errorMessage: "" }; } @@ -372,7 +403,12 @@ function store() { return current; } function getKbContext() { - return { ...store().snapshot, content: { ...store().snapshot.content } }; + const snap = store().snapshot; + return { + ...snap, + content: { ...snap.content }, + paths: { ...snap.paths } + }; } function watchKbContext(listener) { const state = store(); diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index cea2afb..0ba544c 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -240,7 +240,8 @@ function skillBody(snapshot) { "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;`, + `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, + "plus paths.localSkills when present — use those skills for KB triage;", "keep netx-ops for live netx evidence only." ].join(` `) @@ -282,24 +283,29 @@ 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"); +function resolveKbSkillsRoot(snapshot) { + const rel = snapshot.paths.skills?.trim() || "_skills"; + return join2(snapshot.realRoot, rel); +} +function resolveKbLocalSkillsRoot(snapshot) { + const rel = snapshot.paths.localSkills?.trim(); + if (!rel) + return null; + return join2(snapshot.realRoot, rel); +} +async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; try { entries = await readdir2(root); } catch { - ctx.logger.info("netxops: kb pack skills dir missing at %s — skip", root); - return () => {}; + if (options.logMissing) { + ctx.logger.info("netxops: kb %s dir missing at %s — skip", options.label, root); + } + return []; } + if (entries.length === 0) + return []; const disposers = []; - const provider = options.providerLabel ?? "netxops-kb-pack"; for (const entry of entries) { const full = join2(root, entry); let isDir = false; @@ -314,11 +320,11 @@ async function registerKbPackSkills(ctx, snapshot, options) { 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)); + ctx.logger.warn("netxops: kb %s skill load failed at %s: %s", options.label, full, error instanceof Error ? error.message : String(error)); continue; } if (!skill) { - ctx.logger.warn("netxops: kb pack skill skipped (invalid SKILL.md): %s", full); + ctx.logger.warn("netxops: kb %s skill skipped (invalid SKILL.md): %s", options.label, full); continue; } disposers.push(skillsApi.register({ @@ -331,8 +337,32 @@ async function registerKbPackSkills(ctx, snapshot, options) { source: "custom" })); } + return disposers; +} +async function registerKbPackSkills(ctx, snapshot, options) { + if (!options.enabled || !kbPackSkillsEnabled(snapshot)) { + return () => {}; + } + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const provider = options.providerLabel ?? "netxops-kb-pack"; + const disposers = []; + const skillsRoot = resolveKbSkillsRoot(snapshot); + disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { + logMissing: true, + label: "pack" + })); + const localRoot = resolveKbLocalSkillsRoot(snapshot); + if (localRoot) { + disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, { + logMissing: false, + label: "local" + })); + } if (disposers.length > 0) { - ctx.logger.info("netxops: kb pack skills registered count=%s root=%s provider=%s", disposers.length, root, provider); + ctx.logger.info("netxops: kb pack skills registered count=%s skillsRoot=%s localSkills=%s provider=%s", disposers.length, skillsRoot, localRoot ?? "(none)", provider); } return () => { for (const dispose of disposers) @@ -356,6 +386,7 @@ function unconfiguredKbSnapshot() { country: "", version: "", content: { ...EMPTY_CONTENT }, + paths: {}, errorMessage: "" }; } @@ -372,7 +403,12 @@ function store() { return current; } function getKbContext() { - return { ...store().snapshot, content: { ...store().snapshot.content } }; + const snap = store().snapshot; + return { + ...snap, + content: { ...snap.content }, + paths: { ...snap.paths } + }; } function watchKbContext(listener) { const state = store(); diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 6de5804..48185e0 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -240,7 +240,8 @@ function skillBody(snapshot) { "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;`, + `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, + "plus paths.localSkills when present — use those skills for KB triage;", "keep netx-ops for live netx evidence only." ].join(` `) @@ -282,24 +283,29 @@ 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"); +function resolveKbSkillsRoot(snapshot) { + const rel = snapshot.paths.skills?.trim() || "_skills"; + return join2(snapshot.realRoot, rel); +} +function resolveKbLocalSkillsRoot(snapshot) { + const rel = snapshot.paths.localSkills?.trim(); + if (!rel) + return null; + return join2(snapshot.realRoot, rel); +} +async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; try { entries = await readdir2(root); } catch { - ctx.logger.info("netxops: kb pack skills dir missing at %s — skip", root); - return () => {}; + if (options.logMissing) { + ctx.logger.info("netxops: kb %s dir missing at %s — skip", options.label, root); + } + return []; } + if (entries.length === 0) + return []; const disposers = []; - const provider = options.providerLabel ?? "netxops-kb-pack"; for (const entry of entries) { const full = join2(root, entry); let isDir = false; @@ -314,11 +320,11 @@ async function registerKbPackSkills(ctx, snapshot, options) { 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)); + ctx.logger.warn("netxops: kb %s skill load failed at %s: %s", options.label, full, error instanceof Error ? error.message : String(error)); continue; } if (!skill) { - ctx.logger.warn("netxops: kb pack skill skipped (invalid SKILL.md): %s", full); + ctx.logger.warn("netxops: kb %s skill skipped (invalid SKILL.md): %s", options.label, full); continue; } disposers.push(skillsApi.register({ @@ -331,8 +337,32 @@ async function registerKbPackSkills(ctx, snapshot, options) { source: "custom" })); } + return disposers; +} +async function registerKbPackSkills(ctx, snapshot, options) { + if (!options.enabled || !kbPackSkillsEnabled(snapshot)) { + return () => {}; + } + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const provider = options.providerLabel ?? "netxops-kb-pack"; + const disposers = []; + const skillsRoot = resolveKbSkillsRoot(snapshot); + disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { + logMissing: true, + label: "pack" + })); + const localRoot = resolveKbLocalSkillsRoot(snapshot); + if (localRoot) { + disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, { + logMissing: false, + label: "local" + })); + } if (disposers.length > 0) { - ctx.logger.info("netxops: kb pack skills registered count=%s root=%s provider=%s", disposers.length, root, provider); + ctx.logger.info("netxops: kb pack skills registered count=%s skillsRoot=%s localSkills=%s provider=%s", disposers.length, skillsRoot, localRoot ?? "(none)", provider); } return () => { for (const dispose of disposers) @@ -356,6 +386,7 @@ function unconfiguredKbSnapshot() { country: "", version: "", content: { ...EMPTY_CONTENT }, + paths: {}, errorMessage: "" }; } @@ -372,7 +403,12 @@ function store() { return current; } function getKbContext() { - return { ...store().snapshot, content: { ...store().snapshot.content } }; + const snap = store().snapshot; + return { + ...snap, + content: { ...snap.content }, + paths: { ...snap.paths } + }; } function watchKbContext(listener) { const state = store(); diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index 313512b..11445c4 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -240,7 +240,8 @@ function skillBody(snapshot) { "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;`, + `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, + "plus paths.localSkills when present — use those skills for KB triage;", "keep netx-ops for live netx evidence only." ].join(` `) @@ -282,24 +283,29 @@ 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"); +function resolveKbSkillsRoot(snapshot) { + const rel = snapshot.paths.skills?.trim() || "_skills"; + return join2(snapshot.realRoot, rel); +} +function resolveKbLocalSkillsRoot(snapshot) { + const rel = snapshot.paths.localSkills?.trim(); + if (!rel) + return null; + return join2(snapshot.realRoot, rel); +} +async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; try { entries = await readdir2(root); } catch { - ctx.logger.info("netxops: kb pack skills dir missing at %s — skip", root); - return () => {}; + if (options.logMissing) { + ctx.logger.info("netxops: kb %s dir missing at %s — skip", options.label, root); + } + return []; } + if (entries.length === 0) + return []; const disposers = []; - const provider = options.providerLabel ?? "netxops-kb-pack"; for (const entry of entries) { const full = join2(root, entry); let isDir = false; @@ -314,11 +320,11 @@ async function registerKbPackSkills(ctx, snapshot, options) { 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)); + ctx.logger.warn("netxops: kb %s skill load failed at %s: %s", options.label, full, error instanceof Error ? error.message : String(error)); continue; } if (!skill) { - ctx.logger.warn("netxops: kb pack skill skipped (invalid SKILL.md): %s", full); + ctx.logger.warn("netxops: kb %s skill skipped (invalid SKILL.md): %s", options.label, full); continue; } disposers.push(skillsApi.register({ @@ -331,8 +337,32 @@ async function registerKbPackSkills(ctx, snapshot, options) { source: "custom" })); } + return disposers; +} +async function registerKbPackSkills(ctx, snapshot, options) { + if (!options.enabled || !kbPackSkillsEnabled(snapshot)) { + return () => {}; + } + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const provider = options.providerLabel ?? "netxops-kb-pack"; + const disposers = []; + const skillsRoot = resolveKbSkillsRoot(snapshot); + disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { + logMissing: true, + label: "pack" + })); + const localRoot = resolveKbLocalSkillsRoot(snapshot); + if (localRoot) { + disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, { + logMissing: false, + label: "local" + })); + } if (disposers.length > 0) { - ctx.logger.info("netxops: kb pack skills registered count=%s root=%s provider=%s", disposers.length, root, provider); + ctx.logger.info("netxops: kb pack skills registered count=%s skillsRoot=%s localSkills=%s provider=%s", disposers.length, skillsRoot, localRoot ?? "(none)", provider); } return () => { for (const dispose of disposers) @@ -356,6 +386,7 @@ function unconfiguredKbSnapshot() { country: "", version: "", content: { ...EMPTY_CONTENT }, + paths: {}, errorMessage: "" }; } @@ -372,7 +403,12 @@ function store() { return current; } function getKbContext() { - return { ...store().snapshot, content: { ...store().snapshot.content } }; + const snap = store().snapshot; + return { + ...snap, + content: { ...snap.content }, + paths: { ...snap.paths } + }; } function watchKbContext(listener) { const state = store(); diff --git a/lib/agent-tools.js b/lib/agent-tools.js index 9beab03..8549fb9 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -240,7 +240,8 @@ function skillBody(snapshot) { "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;`, + `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, + "plus paths.localSkills when present — use those skills for KB triage;", "keep netx-ops for live netx evidence only." ].join(` `) @@ -282,24 +283,29 @@ 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"); +function resolveKbSkillsRoot(snapshot) { + const rel = snapshot.paths.skills?.trim() || "_skills"; + return join2(snapshot.realRoot, rel); +} +function resolveKbLocalSkillsRoot(snapshot) { + const rel = snapshot.paths.localSkills?.trim(); + if (!rel) + return null; + return join2(snapshot.realRoot, rel); +} +async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; try { entries = await readdir2(root); } catch { - ctx.logger.info("netxops: kb pack skills dir missing at %s — skip", root); - return () => {}; + if (options.logMissing) { + ctx.logger.info("netxops: kb %s dir missing at %s — skip", options.label, root); + } + return []; } + if (entries.length === 0) + return []; const disposers = []; - const provider = options.providerLabel ?? "netxops-kb-pack"; for (const entry of entries) { const full = join2(root, entry); let isDir = false; @@ -314,11 +320,11 @@ async function registerKbPackSkills(ctx, snapshot, options) { 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)); + ctx.logger.warn("netxops: kb %s skill load failed at %s: %s", options.label, full, error instanceof Error ? error.message : String(error)); continue; } if (!skill) { - ctx.logger.warn("netxops: kb pack skill skipped (invalid SKILL.md): %s", full); + ctx.logger.warn("netxops: kb %s skill skipped (invalid SKILL.md): %s", options.label, full); continue; } disposers.push(skillsApi.register({ @@ -331,8 +337,32 @@ async function registerKbPackSkills(ctx, snapshot, options) { source: "custom" })); } + return disposers; +} +async function registerKbPackSkills(ctx, snapshot, options) { + if (!options.enabled || !kbPackSkillsEnabled(snapshot)) { + return () => {}; + } + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const provider = options.providerLabel ?? "netxops-kb-pack"; + const disposers = []; + const skillsRoot = resolveKbSkillsRoot(snapshot); + disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { + logMissing: true, + label: "pack" + })); + const localRoot = resolveKbLocalSkillsRoot(snapshot); + if (localRoot) { + disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, { + logMissing: false, + label: "local" + })); + } if (disposers.length > 0) { - ctx.logger.info("netxops: kb pack skills registered count=%s root=%s provider=%s", disposers.length, root, provider); + ctx.logger.info("netxops: kb pack skills registered count=%s skillsRoot=%s localSkills=%s provider=%s", disposers.length, skillsRoot, localRoot ?? "(none)", provider); } return () => { for (const dispose of disposers) @@ -356,6 +386,7 @@ function unconfiguredKbSnapshot() { country: "", version: "", content: { ...EMPTY_CONTENT }, + paths: {}, errorMessage: "" }; } @@ -372,7 +403,12 @@ function store() { return current; } function getKbContext() { - return { ...store().snapshot, content: { ...store().snapshot.content } }; + const snap = store().snapshot; + return { + ...snap, + content: { ...snap.content }, + paths: { ...snap.paths } + }; } function watchKbContext(listener) { const state = store(); diff --git a/lib/client.js b/lib/client.js index 1af392b..da50b51 100644 --- a/lib/client.js +++ b/lib/client.js @@ -583,6 +583,7 @@ function unconfiguredKbSnapshot() { country: "", version: "", content: { ...EMPTY_CONTENT }, + paths: {}, errorMessage: "" }; } @@ -604,6 +605,14 @@ function asKbSnapshot(value) { content[key] = flag === true; } } + const pathsRaw = row.paths; + const paths = {}; + if (pathsRaw !== null && typeof pathsRaw === "object" && !Array.isArray(pathsRaw)) { + for (const [key, value2] of Object.entries(pathsRaw)) { + if (typeof value2 === "string" && value2.trim()) + paths[key] = value2.trim(); + } + } return { status, realRoot: typeof row.realRoot === "string" ? row.realRoot : "", @@ -611,6 +620,7 @@ function asKbSnapshot(value) { country: typeof row.country === "string" ? row.country : "", version: typeof row.version === "string" ? row.version : "", content, + paths, errorMessage: typeof row.errorMessage === "string" ? row.errorMessage : "" }; } @@ -2282,7 +2292,7 @@ var en = { 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. Pack skills load from _skills/ when hasSkills.", + kbRootHint: "Folder with MANIFEST.json (operator-subset v1.0). Browse auto-saves. Pack skills load from paths.skills/_skills and paths.localSkills when hasSkills.", kbBrowse: "Browse…", kbBrowseUnavailable: "Directory picker unavailable — paste an absolute folder path, then Save.", kbBrowseFailed: "Browse failed: {detail}", @@ -2355,7 +2365,7 @@ var zh = { groupInPreset: "在 Netx Ops 预设中启用", groupPublic: "对其他预设公开", kbRoot: "知识包根目录", - kbRootHint: "含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存;hasSkills 时加载 _skills/。", + kbRootHint: "含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存;hasSkills 时加载 paths.skills/_skills 与 paths.localSkills。", kbBrowse: "浏览…", kbBrowseUnavailable: "目录选择器不可用 — 请粘贴绝对目录路径后点保存。", kbBrowseFailed: "浏览失败:{detail}", diff --git a/lib/index.js b/lib/index.js index 7cbbbcf..1d34b3f 100644 --- a/lib/index.js +++ b/lib/index.js @@ -810,7 +810,8 @@ function skillBody(snapshot) { "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;`, + `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, + "plus paths.localSkills when present — use those skills for KB triage;", "keep netx-ops for live netx evidence only." ].join(` `) @@ -871,6 +872,7 @@ function unconfiguredKbSnapshot() { country: "", version: "", content: { ...EMPTY_CONTENT }, + paths: {}, errorMessage: "" }; } @@ -882,6 +884,7 @@ function errorSnapshot(message) { country: "", version: "", content: { ...EMPTY_CONTENT }, + paths: {}, errorMessage: message }; } @@ -988,6 +991,23 @@ function parseContent(raw) { } return out; } +function parsePaths(raw) { + if (raw === undefined || raw === null) + return {}; + if (typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("MANIFEST paths must be an object"); + } + const out = {}; + for (const [key, value] of Object.entries(raw)) { + if (typeof value !== "string") + continue; + const trimmed = value.trim(); + if (!trimmed) + continue; + out[key] = trimmed; + } + return out; +} function parseManifest(raw) { let data; try { @@ -1014,7 +1034,8 @@ function parseManifest(raw) { operatorName: asNonEmptyString(op.name, "operator.name"), country: asNonEmptyString(op.country, "operator.country"), version: asNonEmptyString(row.version, "version"), - content: parseContent(row.content) + content: parseContent(row.content), + paths: parsePaths(row.paths) }; } function resolveKbRoot(kbRoot, maxDepth = 3) { @@ -1061,6 +1082,7 @@ function resolveKbRoot(kbRoot, maxDepth = 3) { country: parsed.country, version: parsed.version, content: parsed.content, + paths: { ...parsed.paths }, errorMessage: "" }; } catch (error) { @@ -1074,24 +1096,29 @@ 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"); +function resolveKbSkillsRoot(snapshot) { + const rel = snapshot.paths.skills?.trim() || "_skills"; + return join4(snapshot.realRoot, rel); +} +function resolveKbLocalSkillsRoot(snapshot) { + const rel = snapshot.paths.localSkills?.trim(); + if (!rel) + return null; + return join4(snapshot.realRoot, rel); +} +async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; try { entries = await readdir2(root); } catch { - ctx.logger.info("netxops: kb pack skills dir missing at %s — skip", root); - return () => {}; + if (options.logMissing) { + ctx.logger.info("netxops: kb %s dir missing at %s — skip", options.label, root); + } + return []; } + if (entries.length === 0) + return []; const disposers = []; - const provider = options.providerLabel ?? "netxops-kb-pack"; for (const entry of entries) { const full = join4(root, entry); let isDir = false; @@ -1106,11 +1133,11 @@ async function registerKbPackSkills(ctx, snapshot, options) { 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)); + ctx.logger.warn("netxops: kb %s skill load failed at %s: %s", options.label, full, error instanceof Error ? error.message : String(error)); continue; } if (!skill) { - ctx.logger.warn("netxops: kb pack skill skipped (invalid SKILL.md): %s", full); + ctx.logger.warn("netxops: kb %s skill skipped (invalid SKILL.md): %s", options.label, full); continue; } disposers.push(skillsApi.register({ @@ -1123,8 +1150,32 @@ async function registerKbPackSkills(ctx, snapshot, options) { source: "custom" })); } + return disposers; +} +async function registerKbPackSkills(ctx, snapshot, options) { + if (!options.enabled || !kbPackSkillsEnabled(snapshot)) { + return () => {}; + } + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const provider = options.providerLabel ?? "netxops-kb-pack"; + const disposers = []; + const skillsRoot = resolveKbSkillsRoot(snapshot); + disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { + logMissing: true, + label: "pack" + })); + const localRoot = resolveKbLocalSkillsRoot(snapshot); + if (localRoot) { + disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, { + logMissing: false, + label: "local" + })); + } if (disposers.length > 0) { - ctx.logger.info("netxops: kb pack skills registered count=%s root=%s provider=%s", disposers.length, root, provider); + ctx.logger.info("netxops: kb pack skills registered count=%s skillsRoot=%s localSkills=%s provider=%s", disposers.length, skillsRoot, localRoot ?? "(none)", provider); } return () => { for (const dispose of disposers) @@ -1152,13 +1203,19 @@ function store2() { return current; } function getKbContext() { - return { ...store2().snapshot, content: { ...store2().snapshot.content } }; + const snap = store2().snapshot; + return { + ...snap, + content: { ...snap.content }, + paths: { ...snap.paths } + }; } function publishKbContext(next) { const state = store2(); state.snapshot = { ...next, - content: { ...next.content } + content: { ...next.content }, + paths: { ...next.paths } }; for (const listener of state.listeners) listener(); diff --git a/package.json b/package.json index f1c0534..97646af 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-netxops", "version": "0.1.30", - "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills", + "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "license": "MIT", "type": "module", "private": false, diff --git a/src/client/kb-status-view.ts b/src/client/kb-status-view.ts index 11a3318..68a9c29 100644 --- a/src/client/kb-status-view.ts +++ b/src/client/kb-status-view.ts @@ -31,6 +31,13 @@ export function asKbSnapshot(value: unknown): KbSnapshot { content[key] = flag === true } } + const pathsRaw = row.paths + const paths: Record = {} + if (pathsRaw !== null && typeof pathsRaw === 'object' && !Array.isArray(pathsRaw)) { + for (const [key, value] of Object.entries(pathsRaw as Record)) { + if (typeof value === 'string' && value.trim()) paths[key] = value.trim() + } + } return { status, realRoot: typeof row.realRoot === 'string' ? row.realRoot : '', @@ -38,6 +45,7 @@ export function asKbSnapshot(value: unknown): KbSnapshot { country: typeof row.country === 'string' ? row.country : '', version: typeof row.version === 'string' ? row.version : '', content, + paths, errorMessage: typeof row.errorMessage === 'string' ? row.errorMessage : '', } } diff --git a/src/client/locales.ts b/src/client/locales.ts index b889061..2635ea9 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -94,7 +94,7 @@ export const en: Record = { 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. Pack skills load from _skills/ when hasSkills.', + kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Browse auto-saves. Pack skills load from paths.skills/_skills and paths.localSkills when hasSkills.', kbBrowse: 'Browse…', kbBrowseUnavailable: 'Directory picker unavailable — paste an absolute folder path, then Save.', kbBrowseFailed: 'Browse failed: {detail}', @@ -168,7 +168,7 @@ export const zh: Record = { groupInPreset: '在 Netx Ops 预设中启用', groupPublic: '对其他预设公开', kbRoot: '知识包根目录', - kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存;hasSkills 时加载 _skills/。', + kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存;hasSkills 时加载 paths.skills/_skills 与 paths.localSkills。', kbBrowse: '浏览…', kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对目录路径后点保存。', kbBrowseFailed: '浏览失败:{detail}', diff --git a/src/netx/kb-context-skill.ts b/src/netx/kb-context-skill.ts index 052776c..3504d2a 100644 --- a/src/netx/kb-context-skill.ts +++ b/src/netx/kb-context-skill.ts @@ -35,7 +35,8 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string '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;`, + `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, + 'plus paths.localSkills when present — use those skills for KB triage;', 'keep netx-ops for live netx evidence only.', ].join('\n'), } diff --git a/src/netx/kb-manifest.ts b/src/netx/kb-manifest.ts index 131e199..d25adf2 100644 --- a/src/netx/kb-manifest.ts +++ b/src/netx/kb-manifest.ts @@ -6,6 +6,7 @@ * - packageType === "operator-subset" * - operator.name / operator.country, version required * - content.* boolean flags (missing → false) + * - paths.* optional relative dirs (packaging-generated; missing keys skipped) * * Location: `${kbRoot}/MANIFEST.json`, else recurse ≤ maxDepth and accept * exactly one hit (0 or >1 → error). @@ -24,6 +25,18 @@ export interface KbContentFlags { [key: string]: boolean } +/** + * Packaging-generated relative paths under the package root. + * Plugin uses `skills` / `localSkills` for skill registration; other keys are preserved. + */ +export interface KbPaths { + /** Shared skill packs (default `_skills` when absent). */ + skills?: string + /** Operator-local skills (optional; missing/empty → skip). */ + localSkills?: string + [key: string]: string | undefined +} + export type KbStatus = 'unconfigured' | 'configured' | 'error' export interface KbSnapshot { @@ -34,6 +47,8 @@ export interface KbSnapshot { country: string version: string content: KbContentFlags + /** Relative paths from MANIFEST `paths` (empty when absent). */ + paths: KbPaths errorMessage: string } @@ -63,6 +78,7 @@ export function unconfiguredKbSnapshot(): KbSnapshot { country: '', version: '', content: { ...EMPTY_CONTENT }, + paths: {}, errorMessage: '', } } @@ -75,6 +91,7 @@ function errorSnapshot(message: string): KbSnapshot { country: '', version: '', content: { ...EMPTY_CONTENT }, + paths: {}, errorMessage: message, } } @@ -191,6 +208,25 @@ function parseContent(raw: unknown): KbContentFlags { return out } +/** + * Parse optional MANIFEST `paths` map (relative dirs under package root). + * Missing / null → {}; non-string or blank values skipped; invalid type → error. + */ +export function parsePaths(raw: unknown): KbPaths { + if (raw === undefined || raw === null) return {} + if (typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('MANIFEST paths must be an object') + } + const out: KbPaths = {} + for (const [key, value] of Object.entries(raw as Record)) { + if (typeof value !== 'string') continue + const trimmed = value.trim() + if (!trimmed) continue + out[key] = trimmed + } + return out +} + /** * Parse and validate MANIFEST JSON text (UTF-8). * @throws Error with a short message when invalid. @@ -200,6 +236,7 @@ export function parseManifest(raw: string): { country: string version: string content: KbContentFlags + paths: KbPaths } { let data: unknown try { @@ -233,6 +270,7 @@ export function parseManifest(raw: string): { country: asNonEmptyString(op.country, 'operator.country'), version: asNonEmptyString(row.version, 'version'), content: parseContent(row.content), + paths: parsePaths(row.paths), } } @@ -290,6 +328,7 @@ export function resolveKbRoot(kbRoot: string, maxDepth = 3): KbSnapshot { country: parsed.country, version: parsed.version, content: parsed.content, + paths: { ...parsed.paths }, errorMessage: '', } } catch (error) { diff --git a/src/netx/kb-pack-skills.ts b/src/netx/kb-pack-skills.ts index 03183d3..03a2697 100644 --- a/src/netx/kb-pack-skills.ts +++ b/src/netx/kb-pack-skills.ts @@ -1,5 +1,9 @@ /** - * Register operator-subset knowledge pack skills (_skills dir) into DSH when KB is configured. + * Register operator-subset knowledge pack skills into DSH when KB is configured. + * + * Sources (same plane / toggles / loadSkillBundle rules): + * - `paths.skills` or default `_skills/` when `content.hasSkills` + * - `paths.localSkills` when present (missing field or empty/missing dir → skip) */ import { readdir, stat } from 'node:fs/promises' @@ -10,52 +14,55 @@ import { loadSkillBundle } from './group-skills.ts' export type KbSkillPlane = 'preset' | 'public' +type SkillsRegister = (skill: { + name: string + description: string + content: string + path?: string + resourceBase?: { kind: 'directory'; path: string } + provider?: string + source: string +}) => () => void + /** True when MANIFEST says the package ships skill packs. */ export function kbPackSkillsEnabled(snapshot: KbSnapshot): boolean { return snapshot.status === 'configured' && snapshot.content.hasSkills === true } +/** Shared pack root: MANIFEST `paths.skills` or legacy `_skills`. */ +export function resolveKbSkillsRoot(snapshot: KbSnapshot): string { + const rel = snapshot.paths.skills?.trim() || '_skills' + return join(snapshot.realRoot, rel) +} + /** - * Register every valid skill under realRoot/_skills. - * No-op when not configured / hasSkills false / directory missing. - * @returns disposer that unregisters all pack skills. + * Absolute local-skills root from MANIFEST `paths.localSkills`, or null when absent. */ -export async function registerKbPackSkills( +export function resolveKbLocalSkillsRoot(snapshot: KbSnapshot): string | null { + const rel = snapshot.paths.localSkills?.trim() + if (!rel) return null + return join(snapshot.realRoot, rel) +} + +async function registerSkillsUnderRoot( 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') + skillsApi: { register: SkillsRegister }, + root: string, + provider: string, + options: { logMissing: boolean; label: string }, +): Promise void>> { let entries: string[] try { entries = await readdir(root) } catch { - ctx.logger.info('netxops: kb pack skills dir missing at %s — skip', root) - return () => {} + if (options.logMissing) { + ctx.logger.info('netxops: kb %s dir missing at %s — skip', options.label, root) + } + return [] } + if (entries.length === 0) return [] const disposers: Array<() => void> = [] - const provider = options.providerLabel ?? 'netxops-kb-pack' for (const entry of entries) { const full = join(root, entry) let isDir = false @@ -70,14 +77,15 @@ export async function registerKbPackSkills( skill = await loadSkillBundle(full) } catch (error) { ctx.logger.warn( - 'netxops: kb pack skill load failed at %s: %s', + 'netxops: kb %s skill load failed at %s: %s', + options.label, full, error instanceof Error ? error.message : String(error), ) continue } if (!skill) { - ctx.logger.warn('netxops: kb pack skill skipped (invalid SKILL.md): %s', full) + ctx.logger.warn('netxops: kb %s skill skipped (invalid SKILL.md): %s', options.label, full) continue } disposers.push(skillsApi.register({ @@ -90,12 +98,55 @@ export async function registerKbPackSkills( source: 'custom', })) } + return disposers +} + +/** + * Register every valid skill under paths.skills (or `_skills`) and paths.localSkills. + * No-op when not configured / hasSkills false. + * localSkills: field missing or directory empty/missing → skip (no error). + * @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: SkillsRegister } }).skills + if (!skillsApi || typeof skillsApi.register !== 'function') { + return () => {} + } + + const provider = options.providerLabel ?? 'netxops-kb-pack' + const disposers: Array<() => void> = [] + + const skillsRoot = resolveKbSkillsRoot(snapshot) + disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { + logMissing: true, + label: 'pack', + })) + + const localRoot = resolveKbLocalSkillsRoot(snapshot) + if (localRoot) { + disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, { + logMissing: false, + label: 'local', + })) + } if (disposers.length > 0) { ctx.logger.info( - 'netxops: kb pack skills registered count=%s root=%s provider=%s', + 'netxops: kb pack skills registered count=%s skillsRoot=%s localSkills=%s provider=%s', disposers.length, - root, + skillsRoot, + localRoot ?? '(none)', provider, ) } diff --git a/src/netx/kb-runtime.ts b/src/netx/kb-runtime.ts index cb4d814..b5b4ed2 100644 --- a/src/netx/kb-runtime.ts +++ b/src/netx/kb-runtime.ts @@ -39,7 +39,12 @@ function store(): Store { /** @returns the last published KB snapshot. */ export function getKbContext(): KbSnapshot { - return { ...store().snapshot, content: { ...store().snapshot.content } } + const snap = store().snapshot + return { + ...snap, + content: { ...snap.content }, + paths: { ...snap.paths }, + } } /** @@ -51,6 +56,7 @@ export function publishKbContext(next: KbSnapshot): void { state.snapshot = { ...next, content: { ...next.content }, + paths: { ...next.paths }, } for (const listener of state.listeners) listener() } diff --git a/test/kb-manifest.test.mjs b/test/kb-manifest.test.mjs index 79748cb..3b76398 100644 --- a/test/kb-manifest.test.mjs +++ b/test/kb-manifest.test.mjs @@ -11,6 +11,7 @@ import test from 'node:test' import { findManifest, parseManifest, + parsePaths, resolveKbRoot, } from '../src/netx/kb-manifest.ts' @@ -190,5 +191,41 @@ test('contract content flags hasRegions etc. are accepted', () => { assert.equal(snap.content.hasRegions, true) assert.equal(snap.content.hasSkills, true) assert.equal(snap.content.hasCommon, true) + assert.deepEqual(snap.paths, {}) + }) +}) + +test('paths.skills / paths.localSkills parsed; blank skipped', () => { + const parsed = parseManifest(validManifest({ + paths: { + skills: '_skills', + localSkills: 'regions/印尼-Indonesia/IOH/_local/local_skills', + rca: 'regions/印尼-Indonesia/IOH/00_有效RCA', + empty: ' ', + bad: 12, + }, + })) + assert.equal(parsed.paths.skills, '_skills') + assert.equal(parsed.paths.localSkills, 'regions/印尼-Indonesia/IOH/_local/local_skills') + assert.equal(parsed.paths.rca, 'regions/印尼-Indonesia/IOH/00_有效RCA') + assert.equal(parsed.paths.empty, undefined) + assert.equal(parsed.paths.bad, undefined) +}) + +test('paths missing → empty object; invalid type rejected', () => { + assert.deepEqual(parsePaths(undefined), {}) + assert.deepEqual(parsePaths(null), {}) + assert.throws(() => parsePaths([]), /paths must be an object/) + withTemp((root) => { + writeFileSync(join(root, 'MANIFEST.json'), validManifest({ + paths: { + skills: '_skills', + localSkills: 'regions/ID/IOH/_local/local_skills', + }, + }), 'utf8') + const snap = resolveKbRoot(root) + assert.equal(snap.status, 'configured') + assert.equal(snap.paths.skills, '_skills') + assert.equal(snap.paths.localSkills, 'regions/ID/IOH/_local/local_skills') }) }) diff --git a/test/kb-pack-skills.test.mjs b/test/kb-pack-skills.test.mjs index dfa9119..c8a3520 100644 --- a/test/kb-pack-skills.test.mjs +++ b/test/kb-pack-skills.test.mjs @@ -1,5 +1,5 @@ /** - * KB pack skill loader from realRoot/_skills. + * KB pack skill loader from realRoot/_skills (+ optional paths.localSkills). */ import assert from 'node:assert/strict' @@ -8,7 +8,12 @@ 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' +import { + kbPackSkillsEnabled, + registerKbPackSkills, + resolveKbLocalSkillsRoot, + resolveKbSkillsRoot, +} from '../src/netx/kb-pack-skills.ts' async function withTemp(run) { const root = mkdtempSync(join(tmpdir(), 'netxops-kb-pack-')) @@ -19,7 +24,7 @@ async function withTemp(run) { } } -function configuredSnap(realRoot, content = {}) { +function configuredSnap(realRoot, content = {}, paths = {}) { return { status: 'configured', realRoot, @@ -34,6 +39,7 @@ function configuredSnap(realRoot, content = {}) { hasSkills: true, ...content, }, + paths: { ...paths }, errorMessage: '', } } @@ -93,3 +99,108 @@ test('registerKbPackSkills no-op when disabled or hasSkills false', async () => await registerKbPackSkills(ctx, configuredSnap('/nope', { hasSkills: false }), { enabled: true }) assert.equal(registered.length, 0) }) + +test('resolveKbSkillsRoot uses paths.skills or defaults to _skills', () => { + const snap = configuredSnap('/kb') + assert.equal(resolveKbSkillsRoot(snap), join('/kb', '_skills')) + assert.equal( + resolveKbSkillsRoot(configuredSnap('/kb', {}, { skills: 'pack/skills' })), + join('/kb', 'pack/skills'), + ) +}) + +test('resolveKbLocalSkillsRoot null when missing; joins when set', () => { + assert.equal(resolveKbLocalSkillsRoot(configuredSnap('/kb')), null) + assert.equal( + resolveKbLocalSkillsRoot(configuredSnap('/kb', {}, { + localSkills: 'regions/ID/IOH/_local/local_skills', + })), + join('/kb', 'regions/ID/IOH/_local/local_skills'), + ) +}) + +test('registerKbPackSkills also loads paths.localSkills/*/SKILL.md', async () => { + await withTemp(async (root) => { + const packDir = join(root, '_skills', 'kb-troubleshoot') + mkdirSync(packDir, { recursive: true }) + writeFileSync(join(packDir, 'SKILL.md'), `--- +name: kb-troubleshoot +description: KB triage playbook +--- + +# kb-troubleshoot +`, 'utf8') + + const localRel = join('regions', 'ID', 'IOH', '_local', 'local_skills') + const localDir = join(root, localRel, 'ioh-local-rca') + mkdirSync(localDir, { recursive: true }) + writeFileSync(join(localDir, 'SKILL.md'), `--- +name: ioh-local-rca +description: IOH local RCA playbook +--- + +# ioh-local-rca +`, 'utf8') + + const registered = [] + const ctx = { + logger: { info() {}, warn() {} }, + skills: { + register(skill) { + registered.push(skill) + return () => {} + }, + }, + } + + await registerKbPackSkills(ctx, configuredSnap(root, {}, { + skills: '_skills', + localSkills: localRel.replace(/\\/g, '/'), + }), { enabled: true }) + + const names = registered.map((s) => s.name).sort() + assert.deepEqual(names, ['ioh-local-rca', 'kb-troubleshoot']) + assert.ok(registered.every((s) => s.provider === 'netxops-kb-pack')) + }) +}) + +test('registerKbPackSkills skips missing or empty localSkills', 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 emptyLocal = join(root, '_local', 'local_skills') + mkdirSync(emptyLocal, { recursive: true }) + + const registered = [] + const ctx = { + logger: { info() {}, warn() {} }, + skills: { + register(skill) { + registered.push(skill) + return () => {} + }, + }, + } + + await registerKbPackSkills(ctx, configuredSnap(root, {}, { + localSkills: '_local/local_skills', + }), { enabled: true }) + assert.equal(registered.length, 1) + assert.equal(registered[0].name, 'kb-troubleshoot') + + registered.length = 0 + await registerKbPackSkills(ctx, configuredSnap(root, {}, { + localSkills: '_local/does-not-exist', + }), { enabled: true }) + assert.equal(registered.length, 1) + assert.equal(registered[0].name, 'kb-troubleshoot') + }) +}) diff --git a/test/kb-runtime.test.mjs b/test/kb-runtime.test.mjs index df45f32..29bf774 100644 --- a/test/kb-runtime.test.mjs +++ b/test/kb-runtime.test.mjs @@ -32,11 +32,14 @@ test('publishKbContext updates store', () => { hasCommon: false, hasSkills: false, }, + paths: { skills: '_skills', localSkills: 'regions/ID/IOH/_local/local_skills' }, errorMessage: '', }) const snap = getKbContext() assert.equal(snap.status, 'configured') assert.equal(snap.operatorName, 'IOH') + assert.equal(snap.paths.skills, '_skills') + assert.equal(snap.paths.localSkills, 'regions/ID/IOH/_local/local_skills') applyKbEnv(snap) assert.equal(process.env.KB_OPERATOR, 'IOH') assert.equal(process.env.KB_COUNTRY, 'ID')