diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 93e73a2..7203f53 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -42,7 +42,8 @@ One command, three **direct** profile bundles. Do **not** nest IM/cron under net Token fallback: `scripts/set-netx-token.ps1` / `.sh`. Optional: enable **关键告警推送** so this DSH dials `ws(s):///v1/integrations/dsh-alarm/ws`. Choose **投递到 DSH 会话** and/or **投递到 WhatsApp / IM**. For IM, install `dsh-im-ops`, create a delivery target, then paste `imBotId` / `imTargetId`. The card header shows live WSS status. Optional: **导出全部会话** downloads `dsh-sessions--.zip` via `GET /api/netxops.sessions.export` (browser download; works for cloud Hosts). - Capability groups: leave default for **ops**, or enable **topology** / **对其他预设公开** (new sessions after save). + 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). 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 / 「能否登录」. @@ -67,8 +68,9 @@ One command, three **direct** profile bundles. Do **not** nest IM/cron under net 3. Agent presets → Custom → **Netx Ops**. 4. Tools include `netx__queryNmsAlarms` / `netx__execManagedNe` / `netx__findTopologyPaths` (**ops** → skill `netx-ops`). Canvas / dual_unit need **topology** (`netx-topology`). 5. Export section shows a session count; **下载 ZIP** starts a browser download. +6. Optional knowledge-base badge shows operator/country/version when `kbRoot` + MANIFEST are valid. -See [TOOL_MAP.md](TOOL_MAP.md). +See [TOOL_MAP.md](TOOL_MAP.md) and [KNOWLEDGE_BASE.md](KNOWLEDGE_BASE.md). ## Path / local checkout (developers) diff --git a/docs/KNOWLEDGE_BASE.md b/docs/KNOWLEDGE_BASE.md new file mode 100644 index 0000000..115514b --- /dev/null +++ b/docs/KNOWLEDGE_BASE.md @@ -0,0 +1,44 @@ +# Knowledge base (P1) + +Operator-subset knowledge packages are wired through **netxops settings**, not into the netx API or `netx-ops` skill text. + +## 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. + +## MANIFEST v1.0 (plugin checks) + +| Field | Rule | +|-------|------| +| `schemaVersion` | must be `"1.0"` | +| `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) | + +Location: `${kbRoot}/MANIFEST.json`, else recurse ≤3 levels and **exactly one** hit. Zero or multiple → error. + +Authoritative packaging prose lives in the workspace contract notes (`插件` / `契约`); this doc only describes what the plugin enforces. + +## Injected context + +| Channel | Fields | +|---------|--------| +| `process.env` | `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT` (JSON), `KB_STATUS` | +| Skill `kb-context` | Same identity for the model (markdown table) | + +RPC (channel `/netxops`): `kb.status` (saved snapshot), `kb.resolve` with `{ path }` (preview unsaved paths). + +## Degradation matrix + +| Condition | `KB_STATUS` | Behavior | +|-----------|-------------|----------| +| Empty `kbRoot` | `unconfigured` | Pure netx; skill says do not invent an operator | +| Missing / ambiguous / invalid MANIFEST | `error` | Pure netx; badge shows error detail | +| Valid MANIFEST | `configured` | Paths + operator identity available | + +**Never** invent an operator when status is not `configured`. P2 knowledge-base skill packs (case search, etc.) are out of scope for this release. diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index fdf6b33..19e90dc 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -215,22 +215,120 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) { }; } -// src/netx/runtime.ts -var STORE_KEY = Symbol.for("dsh-netxops.connection-store"); +// src/netx/kb-context-skill.ts +var SKILL_NAME = "kb-context"; +function skillBody(snapshot) { + if (snapshot.status === "configured") { + const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)"; + return { + description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + content: [ + "## Knowledge base (configured)", + "", + "When troubleshooting with operator playbooks or docs, **compose paths from this root**.", + "Do not invent another operator or country.", + "", + `| Field | Value |`, + `| --- | --- |`, + `| kbStatus | configured |`, + `| kbRoot | \`${snapshot.realRoot}\` |`, + `| kbOperator | ${snapshot.operatorName} |`, + `| kbCountry | ${snapshot.country} |`, + `| kbVersion | ${snapshot.version} |`, + `| kbContent (on) | ${flags} |`, + "", + "Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`." + ].join(` +`) + }; + } + const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty"; + return { + description: "Knowledge-base context is unavailable — stay on pure netx evidence.", + content: [ + "## Knowledge base (unavailable)", + "", + `kbStatus=\`${snapshot.status}\`. ${reason}`, + "", + "**Do not invent an operator, country, or KB paths.**", + "Use only netx tools / live evidence (alarms, inventory, CLI, topology).", + "Operator-specific playbooks are out of scope until a valid MANIFEST is configured." + ].join(` +`) + }; +} +function registerKbContextSkill(ctx, snapshot) { + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const body = skillBody(snapshot); + return skillsApi.register({ + name: SKILL_NAME, + description: body.description, + content: body.content, + provider: "netxops-kb", + source: "custom" + }); +} + +// src/netx/kb-manifest.ts +var EMPTY_CONTENT = { + regions: false, + theory: false, + packet: false, + skills: false +}; +function unconfiguredKbSnapshot() { + return { + status: "unconfigured", + realRoot: "", + operatorName: "", + country: "", + version: "", + content: { ...EMPTY_CONTENT }, + errorMessage: "" + }; +} + +// src/netx/kb-runtime.ts +var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); function store() { const root = globalThis; let current = root[STORE_KEY]; if (current === undefined) { - current = { connection: undefined, listeners: new Set }; + current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set }; root[STORE_KEY] = current; } return current; } +function getKbContext() { + return { ...store().snapshot, content: { ...store().snapshot.content } }; +} +function watchKbContext(listener) { + const state = store(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + +// src/netx/runtime.ts +var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); +function store2() { + const root = globalThis; + let current = root[STORE_KEY2]; + if (current === undefined) { + current = { connection: undefined, listeners: new Set }; + root[STORE_KEY2] = current; + } + return current; +} function getNetxConnection() { - return store().connection; + return store2().connection; } function watchNetxConnection(listener) { - const state = store(); + const state = store2(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1749,6 +1847,7 @@ function applyGroupToolsPlugin(ctx, options) { remountTools(); }); ctx.inject(["skills"], (skillsCtx) => { + let unregisterKbSkill; const remountSkills = () => { const gen = ++skillGeneration; unregisterSkills?.(); @@ -1766,15 +1865,27 @@ function applyGroupToolsPlugin(ctx, options) { skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error); }); }; + const remountKbSkill = () => { + unregisterKbSkill?.(); + unregisterKbSkill = undefined; + unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext()); + }; remountSkills(); + remountKbSkill(); const stopSkillWatch = watchNetxConnection(() => { remountSkills(); }); + const stopKbWatch = watchKbContext(() => { + remountKbSkill(); + }); skillsCtx.effect(() => () => { skillGeneration += 1; stopSkillWatch(); + stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; + unregisterKbSkill?.(); + unregisterKbSkill = undefined; }, `${options.name}: dispose skills`); }); ctx.effect(() => () => { diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index 8c120ff..03488ba 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -215,22 +215,120 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) { }; } -// src/netx/runtime.ts -var STORE_KEY = Symbol.for("dsh-netxops.connection-store"); +// src/netx/kb-context-skill.ts +var SKILL_NAME = "kb-context"; +function skillBody(snapshot) { + if (snapshot.status === "configured") { + const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)"; + return { + description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + content: [ + "## Knowledge base (configured)", + "", + "When troubleshooting with operator playbooks or docs, **compose paths from this root**.", + "Do not invent another operator or country.", + "", + `| Field | Value |`, + `| --- | --- |`, + `| kbStatus | configured |`, + `| kbRoot | \`${snapshot.realRoot}\` |`, + `| kbOperator | ${snapshot.operatorName} |`, + `| kbCountry | ${snapshot.country} |`, + `| kbVersion | ${snapshot.version} |`, + `| kbContent (on) | ${flags} |`, + "", + "Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`." + ].join(` +`) + }; + } + const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty"; + return { + description: "Knowledge-base context is unavailable — stay on pure netx evidence.", + content: [ + "## Knowledge base (unavailable)", + "", + `kbStatus=\`${snapshot.status}\`. ${reason}`, + "", + "**Do not invent an operator, country, or KB paths.**", + "Use only netx tools / live evidence (alarms, inventory, CLI, topology).", + "Operator-specific playbooks are out of scope until a valid MANIFEST is configured." + ].join(` +`) + }; +} +function registerKbContextSkill(ctx, snapshot) { + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const body = skillBody(snapshot); + return skillsApi.register({ + name: SKILL_NAME, + description: body.description, + content: body.content, + provider: "netxops-kb", + source: "custom" + }); +} + +// src/netx/kb-manifest.ts +var EMPTY_CONTENT = { + regions: false, + theory: false, + packet: false, + skills: false +}; +function unconfiguredKbSnapshot() { + return { + status: "unconfigured", + realRoot: "", + operatorName: "", + country: "", + version: "", + content: { ...EMPTY_CONTENT }, + errorMessage: "" + }; +} + +// src/netx/kb-runtime.ts +var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); function store() { const root = globalThis; let current = root[STORE_KEY]; if (current === undefined) { - current = { connection: undefined, listeners: new Set }; + current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set }; root[STORE_KEY] = current; } return current; } +function getKbContext() { + return { ...store().snapshot, content: { ...store().snapshot.content } }; +} +function watchKbContext(listener) { + const state = store(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + +// src/netx/runtime.ts +var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); +function store2() { + const root = globalThis; + let current = root[STORE_KEY2]; + if (current === undefined) { + current = { connection: undefined, listeners: new Set }; + root[STORE_KEY2] = current; + } + return current; +} function getNetxConnection() { - return store().connection; + return store2().connection; } function watchNetxConnection(listener) { - const state = store(); + const state = store2(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1749,6 +1847,7 @@ function applyGroupToolsPlugin(ctx, options) { remountTools(); }); ctx.inject(["skills"], (skillsCtx) => { + let unregisterKbSkill; const remountSkills = () => { const gen = ++skillGeneration; unregisterSkills?.(); @@ -1766,15 +1865,27 @@ function applyGroupToolsPlugin(ctx, options) { skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error); }); }; + const remountKbSkill = () => { + unregisterKbSkill?.(); + unregisterKbSkill = undefined; + unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext()); + }; remountSkills(); + remountKbSkill(); const stopSkillWatch = watchNetxConnection(() => { remountSkills(); }); + const stopKbWatch = watchKbContext(() => { + remountKbSkill(); + }); skillsCtx.effect(() => () => { skillGeneration += 1; stopSkillWatch(); + stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; + unregisterKbSkill?.(); + unregisterKbSkill = undefined; }, `${options.name}: dispose skills`); }); ctx.effect(() => () => { diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 5a727c0..4ba1f4c 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -215,22 +215,120 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) { }; } -// src/netx/runtime.ts -var STORE_KEY = Symbol.for("dsh-netxops.connection-store"); +// src/netx/kb-context-skill.ts +var SKILL_NAME = "kb-context"; +function skillBody(snapshot) { + if (snapshot.status === "configured") { + const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)"; + return { + description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + content: [ + "## Knowledge base (configured)", + "", + "When troubleshooting with operator playbooks or docs, **compose paths from this root**.", + "Do not invent another operator or country.", + "", + `| Field | Value |`, + `| --- | --- |`, + `| kbStatus | configured |`, + `| kbRoot | \`${snapshot.realRoot}\` |`, + `| kbOperator | ${snapshot.operatorName} |`, + `| kbCountry | ${snapshot.country} |`, + `| kbVersion | ${snapshot.version} |`, + `| kbContent (on) | ${flags} |`, + "", + "Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`." + ].join(` +`) + }; + } + const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty"; + return { + description: "Knowledge-base context is unavailable — stay on pure netx evidence.", + content: [ + "## Knowledge base (unavailable)", + "", + `kbStatus=\`${snapshot.status}\`. ${reason}`, + "", + "**Do not invent an operator, country, or KB paths.**", + "Use only netx tools / live evidence (alarms, inventory, CLI, topology).", + "Operator-specific playbooks are out of scope until a valid MANIFEST is configured." + ].join(` +`) + }; +} +function registerKbContextSkill(ctx, snapshot) { + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const body = skillBody(snapshot); + return skillsApi.register({ + name: SKILL_NAME, + description: body.description, + content: body.content, + provider: "netxops-kb", + source: "custom" + }); +} + +// src/netx/kb-manifest.ts +var EMPTY_CONTENT = { + regions: false, + theory: false, + packet: false, + skills: false +}; +function unconfiguredKbSnapshot() { + return { + status: "unconfigured", + realRoot: "", + operatorName: "", + country: "", + version: "", + content: { ...EMPTY_CONTENT }, + errorMessage: "" + }; +} + +// src/netx/kb-runtime.ts +var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); function store() { const root = globalThis; let current = root[STORE_KEY]; if (current === undefined) { - current = { connection: undefined, listeners: new Set }; + current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set }; root[STORE_KEY] = current; } return current; } +function getKbContext() { + return { ...store().snapshot, content: { ...store().snapshot.content } }; +} +function watchKbContext(listener) { + const state = store(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + +// src/netx/runtime.ts +var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); +function store2() { + const root = globalThis; + let current = root[STORE_KEY2]; + if (current === undefined) { + current = { connection: undefined, listeners: new Set }; + root[STORE_KEY2] = current; + } + return current; +} function getNetxConnection() { - return store().connection; + return store2().connection; } function watchNetxConnection(listener) { - const state = store(); + const state = store2(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1749,6 +1847,7 @@ function applyGroupToolsPlugin(ctx, options) { remountTools(); }); ctx.inject(["skills"], (skillsCtx) => { + let unregisterKbSkill; const remountSkills = () => { const gen = ++skillGeneration; unregisterSkills?.(); @@ -1766,15 +1865,27 @@ function applyGroupToolsPlugin(ctx, options) { skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error); }); }; + const remountKbSkill = () => { + unregisterKbSkill?.(); + unregisterKbSkill = undefined; + unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext()); + }; remountSkills(); + remountKbSkill(); const stopSkillWatch = watchNetxConnection(() => { remountSkills(); }); + const stopKbWatch = watchKbContext(() => { + remountKbSkill(); + }); skillsCtx.effect(() => () => { skillGeneration += 1; stopSkillWatch(); + stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; + unregisterKbSkill?.(); + unregisterKbSkill = undefined; }, `${options.name}: dispose skills`); }); ctx.effect(() => () => { diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index 755d6c6..371a8e3 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -215,22 +215,120 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) { }; } -// src/netx/runtime.ts -var STORE_KEY = Symbol.for("dsh-netxops.connection-store"); +// src/netx/kb-context-skill.ts +var SKILL_NAME = "kb-context"; +function skillBody(snapshot) { + if (snapshot.status === "configured") { + const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)"; + return { + description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + content: [ + "## Knowledge base (configured)", + "", + "When troubleshooting with operator playbooks or docs, **compose paths from this root**.", + "Do not invent another operator or country.", + "", + `| Field | Value |`, + `| --- | --- |`, + `| kbStatus | configured |`, + `| kbRoot | \`${snapshot.realRoot}\` |`, + `| kbOperator | ${snapshot.operatorName} |`, + `| kbCountry | ${snapshot.country} |`, + `| kbVersion | ${snapshot.version} |`, + `| kbContent (on) | ${flags} |`, + "", + "Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`." + ].join(` +`) + }; + } + const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty"; + return { + description: "Knowledge-base context is unavailable — stay on pure netx evidence.", + content: [ + "## Knowledge base (unavailable)", + "", + `kbStatus=\`${snapshot.status}\`. ${reason}`, + "", + "**Do not invent an operator, country, or KB paths.**", + "Use only netx tools / live evidence (alarms, inventory, CLI, topology).", + "Operator-specific playbooks are out of scope until a valid MANIFEST is configured." + ].join(` +`) + }; +} +function registerKbContextSkill(ctx, snapshot) { + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const body = skillBody(snapshot); + return skillsApi.register({ + name: SKILL_NAME, + description: body.description, + content: body.content, + provider: "netxops-kb", + source: "custom" + }); +} + +// src/netx/kb-manifest.ts +var EMPTY_CONTENT = { + regions: false, + theory: false, + packet: false, + skills: false +}; +function unconfiguredKbSnapshot() { + return { + status: "unconfigured", + realRoot: "", + operatorName: "", + country: "", + version: "", + content: { ...EMPTY_CONTENT }, + errorMessage: "" + }; +} + +// src/netx/kb-runtime.ts +var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); function store() { const root = globalThis; let current = root[STORE_KEY]; if (current === undefined) { - current = { connection: undefined, listeners: new Set }; + current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set }; root[STORE_KEY] = current; } return current; } +function getKbContext() { + return { ...store().snapshot, content: { ...store().snapshot.content } }; +} +function watchKbContext(listener) { + const state = store(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + +// src/netx/runtime.ts +var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); +function store2() { + const root = globalThis; + let current = root[STORE_KEY2]; + if (current === undefined) { + current = { connection: undefined, listeners: new Set }; + root[STORE_KEY2] = current; + } + return current; +} function getNetxConnection() { - return store().connection; + return store2().connection; } function watchNetxConnection(listener) { - const state = store(); + const state = store2(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1749,6 +1847,7 @@ function applyGroupToolsPlugin(ctx, options) { remountTools(); }); ctx.inject(["skills"], (skillsCtx) => { + let unregisterKbSkill; const remountSkills = () => { const gen = ++skillGeneration; unregisterSkills?.(); @@ -1766,15 +1865,27 @@ function applyGroupToolsPlugin(ctx, options) { skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error); }); }; + const remountKbSkill = () => { + unregisterKbSkill?.(); + unregisterKbSkill = undefined; + unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext()); + }; remountSkills(); + remountKbSkill(); const stopSkillWatch = watchNetxConnection(() => { remountSkills(); }); + const stopKbWatch = watchKbContext(() => { + remountKbSkill(); + }); skillsCtx.effect(() => () => { skillGeneration += 1; stopSkillWatch(); + stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; + unregisterKbSkill?.(); + unregisterKbSkill = undefined; }, `${options.name}: dispose skills`); }); ctx.effect(() => () => { diff --git a/lib/agent-tools.js b/lib/agent-tools.js index 64422db..82d32c3 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -215,22 +215,120 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) { }; } -// src/netx/runtime.ts -var STORE_KEY = Symbol.for("dsh-netxops.connection-store"); +// src/netx/kb-context-skill.ts +var SKILL_NAME = "kb-context"; +function skillBody(snapshot) { + if (snapshot.status === "configured") { + const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)"; + return { + description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + content: [ + "## Knowledge base (configured)", + "", + "When troubleshooting with operator playbooks or docs, **compose paths from this root**.", + "Do not invent another operator or country.", + "", + `| Field | Value |`, + `| --- | --- |`, + `| kbStatus | configured |`, + `| kbRoot | \`${snapshot.realRoot}\` |`, + `| kbOperator | ${snapshot.operatorName} |`, + `| kbCountry | ${snapshot.country} |`, + `| kbVersion | ${snapshot.version} |`, + `| kbContent (on) | ${flags} |`, + "", + "Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`." + ].join(` +`) + }; + } + const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty"; + return { + description: "Knowledge-base context is unavailable — stay on pure netx evidence.", + content: [ + "## Knowledge base (unavailable)", + "", + `kbStatus=\`${snapshot.status}\`. ${reason}`, + "", + "**Do not invent an operator, country, or KB paths.**", + "Use only netx tools / live evidence (alarms, inventory, CLI, topology).", + "Operator-specific playbooks are out of scope until a valid MANIFEST is configured." + ].join(` +`) + }; +} +function registerKbContextSkill(ctx, snapshot) { + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const body = skillBody(snapshot); + return skillsApi.register({ + name: SKILL_NAME, + description: body.description, + content: body.content, + provider: "netxops-kb", + source: "custom" + }); +} + +// src/netx/kb-manifest.ts +var EMPTY_CONTENT = { + regions: false, + theory: false, + packet: false, + skills: false +}; +function unconfiguredKbSnapshot() { + return { + status: "unconfigured", + realRoot: "", + operatorName: "", + country: "", + version: "", + content: { ...EMPTY_CONTENT }, + errorMessage: "" + }; +} + +// src/netx/kb-runtime.ts +var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); function store() { const root = globalThis; let current = root[STORE_KEY]; if (current === undefined) { - current = { connection: undefined, listeners: new Set }; + current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set }; root[STORE_KEY] = current; } return current; } +function getKbContext() { + return { ...store().snapshot, content: { ...store().snapshot.content } }; +} +function watchKbContext(listener) { + const state = store(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + +// src/netx/runtime.ts +var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); +function store2() { + const root = globalThis; + let current = root[STORE_KEY2]; + if (current === undefined) { + current = { connection: undefined, listeners: new Set }; + root[STORE_KEY2] = current; + } + return current; +} function getNetxConnection() { - return store().connection; + return store2().connection; } function watchNetxConnection(listener) { - const state = store(); + const state = store2(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1749,6 +1847,7 @@ function applyGroupToolsPlugin(ctx, options) { remountTools(); }); ctx.inject(["skills"], (skillsCtx) => { + let unregisterKbSkill; const remountSkills = () => { const gen = ++skillGeneration; unregisterSkills?.(); @@ -1766,15 +1865,27 @@ function applyGroupToolsPlugin(ctx, options) { skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error); }); }; + const remountKbSkill = () => { + unregisterKbSkill?.(); + unregisterKbSkill = undefined; + unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext()); + }; remountSkills(); + remountKbSkill(); const stopSkillWatch = watchNetxConnection(() => { remountSkills(); }); + const stopKbWatch = watchKbContext(() => { + remountKbSkill(); + }); skillsCtx.effect(() => () => { skillGeneration += 1; stopSkillWatch(); + stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; + unregisterKbSkill?.(); + unregisterKbSkill = undefined; }, `${options.name}: dispose skills`); }); ctx.effect(() => () => { diff --git a/lib/client.js b/lib/client.js index ede14d0..0b6bba5 100644 --- a/lib/client.js +++ b/lib/client.js @@ -244,6 +244,399 @@ function setImTargetSelected(current, target, selected) { return normalized ? [...without, normalized] : without; } +// src/netx/kb-manifest.ts +var import_node_fs = (() => ({})); + +// node:path +function assertPath(path) { + if (typeof path !== "string") + throw TypeError("Path must be a string. Received " + JSON.stringify(path)); +} +function normalizeStringPosix(path, allowAboveRoot) { + var res = "", lastSegmentLength = 0, lastSlash = -1, dots = 0, code; + for (var i = 0;i <= path.length; ++i) { + if (i < path.length) + code = path.charCodeAt(i); + else if (code === 47) + break; + else + code = 47; + if (code === 47) { + if (lastSlash === i - 1 || dots === 1) + ; + else if (lastSlash !== i - 1 && dots === 2) { + if (res.length < 2 || lastSegmentLength !== 2 || res.charCodeAt(res.length - 1) !== 46 || res.charCodeAt(res.length - 2) !== 46) { + if (res.length > 2) { + var lastSlashIndex = res.lastIndexOf("/"); + if (lastSlashIndex !== res.length - 1) { + if (lastSlashIndex === -1) + res = "", lastSegmentLength = 0; + else + res = res.slice(0, lastSlashIndex), lastSegmentLength = res.length - 1 - res.lastIndexOf("/"); + lastSlash = i, dots = 0; + continue; + } + } else if (res.length === 2 || res.length === 1) { + res = "", lastSegmentLength = 0, lastSlash = i, dots = 0; + continue; + } + } + if (allowAboveRoot) { + if (res.length > 0) + res += "/.."; + else + res = ".."; + lastSegmentLength = 2; + } + } else { + if (res.length > 0) + res += "/" + path.slice(lastSlash + 1, i); + else + res = path.slice(lastSlash + 1, i); + lastSegmentLength = i - lastSlash - 1; + } + lastSlash = i, dots = 0; + } else if (code === 46 && dots !== -1) + ++dots; + else + dots = -1; + } + return res; +} +function _format(sep, pathObject) { + var dir = pathObject.dir || pathObject.root, base = pathObject.base || (pathObject.name || "") + (pathObject.ext || ""); + if (!dir) + return base; + if (dir === pathObject.root) + return dir + base; + return dir + sep + base; +} +function resolve() { + var resolvedPath = "", resolvedAbsolute = false, cwd; + for (var i = arguments.length - 1;i >= -1 && !resolvedAbsolute; i--) { + var path; + if (i >= 0) + path = arguments[i]; + else { + if (cwd === undefined) + cwd = process.cwd(); + path = cwd; + } + if (assertPath(path), path.length === 0) + continue; + resolvedPath = path + "/" + resolvedPath, resolvedAbsolute = path.charCodeAt(0) === 47; + } + if (resolvedPath = normalizeStringPosix(resolvedPath, !resolvedAbsolute), resolvedAbsolute) + if (resolvedPath.length > 0) + return "/" + resolvedPath; + else + return "/"; + else if (resolvedPath.length > 0) + return resolvedPath; + else + return "."; +} +function normalize(path) { + if (assertPath(path), path.length === 0) + return "."; + var isAbsolute = path.charCodeAt(0) === 47, trailingSeparator = path.charCodeAt(path.length - 1) === 47; + if (path = normalizeStringPosix(path, !isAbsolute), path.length === 0 && !isAbsolute) + path = "."; + if (path.length > 0 && trailingSeparator) + path += "/"; + if (isAbsolute) + return "/" + path; + return path; +} +function isAbsolute(path) { + return assertPath(path), path.length > 0 && path.charCodeAt(0) === 47; +} +function join() { + if (arguments.length === 0) + return "."; + var joined; + for (var i = 0;i < arguments.length; ++i) { + var arg = arguments[i]; + if (assertPath(arg), arg.length > 0) + if (joined === undefined) + joined = arg; + else + joined += "/" + arg; + } + if (joined === undefined) + return "."; + return normalize(joined); +} +function relative(from, to) { + if (assertPath(from), assertPath(to), from === to) + return ""; + if (from = resolve(from), to = resolve(to), from === to) + return ""; + var fromStart = 1; + for (;fromStart < from.length; ++fromStart) + if (from.charCodeAt(fromStart) !== 47) + break; + var fromEnd = from.length, fromLen = fromEnd - fromStart, toStart = 1; + for (;toStart < to.length; ++toStart) + if (to.charCodeAt(toStart) !== 47) + break; + var toEnd = to.length, toLen = toEnd - toStart, length = fromLen < toLen ? fromLen : toLen, lastCommonSep = -1, i = 0; + for (;i <= length; ++i) { + if (i === length) { + if (toLen > length) { + if (to.charCodeAt(toStart + i) === 47) + return to.slice(toStart + i + 1); + else if (i === 0) + return to.slice(toStart + i); + } else if (fromLen > length) { + if (from.charCodeAt(fromStart + i) === 47) + lastCommonSep = i; + else if (i === 0) + lastCommonSep = 0; + } + break; + } + var fromCode = from.charCodeAt(fromStart + i), toCode = to.charCodeAt(toStart + i); + if (fromCode !== toCode) + break; + else if (fromCode === 47) + lastCommonSep = i; + } + var out = ""; + for (i = fromStart + lastCommonSep + 1;i <= fromEnd; ++i) + if (i === fromEnd || from.charCodeAt(i) === 47) + if (out.length === 0) + out += ".."; + else + out += "/.."; + if (out.length > 0) + return out + to.slice(toStart + lastCommonSep); + else { + if (toStart += lastCommonSep, to.charCodeAt(toStart) === 47) + ++toStart; + return to.slice(toStart); + } +} +function _makeLong(path) { + return path; +} +function dirname(path) { + if (assertPath(path), path.length === 0) + return "."; + var code = path.charCodeAt(0), hasRoot = code === 47, end = -1, matchedSlash = true; + for (var i = path.length - 1;i >= 1; --i) + if (code = path.charCodeAt(i), code === 47) { + if (!matchedSlash) { + end = i; + break; + } + } else + matchedSlash = false; + if (end === -1) + return hasRoot ? "/" : "."; + if (hasRoot && end === 1) + return "//"; + return path.slice(0, end); +} +function basename(path, ext) { + if (ext !== undefined && typeof ext !== "string") + throw TypeError('"ext" argument must be a string'); + assertPath(path); + var start = 0, end = -1, matchedSlash = true, i; + if (ext !== undefined && ext.length > 0 && ext.length <= path.length) { + if (ext.length === path.length && ext === path) + return ""; + var extIdx = ext.length - 1, firstNonSlashEnd = -1; + for (i = path.length - 1;i >= 0; --i) { + var code = path.charCodeAt(i); + if (code === 47) { + if (!matchedSlash) { + start = i + 1; + break; + } + } else { + if (firstNonSlashEnd === -1) + matchedSlash = false, firstNonSlashEnd = i + 1; + if (extIdx >= 0) + if (code === ext.charCodeAt(extIdx)) { + if (--extIdx === -1) + end = i; + } else + extIdx = -1, end = firstNonSlashEnd; + } + } + if (start === end) + end = firstNonSlashEnd; + else if (end === -1) + end = path.length; + return path.slice(start, end); + } else { + for (i = path.length - 1;i >= 0; --i) + if (path.charCodeAt(i) === 47) { + if (!matchedSlash) { + start = i + 1; + break; + } + } else if (end === -1) + matchedSlash = false, end = i + 1; + if (end === -1) + return ""; + return path.slice(start, end); + } +} +function extname(path) { + assertPath(path); + var startDot = -1, startPart = 0, end = -1, matchedSlash = true, preDotState = 0; + for (var i = path.length - 1;i >= 0; --i) { + var code = path.charCodeAt(i); + if (code === 47) { + if (!matchedSlash) { + startPart = i + 1; + break; + } + continue; + } + if (end === -1) + matchedSlash = false, end = i + 1; + if (code === 46) { + if (startDot === -1) + startDot = i; + else if (preDotState !== 1) + preDotState = 1; + } else if (startDot !== -1) + preDotState = -1; + } + if (startDot === -1 || end === -1 || preDotState === 0 || preDotState === 1 && startDot === end - 1 && startDot === startPart + 1) + return ""; + return path.slice(startDot, end); +} +function format(pathObject) { + if (pathObject === null || typeof pathObject !== "object") + throw TypeError('The "pathObject" argument must be of type Object. Received type ' + typeof pathObject); + return _format("/", pathObject); +} +function parse(path) { + assertPath(path); + var ret = { root: "", dir: "", base: "", ext: "", name: "" }; + if (path.length === 0) + return ret; + var code = path.charCodeAt(0), isAbsolute2 = code === 47, start; + if (isAbsolute2) + ret.root = "/", start = 1; + else + start = 0; + var startDot = -1, startPart = 0, end = -1, matchedSlash = true, i = path.length - 1, preDotState = 0; + for (;i >= start; --i) { + if (code = path.charCodeAt(i), code === 47) { + if (!matchedSlash) { + startPart = i + 1; + break; + } + continue; + } + if (end === -1) + matchedSlash = false, end = i + 1; + if (code === 46) { + if (startDot === -1) + startDot = i; + else if (preDotState !== 1) + preDotState = 1; + } else if (startDot !== -1) + preDotState = -1; + } + if (startDot === -1 || end === -1 || preDotState === 0 || preDotState === 1 && startDot === end - 1 && startDot === startPart + 1) { + if (end !== -1) + if (startPart === 0 && isAbsolute2) + ret.base = ret.name = path.slice(1, end); + else + ret.base = ret.name = path.slice(startPart, end); + } else { + if (startPart === 0 && isAbsolute2) + ret.name = path.slice(1, startDot), ret.base = path.slice(1, end); + else + ret.name = path.slice(startPart, startDot), ret.base = path.slice(startPart, end); + ret.ext = path.slice(startDot, end); + } + if (startPart > 0) + ret.dir = path.slice(0, startPart - 1); + else if (isAbsolute2) + ret.dir = "/"; + return ret; +} +var sep = "/"; +var delimiter = ":"; +var posix = ((p) => (p.posix = p, p))({ resolve, normalize, isAbsolute, join, relative, _makeLong, dirname, basename, extname, format, parse, sep, delimiter, win32: null, posix: null }); + +// src/netx/kb-manifest.ts +var EMPTY_CONTENT = { + regions: false, + theory: false, + packet: false, + skills: false +}; +function unconfiguredKbSnapshot() { + return { + status: "unconfigured", + realRoot: "", + operatorName: "", + country: "", + version: "", + content: { ...EMPTY_CONTENT }, + errorMessage: "" + }; +} + +// src/client/kb-status-view.ts +var NETXOPS_RPC_CHANNEL2 = "/netxops"; +var KB_STATUS_ENDPOINT = "kb.status"; +var KB_RESOLVE_ENDPOINT = "kb.resolve"; +function asKbSnapshot(value) { + const empty = unconfiguredKbSnapshot(); + if (value === null || typeof value !== "object" || Array.isArray(value)) + return empty; + const row = value; + const status = row.status === "configured" || row.status === "error" || row.status === "unconfigured" ? row.status : "unconfigured"; + const contentRaw = row.content; + const content = { ...empty.content }; + if (contentRaw !== null && typeof contentRaw === "object" && !Array.isArray(contentRaw)) { + for (const [key, flag] of Object.entries(contentRaw)) { + content[key] = flag === true; + } + } + return { + status, + realRoot: typeof row.realRoot === "string" ? row.realRoot : "", + operatorName: typeof row.operatorName === "string" ? row.operatorName : "", + country: typeof row.country === "string" ? row.country : "", + version: typeof row.version === "string" ? row.version : "", + content, + errorMessage: typeof row.errorMessage === "string" ? row.errorMessage : "" + }; +} +async function fetchKbStatus(call, signal) { + const result = await call(NETXOPS_RPC_CHANNEL2, KB_STATUS_ENDPOINT, {}, signal); + if (result !== null && typeof result === "object" && result.ok === true) { + return asKbSnapshot(result.value); + } + return unconfiguredKbSnapshot(); +} +async function resolveKbPath(call, path, signal) { + const result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { path }, signal); + if (result !== null && typeof result === "object" && result.ok === true) { + return asKbSnapshot(result.value); + } + return unconfiguredKbSnapshot(); +} +function kbStatusTone(status) { + if (status === "configured") + return "ok"; + if (status === "error") + return "err"; + if (status === "unconfigured") + return "warn"; + return "neutral"; +} + // src/client/styles.ts var STYLE_ID = "dsh-netxops-settings-css"; var CSS = ` @@ -285,7 +678,9 @@ var CSS = ` .dsh-nx-groupTitle{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329)} .dsh-nx-groupChecks{display:flex;flex-direction:column;gap:8px;padding-left:2px} .dsh-nx-imTargetList{display:flex;flex-direction:column;gap:8px;padding:4px 0 2px} -.dsh-nx-settings-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px} +.dsh-nx-pathRow{display:flex;gap:8px;align-items:stretch} +.dsh-nx-pathRow>input{flex:1;min-width:0} +.dsh-nx-pathRow>.dsh-nx-btn{flex:none;align-self:stretch} .dsh-nx-settings-msg{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)} .dsh-nx-settings-msg.ok{color:var(--dsw-alias-state-success-primary,#20a162)} .dsh-nx-settings-msg.err{color:var(--dsw-alias-state-error-primary,#d54941)} @@ -466,13 +861,26 @@ function phaseLocaleKey(phase) { } } function StatusBadge(props) { - const tone = alarmPushTone(props.phase); + const tone = props.tone ?? (props.phase !== undefined ? alarmPushTone(props.phase) : "neutral"); const className = tone === "ok" ? "dsh-nx-status dsh-nx-statusOk" : tone === "warn" ? "dsh-nx-status dsh-nx-statusWarn" : tone === "err" ? "dsh-nx-status dsh-nx-statusErr" : "dsh-nx-status"; return /* @__PURE__ */ jsx_runtime.jsx("span", { className, children: props.label }); } +function kbBadgeLabel(t, snapshot) { + if (!snapshot || snapshot.status === "unconfigured") { + return t("kbStatusUnconfigured"); + } + if (snapshot.status === "error") { + return fillTemplate(t("kbStatusError"), { detail: snapshot.errorMessage || "error" }); + } + return fillTemplate(t("kbStatusConfigured"), { + operator: snapshot.operatorName, + country: snapshot.country, + version: snapshot.version + }); +} function fillTemplate(template, vars) { return template.replace(/\{(\w+)\}/g, (match, name) => Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : match); } @@ -907,6 +1315,92 @@ function NetxopsCard(props) { }) ] }), + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-settings-card", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("h3", { + children: t("sectionKnowledge") + }), + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-settings-field", + children: [ + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-field-head", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("label", { + htmlFor: "netxops-kb-root", + children: t("kbRoot") + }), + /* @__PURE__ */ jsx_runtime.jsxs("span", { + className: "dsh-nx-badges", + children: [ + /* @__PURE__ */ jsx_runtime.jsx(StatusBadge, { + tone: kbStatusTone(state.kbStatus?.status ?? "unconfigured"), + label: kbBadgeLabel(t, state.kbStatus) + }), + state.kbRoot.overridden ? /* @__PURE__ */ jsx_runtime.jsxs(jsx_runtime.Fragment, { + children: [ + /* @__PURE__ */ jsx_runtime.jsx("span", { + className: "dsh-nx-badge", + children: t("overridden") + }), + /* @__PURE__ */ jsx_runtime.jsx("button", { + type: "button", + className: "dsh-nx-reset", + disabled, + onClick: () => { + props.resetField("kbRoot"); + }, + children: t("reset") + }) + ] + }) : null + ] + }) + ] + }), + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-pathRow", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("input", { + id: "netxops-kb-root", + className: state.kbRoot.invalid ? "dsh-nx-inputInvalid" : undefined, + type: "text", + value: state.kbRoot.text, + disabled, + "aria-invalid": state.kbRoot.invalid || undefined, + onChange: (event) => { + props.edit("kbRoot", event.target.value); + } + }), + /* @__PURE__ */ jsx_runtime.jsx("button", { + type: "button", + className: "dsh-nx-btn", + disabled: disabled || !state.kbDirectoryPickerReady, + onClick: () => { + props.browseKbRoot(); + }, + children: t("kbBrowse") + }) + ] + }), + /* @__PURE__ */ jsx_runtime.jsx("p", { + className: state.kbRoot.invalid ? "dsh-nx-invalid" : "dsh-nx-hint", + children: state.kbRoot.invalid ? t("invalid") : state.kbDirectoryPickerReady ? t("kbRootHint") : t("kbBrowseUnavailable") + }), + state.kbStatus?.status === "error" && state.kbStatus.errorMessage ? /* @__PURE__ */ jsx_runtime.jsx("p", { + className: "dsh-nx-invalid", + role: "status", + children: state.kbStatus.errorMessage + }) : null, + state.kbStatus?.status === "configured" && state.kbStatus.realRoot ? /* @__PURE__ */ jsx_runtime.jsx("p", { + className: "dsh-nx-hint", + children: state.kbStatus.realRoot + }) : null + ] + }) + ] + }), /* @__PURE__ */ jsx_runtime.jsxs("div", { className: "dsh-nx-settings-card", children: [ @@ -1307,6 +1801,11 @@ class NetxopsCardController { }; rpcCall; alarmPushStatus = null; + kbStatus = null; + kbPreview = null; + kbDirectoryPicker; + kbBrowseInFlight = false; + kbStatusInFlight = false; imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }; sessionsExportStatus = null; sessionsExportBusy = false; @@ -1324,6 +1823,7 @@ class NetxopsCardController { textField("apiUrl"), textField("lang"), textField("nmsProvider"), + textField("kbRoot"), booleanFieldPersistFalse("groupOpsInPreset"), booleanField("groupOpsPublic"), booleanField("groupTopologyInPreset"), @@ -1338,6 +1838,7 @@ class NetxopsCardController { this.store = this.form.bind(() => this.projection()); scope.subscribe(() => { this.readCredential(); + this.refreshKbPreview(); }); this.readCredential(); } @@ -1362,6 +1863,11 @@ class NetxopsCardController { this.alarmPushStatus = null; changed = true; } + if (this.kbStatus !== null || this.kbPreview !== null) { + this.kbStatus = null; + this.kbPreview = null; + changed = true; + } if (this.imDeliveryCatalog.options.length > 0 || this.imDeliveryCatalog.available !== true) { this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }; changed = true; @@ -1376,14 +1882,25 @@ class NetxopsCardController { } this.startStatusPoll(); this.refreshAlarmPushStatus(); + this.refreshKbStatus(); + this.refreshKbPreview(); this.refreshImDeliveryCatalog(); this.refreshSessionsExportStatus(); } + setDirectoryPicker(picker) { + const ready = picker !== undefined; + if (this.kbDirectoryPicker === picker && this.kbDirectoryPicker !== undefined === ready) { + return; + } + this.kbDirectoryPicker = picker; + this.store.set(this.projection()); + } startStatusPoll() { if (this.pollTimer !== undefined) return; this.pollTimer = setInterval(() => { this.refreshAlarmPushStatus(); + this.refreshKbStatus(); this.refreshImDeliveryCatalog(); this.refreshSessionsExportStatus(); }, STATUS_POLL_MS); @@ -1446,6 +1963,58 @@ class NetxopsCardController { this.exportStatusInFlight = false; } } + async refreshKbStatus() { + const call = this.rpcCall; + if (call === undefined || this.kbStatusInFlight) + return; + this.kbStatusInFlight = true; + try { + const next = await fetchKbStatus(call); + const prev = this.kbStatus; + if (prev && prev.status === next.status && prev.realRoot === next.realRoot && prev.operatorName === next.operatorName && prev.country === next.country && prev.version === next.version && prev.errorMessage === next.errorMessage) + return; + this.kbStatus = next; + this.store.set(this.projection()); + } catch {} finally { + this.kbStatusInFlight = false; + } + } + async refreshKbPreview() { + const call = this.rpcCall; + if (call === undefined) + return; + const draft = this.form.field("kbRoot").text.trim(); + const saved = (this.scope.getSnapshot().value?.kbRoot ?? "").trim(); + if (draft === saved) { + if (this.kbPreview !== null) { + this.kbPreview = null; + this.store.set(this.projection()); + } + return; + } + try { + const next = draft === "" ? unconfiguredKbSnapshot() : await resolveKbPath(call, draft); + const prev = this.kbPreview; + if (prev && prev.status === next.status && prev.realRoot === next.realRoot && prev.operatorName === next.operatorName && prev.country === next.country && prev.version === next.version && prev.errorMessage === next.errorMessage) + return; + this.kbPreview = next; + this.store.set(this.projection()); + } catch {} + } + browseKbRoot() { + const picker = this.kbDirectoryPicker; + if (picker === undefined || this.kbBrowseInFlight) + return; + this.kbBrowseInFlight = true; + picker.pick().then((path) => { + if (typeof path === "string" && path.trim() !== "") { + this.form.actions().edit("kbRoot", path); + this.refreshKbPreview(); + } + }).catch(() => {}).finally(() => { + this.kbBrowseInFlight = false; + }); + } exportAllSessions() { if (this.exportInFlight !== undefined || this.sessionsExportBusy) return; @@ -1485,6 +2054,7 @@ class NetxopsCardController { apiUrl: this.form.field("apiUrl"), lang: this.form.field("lang"), nmsProvider: this.form.field("nmsProvider"), + kbRoot: this.form.field("kbRoot"), groupOpsInPreset: this.form.field("groupOpsInPreset"), groupOpsPublic: this.form.field("groupOpsPublic"), groupTopologyInPreset: this.form.field("groupTopologyInPreset"), @@ -1500,6 +2070,8 @@ class NetxopsCardController { apiTokenWritable: this.credential.remoteReady && this.credential.writable, apiTokenRemoteReady: this.credential.remoteReady, alarmPushStatus: this.alarmPushStatus, + kbStatus: this.kbPreview ?? this.kbStatus, + kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined, imDeliveryCatalog: this.imDeliveryCatalog, sessionsExportStatus: this.sessionsExportStatus, sessionsExportBusy: this.sessionsExportBusy, @@ -1550,11 +2122,29 @@ class NetxopsCardController { this.readCredential(); } inject() { + const actions = this.form.actions(); return { hooks: { netxopsCard: this.store }, - ...this.form.actions(), + ...actions, + edit: (field, text) => { + actions.edit(field, text); + if (field === "kbRoot") + this.refreshKbPreview(); + }, + discard: () => { + actions.discard(); + this.refreshKbPreview(); + }, + resetField: (field) => { + actions.resetField(field); + if (field === "kbRoot") + this.refreshKbPreview(); + }, exportAllSessions: () => { this.exportAllSessions(); + }, + browseKbRoot: () => { + this.browseKbRoot(); } }; } @@ -1575,9 +2165,10 @@ function refOf(snapshot) { // src/client/locales.ts var en = { title: "Netx Ops", - description: "API, token, capabilities, and alarm delivery.", + description: "API, token, capabilities, knowledge base, and alarm delivery.", sectionConnection: "Connection", sectionCapabilities: "Capability groups", + sectionKnowledge: "Knowledge base", sectionAlarms: "Key-alarm delivery", sectionExport: "Session export", apiUrl: "API URL", @@ -1591,6 +2182,13 @@ var en = { groupTopology: "topology", groupInPreset: "In Netx Ops preset", groupPublic: "Publish to other presets", + kbRoot: "Package root", + kbRootHint: "Folder with MANIFEST.json (operator-subset v1.0). Empty = pure netx.", + kbBrowse: "Browse…", + kbBrowseUnavailable: "Directory picker unavailable — paste an absolute path.", + kbStatusConfigured: "Knowledge base: {operator} ({country}) v{version}", + kbStatusUnconfigured: "Knowledge base: not configured (pure netx)", + kbStatusError: "Knowledge base: error — {detail}", alarmPushEnabled: "Key-alarm push", alarmPushStatus: "Status", alarmPushPhaseDisabled: "Off", @@ -1637,9 +2235,10 @@ var en = { }; var zh = { title: "Netx Ops", - description: "API、Token、能力组与告警投递。", + description: "API、Token、能力组、知识库与告警投递。", sectionConnection: "连接", sectionCapabilities: "能力组", + sectionKnowledge: "知识库", sectionAlarms: "关键告警投递", sectionExport: "会话导出", apiUrl: "API 地址", @@ -1653,6 +2252,13 @@ var zh = { groupTopology: "topology", groupInPreset: "在 Netx Ops 预设中启用", groupPublic: "对其他预设公开", + kbRoot: "知识包根目录", + kbRootHint: "含 MANIFEST.json 的运营商子集包(v1.0)。留空=纯 netx。", + kbBrowse: "浏览…", + kbBrowseUnavailable: "目录选择器不可用 — 请粘贴绝对路径。", + kbStatusConfigured: "知识库: {operator}({country}) v{version}", + kbStatusUnconfigured: "知识库: 未配置(纯 netx)", + kbStatusError: "知识库: 错误 — {detail}", alarmPushEnabled: "关键告警推送", alarmPushStatus: "状态", alarmPushPhaseDisabled: "未开启", @@ -1744,6 +2350,17 @@ function apply(ctx) { card.setAlarmPushRpc(undefined); }, "netxops: clear alarm-push rpc"); }); + ctx.inject(["remote.directoryPicker"], (dpCtx) => { + const picker = dpCtx.remote?.directoryPicker; + if (!picker || typeof picker.pick !== "function") { + dpCtx.logger.warn("netxops: remote.directoryPicker.pick unavailable — browse button disabled"); + return; + } + card.setDirectoryPicker(picker); + dpCtx.effect(() => () => { + card.setDirectoryPicker(undefined); + }, "netxops: clear directory picker"); + }); ctx.slots.inject("settings.section", () => ctx.slots.register({ name: "settings.section", id: NETXOPS_NS, diff --git a/lib/index.js b/lib/index.js index 76a4ab3..3ea7653 100644 --- a/lib/index.js +++ b/lib/index.js @@ -2,9 +2,9 @@ import { createRequire } from "node:module"; var __require = /* @__PURE__ */ createRequire(import.meta.url); // src/index.ts -import { cpSync, existsSync as existsSync2, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync as existsSync3, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { homedir as homedir3 } from "node:os"; -import { dirname as dirname2, join as join3 } from "node:path"; +import { dirname as dirname3, join as join4 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import z from "@deepseek-ai/schemastery"; import { credentialRef } from "@deepseek-ai/dsh-credentials"; @@ -785,28 +785,331 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) { }; } -// src/netx/runtime.ts -var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); +// src/netx/kb-context-skill.ts +var SKILL_NAME = "kb-context"; +function skillBody(snapshot) { + if (snapshot.status === "configured") { + const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)"; + return { + description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).", + content: [ + "## Knowledge base (configured)", + "", + "When troubleshooting with operator playbooks or docs, **compose paths from this root**.", + "Do not invent another operator or country.", + "", + `| Field | Value |`, + `| --- | --- |`, + `| kbStatus | configured |`, + `| kbRoot | \`${snapshot.realRoot}\` |`, + `| kbOperator | ${snapshot.operatorName} |`, + `| kbCountry | ${snapshot.country} |`, + `| kbVersion | ${snapshot.version} |`, + `| kbContent (on) | ${flags} |`, + "", + "Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`." + ].join(` +`) + }; + } + const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty"; + return { + description: "Knowledge-base context is unavailable — stay on pure netx evidence.", + content: [ + "## Knowledge base (unavailable)", + "", + `kbStatus=\`${snapshot.status}\`. ${reason}`, + "", + "**Do not invent an operator, country, or KB paths.**", + "Use only netx tools / live evidence (alarms, inventory, CLI, topology).", + "Operator-specific playbooks are out of scope until a valid MANIFEST is configured." + ].join(` +`) + }; +} +function registerKbContextSkill(ctx, snapshot) { + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const body = skillBody(snapshot); + return skillsApi.register({ + name: SKILL_NAME, + description: body.description, + content: body.content, + provider: "netxops-kb", + source: "custom" + }); +} + +// src/netx/kb-manifest.ts +import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname as dirname2, join as join3, resolve } from "node:path"; +var EMPTY_CONTENT = { + regions: false, + theory: false, + packet: false, + skills: false +}; +function unconfiguredKbSnapshot() { + return { + status: "unconfigured", + realRoot: "", + operatorName: "", + country: "", + version: "", + content: { ...EMPTY_CONTENT }, + errorMessage: "" + }; +} +function errorSnapshot(message) { + return { + status: "error", + realRoot: "", + operatorName: "", + country: "", + version: "", + content: { ...EMPTY_CONTENT }, + errorMessage: message + }; +} +function findManifest(kbRoot, maxDepth = 3) { + const root = resolve(kbRoot.trim()); + if (!kbRoot.trim()) { + return { paths: [], error: "kbRoot is empty" }; + } + let rootStat; + try { + rootStat = statSync(root); + } catch { + return { paths: [], error: `kbRoot not found: ${root}` }; + } + if (!rootStat.isDirectory()) { + return { paths: [], error: `kbRoot is not a directory: ${root}` }; + } + const direct = join3(root, "MANIFEST.json"); + if (existsSync2(direct)) { + try { + if (statSync(direct).isFile()) + return { paths: [direct] }; + } catch {} + } + const found = []; + const walk = (dir, depth) => { + if (depth > maxDepth) + return; + const candidate = join3(dir, "MANIFEST.json"); + if (existsSync2(candidate)) { + try { + if (statSync(candidate).isFile()) + found.push(candidate); + } catch {} + } + if (depth === maxDepth) + return; + let entries; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const name of entries) { + if (name === "node_modules" || name === ".git") + continue; + const full = join3(dir, name); + let st; + try { + st = statSync(full); + } catch { + continue; + } + if (st.isDirectory()) + walk(full, depth + 1); + } + }; + let topEntries; + try { + topEntries = readdirSync(root); + } catch { + return { paths: [], error: `cannot read kbRoot: ${root}` }; + } + for (const name of topEntries) { + if (name === "node_modules" || name === ".git") + continue; + const full = join3(root, name); + let st; + try { + st = statSync(full); + } catch { + continue; + } + if (st.isDirectory()) + walk(full, 1); + } + return { paths: found }; +} +function asNonEmptyString(value, field) { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`MANIFEST missing required string field: ${field}`); + } + return value.trim(); +} +function parseContent(raw) { + const out = { ...EMPTY_CONTENT }; + if (raw === undefined || raw === null) { + throw new Error("MANIFEST missing required object field: content"); + } + 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; + } + return out; +} +function parseManifest(raw) { + let data; + try { + data = JSON.parse(raw); + } catch (error) { + throw new Error(`MANIFEST is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); + } + if (data === null || typeof data !== "object" || Array.isArray(data)) { + throw new Error("MANIFEST root must be an object"); + } + const row = data; + if (row.schemaVersion !== "1.0") { + throw new Error(`unsupported schemaVersion (want "1.0", got ${JSON.stringify(row.schemaVersion)})`); + } + if (row.packageType !== "operator-subset") { + throw new Error(`unsupported packageType (want "operator-subset", got ${JSON.stringify(row.packageType)})`); + } + const operator = row.operator; + if (operator === null || typeof operator !== "object" || Array.isArray(operator)) { + throw new Error("MANIFEST missing required object field: operator"); + } + const op = operator; + return { + operatorName: asNonEmptyString(op.name, "operator.name"), + country: asNonEmptyString(op.country, "operator.country"), + version: asNonEmptyString(row.version, "version"), + content: parseContent(row.content) + }; +} +function resolveKbRoot(kbRoot, maxDepth = 3) { + const trimmed = kbRoot.trim(); + if (!trimmed) + return unconfiguredKbSnapshot(); + const located = findManifest(trimmed, maxDepth); + if (located.error) + return errorSnapshot(located.error); + if (located.paths.length === 0) { + return errorSnapshot(`no MANIFEST.json under ${resolve(trimmed)} (maxDepth=${maxDepth})`); + } + if (located.paths.length > 1) { + return errorSnapshot(`ambiguous MANIFEST.json (${located.paths.length} hits); pick a unique package root`); + } + const manifestPath = located.paths[0]; + let raw; + try { + raw = readFileSync(manifestPath, "utf8"); + } catch (error) { + return errorSnapshot(`cannot read MANIFEST: ${error instanceof Error ? error.message : String(error)}`); + } + try { + const parsed = parseManifest(raw); + return { + status: "configured", + realRoot: dirname2(manifestPath), + operatorName: parsed.operatorName, + country: parsed.country, + version: parsed.version, + content: parsed.content, + errorMessage: "" + }; + } catch (error) { + return errorSnapshot(error instanceof Error ? error.message : String(error)); + } +} + +// src/netx/kb-runtime.ts +var STORE_KEY2 = Symbol.for("dsh-netxops.kb-store"); +var ENV_KEYS = [ + "KB_ROOT", + "KB_OPERATOR", + "KB_COUNTRY", + "KB_VERSION", + "KB_CONTENT", + "KB_STATUS" +]; function store2() { const root = globalThis; let current = root[STORE_KEY2]; if (current === undefined) { - current = { connection: undefined, listeners: new Set }; + current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set }; root[STORE_KEY2] = current; } return current; } -function publishNetxConnection(next) { +function getKbContext() { + return { ...store2().snapshot, content: { ...store2().snapshot.content } }; +} +function publishKbContext(next) { const state = store2(); + state.snapshot = { + ...next, + content: { ...next.content } + }; + for (const listener of state.listeners) + listener(); +} +function watchKbContext(listener) { + const state = store2(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} +function applyKbEnv(snapshot) { + for (const key of ENV_KEYS) { + delete process.env[key]; + } + process.env.KB_STATUS = snapshot.status; + if (snapshot.status !== "configured") + return; + process.env.KB_ROOT = snapshot.realRoot; + process.env.KB_OPERATOR = snapshot.operatorName; + process.env.KB_COUNTRY = snapshot.country; + process.env.KB_VERSION = snapshot.version; + process.env.KB_CONTENT = JSON.stringify(snapshot.content); +} +function resetKbContext() { + publishKbContext(unconfiguredKbSnapshot()); + applyKbEnv(unconfiguredKbSnapshot()); +} + +// src/netx/runtime.ts +var STORE_KEY3 = Symbol.for("dsh-netxops.connection-store"); +function store3() { + const root = globalThis; + let current = root[STORE_KEY3]; + if (current === undefined) { + current = { connection: undefined, listeners: new Set }; + root[STORE_KEY3] = current; + } + return current; +} +function publishNetxConnection(next) { + const state = store3(); state.connection = next; for (const listener of state.listeners) listener(); } function getNetxConnection() { - return store2().connection; + return store3().connection; } function watchNetxConnection(listener) { - const state = store2(); + const state = store3(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -3390,33 +3693,34 @@ var Config = z.object({ groupOpsInPreset: z.boolean().default(true), groupOpsPublic: z.boolean().default(false), groupTopologyInPreset: z.boolean().default(false), - groupTopologyPublic: z.boolean().default(false) + groupTopologyPublic: z.boolean().default(false), + kbRoot: z.string().default("") }); function packageRoot() { - return join3(dirname2(fileURLToPath2(import.meta.url)), ".."); + return join4(dirname3(fileURLToPath2(import.meta.url)), ".."); } function resolveDshHome() { const fromEnv = process.env.DSH_HOME?.trim(); if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv; - return join3(homedir3(), ".dsh"); + return join4(homedir3(), ".dsh"); } function ensureAgentPresetInstalled(logger) { - const src = join3(packageRoot(), "presets", NETXOPS_PRESET_ID); - const composition = join3(src, "agent.cordis.yml"); - if (!existsSync2(composition)) { + const src = join4(packageRoot(), "presets", NETXOPS_PRESET_ID); + const composition = join4(src, "agent.cordis.yml"); + if (!existsSync3(composition)) { logger.warn("netxops: bundled preset missing at %s — skip user-preset install", src); return; } - const destParent = join3(resolveDshHome(), ".agent-presets"); - const dest = join3(destParent, NETXOPS_PRESET_ID); + const destParent = join4(resolveDshHome(), ".agent-presets"); + const dest = join4(destParent, NETXOPS_PRESET_ID); try { mkdirSync(destParent, { recursive: true }); - if (existsSync2(dest)) { + if (existsSync3(dest)) { rmSync(dest, { recursive: true, force: true }); } cpSync(src, dest, { recursive: true }); - writeFileSync(join3(dest, ".dsh-netxops-managed"), `${new Date().toISOString()} + writeFileSync(join4(dest, ".dsh-netxops-managed"), `${new Date().toISOString()} `, "utf8"); logger.info("netxops: agent preset installed at %s", dest); } catch (error) { @@ -3508,6 +3812,14 @@ function apply(ctx, config = Config({})) { toolCallTimeoutMs: current.toolCallTimeoutMs, groups }); + const kb = resolveKbRoot(current.kbRoot ?? ""); + publishKbContext(kb); + applyKbEnv(kb); + if (kb.status === "configured") { + ctx.logger.info("netxops: knowledge base configured → %s (%s / %s v%s)", kb.realRoot, kb.operatorName, kb.country, kb.version); + } else if (kb.status === "error") { + ctx.logger.warn("netxops: knowledge base error — %s", kb.errorMessage); + } restartAlarmPush(current, apiUrl, token); if (!tokenConfigured) { ctx.logger.warn("netxops: published connection → %s tokenConfigured=false (set credential %s)", apiUrl, current.tokenCredentialRef); @@ -3559,6 +3871,7 @@ function apply(ctx, config = Config({})) { }); ctx.inject(["skills"], (skillsCtx) => { let unregisterSkills; + let unregisterKbSkill; let generation2 = 0; const remountPublicSkills = () => { const gen = ++generation2; @@ -3579,14 +3892,27 @@ function apply(ctx, config = Config({})) { skillsCtx.logger.warn("netxops: public skill register failed: %s", error); }); }; + const remountKbSkill = () => { + unregisterKbSkill?.(); + unregisterKbSkill = undefined; + const snapshot = getKbContext(); + unregisterKbSkill = registerKbContextSkill(skillsCtx, snapshot); + skillsCtx.logger.info("netxops: kb-context skill status=%s", snapshot.status); + }; remountPublicSkills(); + remountKbSkill(); const stopWatch = watchNetxConnection(() => { remountPublicSkills(); }); + const stopKbWatch = watchKbContext(() => { + remountKbSkill(); + }); skillsCtx.effect(() => () => { generation2 += 1; stopWatch(); + stopKbWatch(); unregisterSkills?.(); + unregisterKbSkill?.(); }, "netxops: dispose public skills"); }); ctx.inject(["connection"], (connCtx) => { @@ -3596,10 +3922,17 @@ function apply(ctx, config = Config({})) { connCtx.logger.warn("netxops: connection.rpc.handle unavailable — alarm status UI disabled"); } else { connCtx.effect(() => { - const dispose = rpc.handle(NETXOPS_RPC_CHANNEL, async (endpoint) => { + const dispose = rpc.handle(NETXOPS_RPC_CHANNEL, async (endpoint, payload) => { if (endpoint === "alarm-push.status") { return { ok: true, value: getAlarmPushStatus() }; } + if (endpoint === "kb.status") { + return { ok: true, value: getKbContext() }; + } + if (endpoint === "kb.resolve") { + const path = extractKbResolvePath(payload); + return { ok: true, value: resolveKbRoot(path) }; + } if (endpoint === "sessions.export.status") { const value = await getSessionsExportStatus(ctx); return { ok: true, value }; @@ -3672,8 +4005,23 @@ function apply(ctx, config = Config({})) { stopAlarmPush = undefined; resetAlarmSession(); resetAlarmPushStatus(); + resetKbContext(); }, "netxops: dispose host bridge"); } +function extractKbResolvePath(payload) { + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) + return ""; + const row = payload; + if (typeof row.path === "string") + return row.path; + const args = row.args; + if (args !== null && typeof args === "object" && !Array.isArray(args)) { + const path = args.path; + if (typeof path === "string") + return path; + } + return ""; +} export { name, inject, diff --git a/package.json b/package.json index ea75791..78e9d1f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-netxops", - "version": "0.1.28", - "description": "DeepSeek Harness Netx Ops: capability groups ops/topology + multi IM delivery + session export", + "version": "0.1.29", + "description": "DeepSeek Harness Netx Ops: capability groups ops/topology + multi IM delivery + session export + knowledge-base MANIFEST", "license": "MIT", "type": "module", "private": false, diff --git a/src/client/NetxopsCard.tsx b/src/client/NetxopsCard.tsx index b1edd99..de4e630 100644 --- a/src/client/NetxopsCard.tsx +++ b/src/client/NetxopsCard.tsx @@ -19,6 +19,7 @@ import { type ImDeliveryTarget, } from '../netx/im-targets.ts' import { alarmPushTone, type AlarmPushPhase } from './alarm-push-status-view.ts' +import { kbStatusTone, type KbSnapshot } from './kb-status-view.ts' import { ensureStyles } from './styles.ts' import { sessionsExportReasonLocaleKey } from './sessions-export-view.ts' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' @@ -38,10 +39,12 @@ function phaseLocaleKey(phase: AlarmPushPhase): NetxopsLocaleKey { } function StatusBadge(props: { - phase: AlarmPushPhase + phase?: AlarmPushPhase + tone?: 'ok' | 'warn' | 'err' | 'neutral' label: string }) { - const tone = alarmPushTone(props.phase) + const tone = props.tone + ?? (props.phase !== undefined ? alarmPushTone(props.phase) : 'neutral') const className = tone === 'ok' ? 'dsh-nx-status dsh-nx-statusOk' : tone === 'warn' @@ -52,6 +55,23 @@ function StatusBadge(props: { return {props.label} } +function kbBadgeLabel( + t: (key: NetxopsLocaleKey) => string, + snapshot: KbSnapshot | null, +): string { + if (!snapshot || snapshot.status === 'unconfigured') { + return t('kbStatusUnconfigured') + } + if (snapshot.status === 'error') { + return fillTemplate(t('kbStatusError'), { detail: snapshot.errorMessage || 'error' }) + } + return fillTemplate(t('kbStatusConfigured'), { + operator: snapshot.operatorName, + country: snapshot.country, + version: snapshot.version, + }) +} + function fillTemplate(template: string, vars: Record): string { return template.replace(/\{(\w+)\}/g, (match, name: string) => ( Object.prototype.hasOwnProperty.call(vars, name) ? vars[name]! : match @@ -461,6 +481,68 @@ export function NetxopsCard(props: NetxopsCardProps) { /> +
+

{t('sectionKnowledge')}

+
+
+ + + + {state.kbRoot.overridden + ? ( + <> + {t('overridden')} + + + ) + : null} + +
+
+ { props.edit('kbRoot', event.target.value) }} + /> + +
+

+ {state.kbRoot.invalid + ? t('invalid') + : state.kbDirectoryPickerReady + ? t('kbRootHint') + : t('kbBrowseUnavailable')} +

+ {state.kbStatus?.status === 'error' && state.kbStatus.errorMessage + ?

{state.kbStatus.errorMessage}

+ : null} + {state.kbStatus?.status === 'configured' && state.kbStatus.realRoot + ?

{state.kbStatus.realRoot}

+ : null} +
+
+

{t('sectionAlarms')}

void + /** Open the Host directory picker and write the chosen path into kbRoot. */ + browseKbRoot: () => void +} + +type DirectoryPickerRemote = { + pick: (signal?: AbortSignal) => Promise } type CredentialsRemote = { @@ -119,6 +138,11 @@ export class NetxopsCardController { } private rpcCall: AlarmPushRpcCall | undefined private alarmPushStatus: AlarmPushStatus | null = null + private kbStatus: KbSnapshot | null = null + private kbPreview: KbSnapshot | null = null + private kbDirectoryPicker: DirectoryPickerRemote | undefined + private kbBrowseInFlight = false + private kbStatusInFlight = false private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG } private sessionsExportStatus: SessionsExportStatus | null = null private sessionsExportBusy = false @@ -140,6 +164,7 @@ export class NetxopsCardController { textField('apiUrl'), textField('lang'), textField('nmsProvider'), + textField('kbRoot'), booleanFieldPersistFalse('groupOpsInPreset'), booleanField('groupOpsPublic'), booleanField('groupTopologyInPreset'), @@ -154,7 +179,10 @@ export class NetxopsCardController { [{ field: API_TOKEN_FIELD, write: text => this.writeToken(text) }], ) this.store = this.form.bind(() => this.projection()) - scope.subscribe(() => { void this.readCredential() }) + scope.subscribe(() => { + void this.readCredential() + void this.refreshKbPreview() + }) void this.readCredential() } @@ -171,7 +199,7 @@ export class NetxopsCardController { } /** - * Soft-inject Host Connection RPC used to read alarm-push WSS status. + * Soft-inject Host Connection RPC used to read alarm-push WSS status + KB status. * @param call - `ctx.connection.rpc.call`, or undefined when connection leaves. */ setAlarmPushRpc(call: AlarmPushRpcCall | undefined): void { @@ -183,6 +211,11 @@ export class NetxopsCardController { this.alarmPushStatus = null changed = true } + if (this.kbStatus !== null || this.kbPreview !== null) { + this.kbStatus = null + this.kbPreview = null + changed = true + } if (this.imDeliveryCatalog.options.length > 0 || this.imDeliveryCatalog.available !== true) { this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG } changed = true @@ -196,14 +229,29 @@ export class NetxopsCardController { } this.startStatusPoll() void this.refreshAlarmPushStatus() + void this.refreshKbStatus() + void this.refreshKbPreview() void this.refreshImDeliveryCatalog() void this.refreshSessionsExportStatus() } + /** + * Soft-inject `remote.directoryPicker` for the knowledge-base browse button. + */ + setDirectoryPicker(picker: DirectoryPickerRemote | undefined): void { + const ready = picker !== undefined + if (this.kbDirectoryPicker === picker && (this.kbDirectoryPicker !== undefined) === ready) { + return + } + this.kbDirectoryPicker = picker + this.store.set(this.projection()) + } + private startStatusPoll(): void { if (this.pollTimer !== undefined) return this.pollTimer = setInterval(() => { void this.refreshAlarmPushStatus() + void this.refreshKbStatus() void this.refreshImDeliveryCatalog() void this.refreshSessionsExportStatus() }, STATUS_POLL_MS) @@ -293,6 +341,86 @@ export class NetxopsCardController { } } + private async refreshKbStatus(): Promise { + const call = this.rpcCall + if (call === undefined || this.kbStatusInFlight) return + this.kbStatusInFlight = true + try { + const next = await fetchKbStatus(call) + const prev = this.kbStatus + if ( + prev + && prev.status === next.status + && prev.realRoot === next.realRoot + && prev.operatorName === next.operatorName + && prev.country === next.country + && prev.version === next.version + && prev.errorMessage === next.errorMessage + ) return + this.kbStatus = next + this.store.set(this.projection()) + } catch { + // Keep last good snapshot; next poll retries. + } finally { + this.kbStatusInFlight = false + } + } + + private async refreshKbPreview(): Promise { + const call = this.rpcCall + if (call === undefined) return + const draft = this.form.field('kbRoot').text.trim() + const saved = (this.scope.getSnapshot().value?.kbRoot ?? '').trim() + if (draft === saved) { + if (this.kbPreview !== null) { + this.kbPreview = null + this.store.set(this.projection()) + } + return + } + try { + const next = draft === '' + ? unconfiguredKbSnapshot() + : await resolveKbPath(call, draft) + const prev = this.kbPreview + if ( + prev + && prev.status === next.status + && prev.realRoot === next.realRoot + && prev.operatorName === next.operatorName + && prev.country === next.country + && prev.version === next.version + && prev.errorMessage === next.errorMessage + ) return + this.kbPreview = next + this.store.set(this.projection()) + } catch { + // Keep last preview; edits retry via scope subscribe. + } + } + + /** + * Open the Host OS directory chooser and write the path into kbRoot. + */ + browseKbRoot(): void { + const picker = this.kbDirectoryPicker + if (picker === undefined || this.kbBrowseInFlight) return + this.kbBrowseInFlight = true + void picker.pick() + .then((path) => { + if (typeof path === 'string' && path.trim() !== '') { + this.form.actions().edit('kbRoot', path) + void this.refreshKbPreview() + } + }) + .catch(() => { + // Picker cancel / host refusal — leave draft unchanged. + }) + .finally(() => { + this.kbBrowseInFlight = false + }) + } + /** * Download every durable session as one ZIP through the browser download manager. */ @@ -337,6 +465,7 @@ export class NetxopsCardController { apiUrl: this.form.field('apiUrl'), lang: this.form.field('lang'), nmsProvider: this.form.field('nmsProvider'), + kbRoot: this.form.field('kbRoot'), groupOpsInPreset: this.form.field('groupOpsInPreset'), groupOpsPublic: this.form.field('groupOpsPublic'), groupTopologyInPreset: this.form.field('groupTopologyInPreset'), @@ -352,6 +481,8 @@ export class NetxopsCardController { apiTokenWritable: this.credential.remoteReady && this.credential.writable, apiTokenRemoteReady: this.credential.remoteReady, alarmPushStatus: this.alarmPushStatus, + kbStatus: this.kbPreview ?? this.kbStatus, + kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined, imDeliveryCatalog: this.imDeliveryCatalog, sessionsExportStatus: this.sessionsExportStatus, sessionsExportBusy: this.sessionsExportBusy, @@ -407,10 +538,24 @@ export class NetxopsCardController { } inject(): NetxopsCardFace { + const actions = this.form.actions() return { hooks: { netxopsCard: this.store }, - ...this.form.actions(), + ...actions, + edit: (field, text) => { + actions.edit(field, text) + if (field === 'kbRoot') void this.refreshKbPreview() + }, + discard: () => { + actions.discard() + void this.refreshKbPreview() + }, + resetField: (field) => { + actions.resetField(field) + if (field === 'kbRoot') void this.refreshKbPreview() + }, exportAllSessions: () => { this.exportAllSessions() }, + browseKbRoot: () => { this.browseKbRoot() }, } } diff --git a/src/client/index.ts b/src/client/index.ts index 0f16ad0..02d809d 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -65,7 +65,7 @@ export function apply(ctx: ClientContext): void { }, 'netxops: credential invalidations') }) - // Soft-inject Connection so the card can poll host WSS status. + // Soft-inject Connection so the card can poll host WSS status + KB status. ctx.inject(['connection'], (connCtx) => { const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc) if (typeof call !== 'function') { @@ -78,6 +78,20 @@ export function apply(ctx: ClientContext): void { }, 'netxops: clear alarm-push rpc') }) + // Soft-inject Host directory picker for knowledge-base browse. + ctx.inject(['remote.directoryPicker'], (dpCtx) => { + const picker = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise } } }) + .remote?.directoryPicker + if (!picker || typeof picker.pick !== 'function') { + dpCtx.logger.warn('netxops: remote.directoryPicker.pick unavailable — browse button disabled') + return + } + card.setDirectoryPicker(picker) + dpCtx.effect(() => () => { + card.setDirectoryPicker(undefined) + }, 'netxops: clear directory picker') + }) + ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: NETXOPS_NS, diff --git a/src/client/kb-status-view.ts b/src/client/kb-status-view.ts new file mode 100644 index 0000000..c55b13f --- /dev/null +++ b/src/client/kb-status-view.ts @@ -0,0 +1,80 @@ +/** + * Browser helpers for knowledge-base status via Host Connection RPC. + */ + +import type { KbSnapshot, KbStatus } from '../netx/kb-manifest.ts' +import { unconfiguredKbSnapshot } from '../netx/kb-manifest.ts' +import type { AlarmPushRpcCall } from './alarm-push-status-view.ts' + +/** Same channel as host `NETXOPS_RPC_CHANNEL`. */ +export const NETXOPS_RPC_CHANNEL = '/netxops' + +export const KB_STATUS_ENDPOINT = 'kb.status' +export const KB_RESOLVE_ENDPOINT = 'kb.resolve' + +export type { KbSnapshot, KbStatus } + +/** + * Normalize a host KB snapshot for the card. + */ +export function asKbSnapshot(value: unknown): KbSnapshot { + const empty = unconfiguredKbSnapshot() + if (value === null || typeof value !== 'object' || Array.isArray(value)) return empty + const row = value as Record + const status = row.status === 'configured' || row.status === 'error' || row.status === 'unconfigured' + ? row.status + : 'unconfigured' + const contentRaw = row.content + const content = { ...empty.content } + if (contentRaw !== null && typeof contentRaw === 'object' && !Array.isArray(contentRaw)) { + for (const [key, flag] of Object.entries(contentRaw as Record)) { + content[key] = flag === true + } + } + return { + status, + realRoot: typeof row.realRoot === 'string' ? row.realRoot : '', + operatorName: typeof row.operatorName === 'string' ? row.operatorName : '', + country: typeof row.country === 'string' ? row.country : '', + version: typeof row.version === 'string' ? row.version : '', + content, + errorMessage: typeof row.errorMessage === 'string' ? row.errorMessage : '', + } +} + +/** + * Fetch the published KB snapshot from the Host. + */ +export async function fetchKbStatus( + call: AlarmPushRpcCall, + signal?: AbortSignal, +): Promise { + const result = await call(NETXOPS_RPC_CHANNEL, KB_STATUS_ENDPOINT, {}, signal) + if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) { + return asKbSnapshot((result as { value?: unknown }).value) + } + return unconfiguredKbSnapshot() +} + +/** + * Preview resolve for a path that may not be saved yet. + */ +export async function resolveKbPath( + call: AlarmPushRpcCall, + path: string, + signal?: AbortSignal, +): Promise { + const result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { path }, signal) + if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) { + return asKbSnapshot((result as { value?: unknown }).value) + } + return unconfiguredKbSnapshot() +} + +/** Badge tone for the settings card. */ +export function kbStatusTone(status: KbStatus): 'ok' | 'warn' | 'err' | 'neutral' { + if (status === 'configured') return 'ok' + if (status === 'error') return 'err' + if (status === 'unconfigured') return 'warn' + return 'neutral' +} diff --git a/src/client/locales.ts b/src/client/locales.ts index f107849..ee57bef 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -5,6 +5,7 @@ export type NetxopsLocaleKey = | 'description' | 'sectionConnection' | 'sectionCapabilities' + | 'sectionKnowledge' | 'sectionAlarms' | 'sectionExport' | 'apiUrl' @@ -18,6 +19,13 @@ export type NetxopsLocaleKey = | 'groupTopology' | 'groupInPreset' | 'groupPublic' + | 'kbRoot' + | 'kbRootHint' + | 'kbBrowse' + | 'kbBrowseUnavailable' + | 'kbStatusConfigured' + | 'kbStatusUnconfigured' + | 'kbStatusError' | 'alarmPushEnabled' | 'alarmPushStatus' | 'alarmPushPhaseDisabled' @@ -64,9 +72,10 @@ export type NetxopsLocaleKey = export const en: Record = { title: 'Netx Ops', - description: 'API, token, capabilities, and alarm delivery.', + description: 'API, token, capabilities, knowledge base, and alarm delivery.', sectionConnection: 'Connection', sectionCapabilities: 'Capability groups', + sectionKnowledge: 'Knowledge base', sectionAlarms: 'Key-alarm delivery', sectionExport: 'Session export', apiUrl: 'API URL', @@ -80,6 +89,13 @@ export const en: Record = { groupTopology: 'topology', groupInPreset: 'In Netx Ops preset', groupPublic: 'Publish to other presets', + kbRoot: 'Package root', + kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Empty = pure netx.', + kbBrowse: 'Browse…', + kbBrowseUnavailable: 'Directory picker unavailable — paste an absolute path.', + kbStatusConfigured: 'Knowledge base: {operator} ({country}) v{version}', + kbStatusUnconfigured: 'Knowledge base: not configured (pure netx)', + kbStatusError: 'Knowledge base: error — {detail}', alarmPushEnabled: 'Key-alarm push', alarmPushStatus: 'Status', alarmPushPhaseDisabled: 'Off', @@ -127,9 +143,10 @@ export const en: Record = { export const zh: Record = { title: 'Netx Ops', - description: 'API、Token、能力组与告警投递。', + description: 'API、Token、能力组、知识库与告警投递。', sectionConnection: '连接', sectionCapabilities: '能力组', + sectionKnowledge: '知识库', sectionAlarms: '关键告警投递', sectionExport: '会话导出', apiUrl: 'API 地址', @@ -143,6 +160,13 @@ export const zh: Record = { groupTopology: 'topology', groupInPreset: '在 Netx Ops 预设中启用', groupPublic: '对其他预设公开', + kbRoot: '知识包根目录', + kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。留空=纯 netx。', + kbBrowse: '浏览…', + kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对路径。', + kbStatusConfigured: '知识库: {operator}({country}) v{version}', + kbStatusUnconfigured: '知识库: 未配置(纯 netx)', + kbStatusError: '知识库: 错误 — {detail}', alarmPushEnabled: '关键告警推送', alarmPushStatus: '状态', alarmPushPhaseDisabled: '未开启', diff --git a/src/client/styles.ts b/src/client/styles.ts index 389610e..ff6e350 100644 --- a/src/client/styles.ts +++ b/src/client/styles.ts @@ -41,7 +41,9 @@ const CSS = ` .dsh-nx-groupTitle{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329)} .dsh-nx-groupChecks{display:flex;flex-direction:column;gap:8px;padding-left:2px} .dsh-nx-imTargetList{display:flex;flex-direction:column;gap:8px;padding:4px 0 2px} -.dsh-nx-settings-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px} +.dsh-nx-pathRow{display:flex;gap:8px;align-items:stretch} +.dsh-nx-pathRow>input{flex:1;min-width:0} +.dsh-nx-pathRow>.dsh-nx-btn{flex:none;align-self:stretch} .dsh-nx-settings-msg{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)} .dsh-nx-settings-msg.ok{color:var(--dsw-alias-state-success-primary,#20a162)} .dsh-nx-settings-msg.err{color:var(--dsw-alias-state-error-primary,#d54941)} diff --git a/src/index.ts b/src/index.ts index d7bcf3a..9c0a168 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,9 @@ /** - * Host-plane Netx Ops: settings (apiUrl / lang / alarm push / capability groups) - * + credentials (NETX_API_TOKEN) publish a connection snapshot. The Ops preset - * mounts selected `netx__*` groups via `dsh-netxops/tools`; groups marked public - * also register on the host tool/skill layer for other presets. + * Host-plane Netx Ops: settings (apiUrl / lang / alarm push / capability groups / + * optional knowledge-base root) + credentials (NETX_API_TOKEN) publish a + * connection snapshot. The Ops preset mounts selected `netx__*` groups via + * `dsh-netxops/tools`; groups marked public also register on the host tool/skill + * layer for other presets. * * When「关键告警推送」is on, this host dials out to netx's fixed-IP alarm hub and * opens/follows a sticky DSH session (im / WhatsApp is optional and separate). @@ -11,6 +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). + * * @module dsh-netxops */ @@ -39,6 +44,15 @@ import { } from './netx/capability-groups.ts' 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 { + applyKbEnv, + getKbContext, + publishKbContext, + resetKbContext, + watchKbContext, +} from './netx/kb-runtime.ts' import { publishNetxConnection, getNetxConnection, watchNetxConnection } from './netx/runtime.ts' import { getSessionsExportStatus, @@ -115,6 +129,12 @@ export interface Config { groupTopologyInPreset: boolean /** Publish topology tools/skills to other presets (default off). */ groupTopologyPublic: boolean + /** + * 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. + */ + kbRoot: string } export const Config: z = z.object({ @@ -134,6 +154,7 @@ export const Config: z = z.object({ groupOpsPublic: z.boolean().default(false), groupTopologyInPreset: z.boolean().default(false), groupTopologyPublic: z.boolean().default(false), + kbRoot: z.string().default(''), }) /** Package root (parent of `lib/` or `src/` depending on launch). */ @@ -297,6 +318,20 @@ export function apply(ctx: Context, config: Config = Config({})): void { toolCallTimeoutMs: current.toolCallTimeoutMs, groups, }) + const kb = resolveKbRoot(current.kbRoot ?? '') + publishKbContext(kb) + applyKbEnv(kb) + if (kb.status === 'configured') { + ctx.logger.info( + 'netxops: knowledge base configured → %s (%s / %s v%s)', + kb.realRoot, + kb.operatorName, + kb.country, + kb.version, + ) + } else if (kb.status === 'error') { + ctx.logger.warn('netxops: knowledge base error — %s', kb.errorMessage) + } restartAlarmPush(current, apiUrl, token) if (!tokenConfigured) { ctx.logger.warn( @@ -364,6 +399,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { ctx.inject(['skills'], (skillsCtx) => { let unregisterSkills: (() => void) | undefined + let unregisterKbSkill: (() => void) | undefined let generation = 0 const remountPublicSkills = (): void => { const gen = ++generation @@ -386,19 +422,36 @@ export function apply(ctx: Context, config: Config = Config({})): void { skillsCtx.logger.warn('netxops: public skill register failed: %s', error) }) } + const remountKbSkill = (): void => { + unregisterKbSkill?.() + unregisterKbSkill = undefined + const snapshot = getKbContext() + unregisterKbSkill = registerKbContextSkill(skillsCtx, snapshot) + skillsCtx.logger.info( + 'netxops: kb-context skill status=%s', + snapshot.status, + ) + } remountPublicSkills() + remountKbSkill() const stopWatch = watchNetxConnection(() => { remountPublicSkills() }) + const stopKbWatch = watchKbContext(() => { remountKbSkill() }) skillsCtx.effect(() => () => { generation += 1 stopWatch() + stopKbWatch() unregisterSkills?.() + unregisterKbSkill?.() }, 'netxops: dispose public skills') }) // Browser card: alarm-push status + IM catalog (RPC) and all-sessions ZIP (Fetch). ctx.inject(['connection'], (connCtx) => { const connection = connCtx.connection as { - rpc?: { handle?: (channel: string, handler: (endpoint: string) => Promise) => (() => void) | Promise } + rpc?: { handle?: ( + channel: string, + handler: (endpoint: string, payload?: unknown) => Promise, + ) => (() => void) | Promise } fetch?: { register?: (route: { readonly path: string @@ -414,10 +467,17 @@ export function apply(ctx: Context, config: Config = Config({})): void { connCtx.effect(() => { const dispose = rpc.handle( NETXOPS_RPC_CHANNEL, - async (endpoint: string) => { + async (endpoint: string, payload?: unknown) => { if (endpoint === 'alarm-push.status') { return { ok: true, value: getAlarmPushStatus() } } + if (endpoint === 'kb.status') { + return { ok: true, value: getKbContext() } + } + if (endpoint === 'kb.resolve') { + const path = extractKbResolvePath(payload) + return { ok: true, value: resolveKbRoot(path) } + } if (endpoint === 'sessions.export.status') { const value = await getSessionsExportStatus(ctx) return { ok: true, value } @@ -492,5 +552,19 @@ export function apply(ctx: Context, config: Config = Config({})): void { stopAlarmPush = undefined resetAlarmSession() resetAlarmPushStatus() + resetKbContext() }, 'netxops: dispose host bridge') } + +/** Pull `path` from either `{ path }` or `{ args: { path } }` RPC payloads. */ +function extractKbResolvePath(payload: unknown): string { + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return '' + const row = payload as Record + if (typeof row.path === 'string') return row.path + const args = row.args + if (args !== null && typeof args === 'object' && !Array.isArray(args)) { + const path = (args as Record).path + if (typeof path === 'string') return path + } + return '' +} diff --git a/src/netx/group-tools-plugin.ts b/src/netx/group-tools-plugin.ts index ea678b2..f679de8 100644 --- a/src/netx/group-tools-plugin.ts +++ b/src/netx/group-tools-plugin.ts @@ -10,6 +10,8 @@ import { type NetxCapabilityGroupId, } from './capability-groups.ts' import { registerGroupSkills } from './group-skills.ts' +import { registerKbContextSkill } from './kb-context-skill.ts' +import { getKbContext, watchKbContext } from './kb-runtime.ts' import { getNetxConnection, watchNetxConnection } from './runtime.ts' import { registerNetxTools } from './tools.ts' @@ -72,6 +74,7 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt const stopToolWatch = watchNetxConnection(() => { remountTools() }) ctx.inject(['skills'], (skillsCtx) => { + let unregisterKbSkill: (() => void) | undefined const remountSkills = (): void => { const gen = ++skillGeneration unregisterSkills?.() @@ -88,14 +91,24 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt skillsCtx.logger.warn('%s: skill register failed: %s', options.name, error) }) } + const remountKbSkill = (): void => { + unregisterKbSkill?.() + unregisterKbSkill = undefined + unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext()) + } remountSkills() + remountKbSkill() const stopSkillWatch = watchNetxConnection(() => { remountSkills() }) + const stopKbWatch = watchKbContext(() => { remountKbSkill() }) skillsCtx.effect(() => () => { skillGeneration += 1 stopSkillWatch() + stopKbWatch() unregisterSkills?.() unregisterSkills = undefined + unregisterKbSkill?.() + unregisterKbSkill = undefined }, `${options.name}: dispose skills`) }) diff --git a/src/netx/kb-context-skill.ts b/src/netx/kb-context-skill.ts new file mode 100644 index 0000000..6119775 --- /dev/null +++ b/src/netx/kb-context-skill.ts @@ -0,0 +1,85 @@ +/** + * Dynamic `kb-context` skill — model-visible annotation of the KB snapshot. + * Tools/scripts still use `process.env.KB_*` from `applyKbEnv`. + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { KbSnapshot } from './kb-manifest.ts' + +const SKILL_NAME = 'kb-context' + +function skillBody(snapshot: KbSnapshot): { description: string; content: string } { + if (snapshot.status === 'configured') { + const flags = Object.entries(snapshot.content) + .filter(([, on]) => on) + .map(([key]) => key) + .join(', ') || '(none)' + return { + description: + 'Operator knowledge-base context for this Host (paths + identity from MANIFEST).', + content: [ + '## Knowledge base (configured)', + '', + 'When troubleshooting with operator playbooks or docs, **compose paths from this root**.', + 'Do not invent another operator or country.', + '', + `| Field | Value |`, + `| --- | --- |`, + `| kbStatus | configured |`, + `| kbRoot | \`${snapshot.realRoot}\` |`, + `| kbOperator | ${snapshot.operatorName} |`, + `| kbCountry | ${snapshot.country} |`, + `| kbVersion | ${snapshot.version} |`, + `| kbContent (on) | ${flags} |`, + '', + 'Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`.', + ].join('\n'), + } + } + + const reason = snapshot.status === 'error' + ? (snapshot.errorMessage || 'invalid knowledge base') + : 'kbRoot is empty' + + return { + description: + 'Knowledge-base context is unavailable — stay on pure netx evidence.', + content: [ + '## Knowledge base (unavailable)', + '', + `kbStatus=\`${snapshot.status}\`. ${reason}`, + '', + '**Do not invent an operator, country, or KB paths.**', + 'Use only netx tools / live evidence (alarms, inventory, CLI, topology).', + 'Operator-specific playbooks are out of scope until a valid MANIFEST is configured.', + ].join('\n'), + } +} + +/** + * Register (or replace) the `kb-context` skill for the current snapshot. + * @returns disposer that unregisters the skill. + */ +export function registerKbContextSkill( + ctx: Context, + snapshot: KbSnapshot, +): () => void { + const skillsApi = (ctx as { skills?: { register: (skill: { + name: string + description: string + content: string + provider?: string + source: string + }) => () => void } }).skills + if (!skillsApi || typeof skillsApi.register !== 'function') { + return () => {} + } + const body = skillBody(snapshot) + return skillsApi.register({ + name: SKILL_NAME, + description: body.description, + content: body.content, + provider: 'netxops-kb', + source: 'custom', + }) +} diff --git a/src/netx/kb-manifest.ts b/src/netx/kb-manifest.ts new file mode 100644 index 0000000..d251f9e --- /dev/null +++ b/src/netx/kb-manifest.ts @@ -0,0 +1,261 @@ +/** + * Locate and validate an operator-subset MANIFEST.json under a kbRoot. + * + * Contract (MANIFEST v1.0): + * - schemaVersion === "1.0" + * - packageType === "operator-subset" + * - operator.name / operator.country, version required + * - content.* boolean flags (missing → false) + * + * Location: `${kbRoot}/MANIFEST.json`, else recurse ≤ maxDepth and accept + * exactly one hit (0 or >1 → error). + */ + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' + +/** Known content flags; unknown keys are preserved when boolean. */ +export interface KbContentFlags { + regions: boolean + theory: boolean + packet: boolean + skills: boolean + [key: string]: boolean +} + +export type KbStatus = 'unconfigured' | 'configured' | 'error' + +export interface KbSnapshot { + status: KbStatus + /** Absolute real package root (directory that holds MANIFEST.json). */ + realRoot: string + operatorName: string + country: string + version: string + content: KbContentFlags + errorMessage: string +} + +const EMPTY_CONTENT: KbContentFlags = { + regions: false, + theory: false, + packet: false, + skills: false, +} + +/** Empty / unconfigured snapshot (kbRoot blank). */ +export function unconfiguredKbSnapshot(): KbSnapshot { + return { + status: 'unconfigured', + realRoot: '', + operatorName: '', + country: '', + version: '', + content: { ...EMPTY_CONTENT }, + errorMessage: '', + } +} + +function errorSnapshot(message: string): KbSnapshot { + return { + status: 'error', + realRoot: '', + operatorName: '', + country: '', + version: '', + content: { ...EMPTY_CONTENT }, + errorMessage: message, + } +} + +/** + * Recursively collect `MANIFEST.json` paths under `root`, depth-limited. + * Depth 0 = root itself; children are depth 1..maxDepth. + */ +export function findManifest( + kbRoot: string, + maxDepth = 3, +): { paths: string[]; error?: string } { + const root = resolve(kbRoot.trim()) + if (!kbRoot.trim()) { + return { paths: [], error: 'kbRoot is empty' } + } + let rootStat + try { + rootStat = statSync(root) + } catch { + return { paths: [], error: `kbRoot not found: ${root}` } + } + if (!rootStat.isDirectory()) { + return { paths: [], error: `kbRoot is not a directory: ${root}` } + } + + const direct = join(root, 'MANIFEST.json') + if (existsSync(direct)) { + try { + if (statSync(direct).isFile()) return { paths: [direct] } + } catch { + // fall through to search + } + } + + const found: string[] = [] + const walk = (dir: string, depth: number): void => { + if (depth > maxDepth) return + const candidate = join(dir, 'MANIFEST.json') + if (existsSync(candidate)) { + try { + if (statSync(candidate).isFile()) found.push(candidate) + } catch { + // ignore unreadable + } + } + if (depth === maxDepth) return + let entries: string[] + try { + entries = readdirSync(dir) + } catch { + return + } + for (const name of entries) { + if (name === 'node_modules' || name === '.git') continue + const full = join(dir, name) + let st + try { + st = statSync(full) + } catch { + continue + } + if (st.isDirectory()) walk(full, depth + 1) + } + } + // Root already checked; search nested packages at depth 1..maxDepth. + let topEntries: string[] + try { + topEntries = readdirSync(root) + } catch { + return { paths: [], error: `cannot read kbRoot: ${root}` } + } + for (const name of topEntries) { + if (name === 'node_modules' || name === '.git') continue + const full = join(root, name) + let st + try { + st = statSync(full) + } catch { + continue + } + if (st.isDirectory()) walk(full, 1) + } + return { paths: found } +} + +function asNonEmptyString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`MANIFEST missing required string field: ${field}`) + } + return value.trim() +} + +function parseContent(raw: unknown): KbContentFlags { + const out: KbContentFlags = { ...EMPTY_CONTENT } + if (raw === undefined || raw === null) { + throw new Error('MANIFEST missing required object field: content') + } + if (typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('MANIFEST content must be an object') + } + for (const [key, value] of Object.entries(raw as Record)) { + out[key] = value === true + } + return out +} + +/** + * Parse and validate MANIFEST JSON text (UTF-8). + * @throws Error with a short message when invalid. + */ +export function parseManifest(raw: string): { + operatorName: string + country: string + version: string + content: KbContentFlags +} { + let data: unknown + try { + data = JSON.parse(raw) + } catch (error) { + throw new Error( + `MANIFEST is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + ) + } + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('MANIFEST root must be an object') + } + const row = data as Record + if (row.schemaVersion !== '1.0') { + throw new Error( + `unsupported schemaVersion (want "1.0", got ${JSON.stringify(row.schemaVersion)})`, + ) + } + if (row.packageType !== 'operator-subset') { + throw new Error( + `unsupported packageType (want "operator-subset", got ${JSON.stringify(row.packageType)})`, + ) + } + const operator = row.operator + if (operator === null || typeof operator !== 'object' || Array.isArray(operator)) { + throw new Error('MANIFEST missing required object field: operator') + } + const op = operator as Record + return { + operatorName: asNonEmptyString(op.name, 'operator.name'), + country: asNonEmptyString(op.country, 'operator.country'), + version: asNonEmptyString(row.version, 'version'), + content: parseContent(row.content), + } +} + +/** + * Resolve kbRoot → KbSnapshot (unconfigured / configured / error). + */ +export function resolveKbRoot(kbRoot: string, maxDepth = 3): KbSnapshot { + const trimmed = kbRoot.trim() + if (!trimmed) return unconfiguredKbSnapshot() + + const located = findManifest(trimmed, maxDepth) + if (located.error) return errorSnapshot(located.error) + if (located.paths.length === 0) { + return errorSnapshot(`no MANIFEST.json under ${resolve(trimmed)} (maxDepth=${maxDepth})`) + } + if (located.paths.length > 1) { + return errorSnapshot( + `ambiguous MANIFEST.json (${located.paths.length} hits); pick a unique package root`, + ) + } + + const manifestPath = located.paths[0]! + let raw: string + try { + raw = readFileSync(manifestPath, 'utf8') + } catch (error) { + return errorSnapshot( + `cannot read MANIFEST: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + try { + const parsed = parseManifest(raw) + return { + status: 'configured', + realRoot: dirname(manifestPath), + operatorName: parsed.operatorName, + country: parsed.country, + version: parsed.version, + content: parsed.content, + errorMessage: '', + } + } catch (error) { + return errorSnapshot(error instanceof Error ? error.message : String(error)) + } +} diff --git a/src/netx/kb-runtime.ts b/src/netx/kb-runtime.ts new file mode 100644 index 0000000..cb4d814 --- /dev/null +++ b/src/netx/kb-runtime.ts @@ -0,0 +1,90 @@ +/** + * Process-local knowledge-base snapshot shared between the host settings + * bridge, RPC, and dynamic kb-context skill registration. + * + * Uses `Symbol.for` on `globalThis` so host + agent-tools bundles share one store + * even when Bun emits them as separate ESM files. + */ + +import type { KbSnapshot } from './kb-manifest.ts' +import { unconfiguredKbSnapshot } from './kb-manifest.ts' + +type Listener = () => void + +interface Store { + snapshot: KbSnapshot + listeners: Set +} + +const STORE_KEY = Symbol.for('dsh-netxops.kb-store') + +const ENV_KEYS = [ + 'KB_ROOT', + 'KB_OPERATOR', + 'KB_COUNTRY', + 'KB_VERSION', + 'KB_CONTENT', + 'KB_STATUS', +] as const + +function store(): Store { + const root = globalThis as typeof globalThis & { [STORE_KEY]?: Store } + let current = root[STORE_KEY] + if (current === undefined) { + current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set() } + root[STORE_KEY] = current + } + return current +} + +/** @returns the last published KB snapshot. */ +export function getKbContext(): KbSnapshot { + return { ...store().snapshot, content: { ...store().snapshot.content } } +} + +/** + * Publish the latest KB snapshot for UI / skill remounts. + * @param next - resolved snapshot from `resolveKbRoot`. + */ +export function publishKbContext(next: KbSnapshot): void { + const state = store() + state.snapshot = { + ...next, + content: { ...next.content }, + } + for (const listener of state.listeners) listener() +} + +/** + * Subscribe to KB snapshot publishes (settings remounts). + * @param listener - called synchronously after each publish. + * @returns disposer. + */ +export function watchKbContext(listener: Listener): () => void { + const state = store() + state.listeners.add(listener) + return () => { state.listeners.delete(listener) } +} + +/** + * Mirror the snapshot into `process.env.KB_*` for tools / future KB skill packs. + * Clears identity fields when status is not `configured`. + */ +export function applyKbEnv(snapshot: KbSnapshot): void { + for (const key of ENV_KEYS) { + delete process.env[key] + } + process.env.KB_STATUS = snapshot.status + if (snapshot.status !== 'configured') return + process.env.KB_ROOT = snapshot.realRoot + process.env.KB_OPERATOR = snapshot.operatorName + process.env.KB_COUNTRY = snapshot.country + process.env.KB_VERSION = snapshot.version + process.env.KB_CONTENT = JSON.stringify(snapshot.content) +} + +/** Reset store + env to unconfigured (plugin dispose). */ +export function resetKbContext(): void { + publishKbContext(unconfiguredKbSnapshot()) + applyKbEnv(unconfiguredKbSnapshot()) +} diff --git a/test/kb-manifest.test.mjs b/test/kb-manifest.test.mjs new file mode 100644 index 0000000..bf3aef3 --- /dev/null +++ b/test/kb-manifest.test.mjs @@ -0,0 +1,151 @@ +/** + * MANIFEST locate + validate for operator-subset knowledge packages. + */ + +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 { + findManifest, + parseManifest, + resolveKbRoot, +} from '../src/netx/kb-manifest.ts' + +function validManifest(overrides = {}) { + return JSON.stringify({ + schemaVersion: '1.0', + packageType: 'operator-subset', + operator: { name: 'IOH', country: 'ID' }, + version: '2026.09.01', + content: { regions: true, theory: false, packet: true }, + ...overrides, + }, null, 2) +} + +function withTemp(run) { + const root = mkdtempSync(join(tmpdir(), 'netxops-kb-')) + try { + run(root) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +test('empty kbRoot → unconfigured', () => { + const snap = resolveKbRoot('') + assert.equal(snap.status, 'unconfigured') + assert.equal(snap.realRoot, '') + assert.equal(snap.errorMessage, '') +}) + +test('direct MANIFEST at kbRoot', () => { + withTemp((root) => { + writeFileSync(join(root, 'MANIFEST.json'), validManifest(), 'utf8') + const snap = resolveKbRoot(root) + assert.equal(snap.status, 'configured') + assert.equal(snap.realRoot, root) + 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) + }) +}) + +test('nested one level under maxDepth', () => { + withTemp((root) => { + const pkg = join(root, 'packages', 'ioh') + mkdirSync(pkg, { recursive: true }) + writeFileSync(join(pkg, 'MANIFEST.json'), validManifest({ + operator: { name: 'XL', country: 'ID' }, + version: '1.2.3', + }), 'utf8') + const snap = resolveKbRoot(root) + assert.equal(snap.status, 'configured') + assert.equal(snap.realRoot, pkg) + assert.equal(snap.operatorName, 'XL') + assert.equal(snap.version, '1.2.3') + }) +}) + +test('zero manifests → error', () => { + withTemp((root) => { + mkdirSync(join(root, 'empty'), { recursive: true }) + const snap = resolveKbRoot(root) + assert.equal(snap.status, 'error') + assert.match(snap.errorMessage, /no MANIFEST\.json/) + }) +}) + +test('multiple nested manifests → error', () => { + withTemp((root) => { + const a = join(root, 'a') + const b = join(root, 'b') + mkdirSync(a, { recursive: true }) + mkdirSync(b, { recursive: true }) + writeFileSync(join(a, 'MANIFEST.json'), validManifest(), 'utf8') + writeFileSync(join(b, 'MANIFEST.json'), validManifest({ + operator: { name: 'Other', country: 'XX' }, + }), 'utf8') + const snap = resolveKbRoot(root) + assert.equal(snap.status, 'error') + assert.match(snap.errorMessage, /ambiguous/) + }) +}) + +test('bad JSON → error', () => { + withTemp((root) => { + writeFileSync(join(root, 'MANIFEST.json'), '{not-json', 'utf8') + const snap = resolveKbRoot(root) + assert.equal(snap.status, 'error') + assert.match(snap.errorMessage, /not valid JSON/) + }) +}) + +test('wrong schemaVersion / packageType rejected', () => { + assert.throws( + () => parseManifest(validManifest({ schemaVersion: '2.0' })), + /schemaVersion/, + ) + assert.throws( + () => parseManifest(validManifest({ packageType: 'full-tree' })), + /packageType/, + ) +}) + +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 withoutContent = JSON.stringify({ + schemaVersion: '1.0', + packageType: 'operator-subset', + operator: { name: 'IOH', country: 'ID' }, + version: '1', + }) + assert.throws(() => parseManifest(withoutContent), /content/) +}) + +test('findManifest respects maxDepth', () => { + withTemp((root) => { + const deep = join(root, 'a', 'b', 'c', 'd') + mkdirSync(deep, { recursive: true }) + writeFileSync(join(deep, 'MANIFEST.json'), validManifest(), 'utf8') + const tooDeep = findManifest(root, 3) + assert.equal(tooDeep.paths.length, 0) + const ok = findManifest(root, 4) + assert.equal(ok.paths.length, 1) + }) +}) + +test('missing kbRoot path → error', () => { + const snap = resolveKbRoot(join(tmpdir(), 'netxops-kb-missing-' + Date.now())) + assert.equal(snap.status, 'error') + assert.match(snap.errorMessage, /not found/) +}) diff --git a/test/kb-runtime.test.mjs b/test/kb-runtime.test.mjs new file mode 100644 index 0000000..00e54f2 --- /dev/null +++ b/test/kb-runtime.test.mjs @@ -0,0 +1,39 @@ +/** + * KB runtime env mirroring. + */ + +import assert from 'node:assert/strict' +import test from 'node:test' + +import { resolveKbRoot } from '../src/netx/kb-manifest.ts' +import { applyKbEnv, getKbContext, publishKbContext, resetKbContext } from '../src/netx/kb-runtime.ts' + +test('applyKbEnv clears identity when unconfigured', () => { + process.env.KB_ROOT = '/tmp/x' + process.env.KB_OPERATOR = 'IOH' + applyKbEnv(resolveKbRoot('')) + assert.equal(process.env.KB_STATUS, 'unconfigured') + assert.equal(process.env.KB_ROOT, undefined) + assert.equal(process.env.KB_OPERATOR, undefined) + resetKbContext() +}) + +test('publishKbContext updates store', () => { + publishKbContext({ + status: 'configured', + realRoot: '/kb', + operatorName: 'IOH', + country: 'ID', + version: '1', + content: { regions: true, theory: false, packet: false, skills: false }, + errorMessage: '', + }) + const snap = getKbContext() + assert.equal(snap.status, 'configured') + assert.equal(snap.operatorName, 'IOH') + 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')) + resetKbContext() +})