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

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

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

View file

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

View file

@ -57,6 +57,10 @@ export interface NetxopsSettings {
nmsProvider?: string
/** Operator-subset knowledge package root (Host-local path). */
kbRoot?: string
/** Mount `_skills/kb-*` into Netx Ops preset. */
groupKbInPreset?: boolean
/** Publish `_skills/kb-*` to other presets. */
groupKbPublic?: boolean
}
interface CredentialState {
@ -75,6 +79,8 @@ export interface NetxopsCardState extends CardShell {
groupTopologyPublic: CardFieldState
nmsProvider: CardFieldState
kbRoot: CardFieldState
groupKbInPreset: CardFieldState
groupKbPublic: CardFieldState
alarmPushEnabled: CardFieldState
alarmDeliverDsh: CardFieldState
alarmDeliverIm: CardFieldState
@ -172,6 +178,8 @@ export class NetxopsCardController {
booleanField('groupOpsPublic'),
booleanField('groupTopologyInPreset'),
booleanField('groupTopologyPublic'),
booleanFieldPersistFalse('groupKbInPreset'),
booleanField('groupKbPublic'),
booleanField('alarmPushEnabled'),
booleanFieldPersistFalse('alarmDeliverDsh'),
booleanField('alarmDeliverIm'),
@ -519,6 +527,8 @@ export class NetxopsCardController {
groupOpsPublic: this.form.field('groupOpsPublic'),
groupTopologyInPreset: this.form.field('groupTopologyInPreset'),
groupTopologyPublic: this.form.field('groupTopologyPublic'),
groupKbInPreset: this.form.field('groupKbInPreset'),
groupKbPublic: this.form.field('groupKbPublic'),
alarmPushEnabled: this.form.field('alarmPushEnabled'),
alarmDeliverDsh: this.form.field('alarmDeliverDsh'),
alarmDeliverIm: this.form.field('alarmDeliverIm'),

View file

@ -17,6 +17,7 @@ export type NetxopsLocaleKey =
| 'nmsProviderHint'
| 'groupOps'
| 'groupTopology'
| 'groupKb'
| 'groupInPreset'
| 'groupPublic'
| 'kbRoot'
@ -89,10 +90,11 @@ export const en: Record<NetxopsLocaleKey, string> = {
nmsProviderHint: 'zte-ume',
groupOps: 'ops',
groupTopology: 'topology',
groupKb: 'Knowledge skills',
groupInPreset: 'In Netx Ops preset',
groupPublic: 'Publish to other presets',
kbRoot: 'Package root',
kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Browse auto-saves.',
kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Browse auto-saves. Pack skills load from _skills/ when hasSkills.',
kbBrowse: 'Browse…',
kbBrowseUnavailable: 'Directory picker unavailable — paste an absolute folder path, then Save.',
kbBrowseFailed: 'Browse failed: {detail}',
@ -162,10 +164,11 @@ export const zh: Record<NetxopsLocaleKey, string> = {
nmsProviderHint: 'zte-ume',
groupOps: 'ops',
groupTopology: 'topology',
groupKb: '知识库技能',
groupInPreset: '在 Netx Ops 预设中启用',
groupPublic: '对其他预设公开',
kbRoot: '知识包根目录',
kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存。',
kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存;hasSkills 时加载 _skills/。',
kbBrowse: '浏览…',
kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对目录路径后点保存。',
kbBrowseFailed: '浏览失败:{detail}',

View file

@ -12,9 +12,10 @@
* (`GET /api/netxops.sessions.export`) for HQ analysis — including cloud Hosts,
* where the browser receives the archive.
*
* Knowledge base (P1): settings `kbRoot` → locate/validate MANIFEST.json →
* `process.env.KB_*` + dynamic `kb-context` skill. Unconfigured/error degrades
* to pure netx (no invented operator).
* Knowledge base: settings `kbRoot` → locate/validate MANIFEST.json →
* `process.env.KB_*` + dynamic `kb-context` + optional `_skills/kb-*` packs
* (default Netx Ops preset; optional public). Unconfigured/error degrades to
* pure netx (no invented operator).
*
* @module dsh-netxops
*/
@ -46,6 +47,7 @@ import { registerGroupSkills } from './netx/group-skills.ts'
import { resolveImTargets } from './netx/im-targets.ts'
import { registerKbContextSkill } from './netx/kb-context-skill.ts'
import { resolveKbRoot } from './netx/kb-manifest.ts'
import { registerKbPackSkills } from './netx/kb-pack-skills.ts'
import {
applyKbEnv,
getKbContext,
@ -132,9 +134,13 @@ export interface Config {
/**
* Absolute (or Host-local) path to an operator-subset knowledge package.
* Empty = unconfigured. Plugin locates `MANIFEST.json` (≤3 levels) and
* injects `KB_*` + `kb-context` skill.
* injects `KB_*` + `kb-context` skill + optional `_skills` packs.
*/
kbRoot: string
/** Mount subset `_skills/kb-*` into the Netx Ops preset (default on). */
groupKbInPreset: boolean
/** Publish subset `_skills/kb-*` to other presets (default off). */
groupKbPublic: boolean
}
export const Config: z<Config> = z.object({
@ -155,6 +161,8 @@ export const Config: z<Config> = z.object({
groupTopologyInPreset: z.boolean().default(false),
groupTopologyPublic: z.boolean().default(false),
kbRoot: z.string().default(''),
groupKbInPreset: z.boolean().default(true),
groupKbPublic: z.boolean().default(false),
})
/** Package root (parent of `lib/` or `src/` depending on launch). */
@ -317,6 +325,8 @@ export function apply(ctx: Context, config: Config = Config({})): void {
lang: current.lang,
toolCallTimeoutMs: current.toolCallTimeoutMs,
groups,
groupKbInPreset: current.groupKbInPreset !== false,
groupKbPublic: current.groupKbPublic === true,
})
const kb = resolveKbRoot(current.kbRoot ?? '')
publishKbContext(kb)
@ -400,7 +410,9 @@ export function apply(ctx: Context, config: Config = Config({})): void {
ctx.inject(['skills'], (skillsCtx) => {
let unregisterSkills: (() => void) | undefined
let unregisterKbSkill: (() => void) | undefined
let unregisterKbPack: (() => void) | undefined
let generation = 0
let packGeneration = 0
const remountPublicSkills = (): void => {
const gen = ++generation
unregisterSkills?.()
@ -432,16 +444,44 @@ export function apply(ctx: Context, config: Config = Config({})): void {
snapshot.status,
)
}
const remountKbPack = (): void => {
const gen = ++packGeneration
unregisterKbPack?.()
unregisterKbPack = undefined
const connection = getNetxConnection()
const enabled = connection?.groupKbPublic === true
void registerKbPackSkills(skillsCtx, getKbContext(), {
enabled,
providerLabel: 'netxops-kb-pack-public',
}).then((dispose) => {
if (gen !== packGeneration) {
dispose()
return
}
unregisterKbPack = dispose
}).catch((error) => {
skillsCtx.logger.warn('netxops: public kb pack skill register failed: %s', error)
})
}
remountPublicSkills()
remountKbSkill()
const stopWatch = watchNetxConnection(() => { remountPublicSkills() })
const stopKbWatch = watchKbContext(() => { remountKbSkill() })
remountKbPack()
const stopWatch = watchNetxConnection(() => {
remountPublicSkills()
remountKbPack()
})
const stopKbWatch = watchKbContext(() => {
remountKbSkill()
remountKbPack()
})
skillsCtx.effect(() => () => {
generation += 1
packGeneration += 1
stopWatch()
stopKbWatch()
unregisterSkills?.()
unregisterKbSkill?.()
unregisterKbPack?.()
}, 'netxops: dispose public skills')
})

View file

@ -21,6 +21,8 @@ interface ParsedSkill {
directory: string
}
export type { ParsedSkill }
/**
* Package `presets/netxops/skills` root, or a shared netx skills checkout.
*
@ -95,7 +97,10 @@ function stripQuotes(value: string): string {
return value
}
async function loadSkillBundle(dir: string): Promise<ParsedSkill | null> {
/**
* Load one skill directory (`SKILL.md` + frontmatter). Exported for KB pack loader.
*/
export async function loadSkillBundle(dir: string): Promise<ParsedSkill | null> {
const skillPath = join(dir, 'SKILL.md')
let raw: string
try {

View file

@ -11,6 +11,7 @@ import {
} from './capability-groups.ts'
import { registerGroupSkills } from './group-skills.ts'
import { registerKbContextSkill } from './kb-context-skill.ts'
import { registerKbPackSkills } from './kb-pack-skills.ts'
import { getKbContext, watchKbContext } from './kb-runtime.ts'
import { getNetxConnection, watchNetxConnection } from './runtime.ts'
import { registerNetxTools } from './tools.ts'
@ -75,6 +76,8 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
ctx.inject(['skills'], (skillsCtx) => {
let unregisterKbSkill: (() => void) | undefined
let unregisterKbPack: (() => void) | undefined
let packGeneration = 0
const remountSkills = (): void => {
const gen = ++skillGeneration
unregisterSkills?.()
@ -96,19 +99,48 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
unregisterKbSkill = undefined
unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext())
}
const remountKbPack = (): void => {
const gen = ++packGeneration
unregisterKbPack?.()
unregisterKbPack = undefined
const connection = getNetxConnection()
const enabled = connection?.groupKbInPreset !== false
void registerKbPackSkills(skillsCtx, getKbContext(), {
enabled,
providerLabel: `${options.name}-kb-pack`,
}).then((dispose) => {
if (gen !== packGeneration) {
dispose()
return
}
unregisterKbPack = dispose
}).catch((error) => {
skillsCtx.logger.warn('%s: kb pack skill register failed: %s', options.name, error)
})
}
remountSkills()
remountKbSkill()
const stopSkillWatch = watchNetxConnection(() => { remountSkills() })
const stopKbWatch = watchKbContext(() => { remountKbSkill() })
remountKbPack()
const stopSkillWatch = watchNetxConnection(() => {
remountSkills()
remountKbPack()
})
const stopKbWatch = watchKbContext(() => {
remountKbSkill()
remountKbPack()
})
skillsCtx.effect(() => () => {
skillGeneration += 1
packGeneration += 1
stopSkillWatch()
stopKbWatch()
unregisterSkills?.()
unregisterSkills = undefined
unregisterKbSkill?.()
unregisterKbSkill = undefined
unregisterKbPack?.()
unregisterKbPack = undefined
}, `${options.name}: dispose skills`)
})

View file

@ -32,7 +32,11 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string
`| kbVersion | ${snapshot.version} |`,
`| kbContent (on) | ${flags} |`,
'',
'Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`.',
'Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.',
'',
'Business playbooks (kb-troubleshoot, kb-retrieve, …) register from',
`${snapshot.realRoot}/_skills/ when hasSkills is true — use those skills for KB triage;`,
'keep netx-ops for live netx evidence only.',
].join('\n'),
}
}
@ -47,7 +51,7 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string
content: [
'## Knowledge base (unavailable)',
'',
`kbStatus=\`${snapshot.status}\`. ${reason}`,
`kbStatus=${snapshot.status}. ${reason}`,
'',
'**Do not invent an operator, country, or KB paths.**',
'Use only netx tools / live evidence (alarms, inventory, CLI, topology).',

View file

@ -14,12 +14,13 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
/** Known content flags; unknown keys are preserved when boolean. */
/** Known content flags (MANIFEST contract v1.0); unknown boolean keys may be preserved. */
export interface KbContentFlags {
regions: boolean
theory: boolean
packet: boolean
skills: boolean
hasRegions: boolean
hasTheory: boolean
hasPacket: boolean
hasCommon: boolean
hasSkills: boolean
[key: string]: boolean
}
@ -37,10 +38,20 @@ export interface KbSnapshot {
}
const EMPTY_CONTENT: KbContentFlags = {
regions: false,
theory: false,
packet: false,
skills: false,
hasRegions: false,
hasTheory: false,
hasPacket: false,
hasCommon: false,
hasSkills: false,
}
/** Legacy short keys → contract `has*` (one-release compat). */
const LEGACY_CONTENT_KEY: Record<string, keyof KbContentFlags> = {
regions: 'hasRegions',
theory: 'hasTheory',
packet: 'hasPacket',
skills: 'hasSkills',
common: 'hasCommon',
}
/** Empty / unconfigured snapshot (kbRoot blank). */
@ -165,8 +176,17 @@ function parseContent(raw: unknown): KbContentFlags {
if (typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error('MANIFEST content must be an object')
}
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
out[key] = value === true
const row = raw as Record<string, unknown>
const present = new Set(Object.keys(row))
for (const [key, value] of Object.entries(row)) {
const on = value === true
const mapped = LEGACY_CONTENT_KEY[key]
if (mapped !== undefined) {
// Short keys only fill has* when the contract key is absent (no dual truth).
if (on && !present.has(mapped)) out[mapped] = true
continue
}
out[key] = on
}
return out
}

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

@ -0,0 +1,106 @@
/**
* Register operator-subset knowledge pack skills (_skills dir) into DSH when KB is configured.
*/
import { readdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import type { KbSnapshot } from './kb-manifest.ts'
import { loadSkillBundle } from './group-skills.ts'
export type KbSkillPlane = 'preset' | 'public'
/** True when MANIFEST says the package ships skill packs. */
export function kbPackSkillsEnabled(snapshot: KbSnapshot): boolean {
return snapshot.status === 'configured' && snapshot.content.hasSkills === true
}
/**
* Register every valid skill under realRoot/_skills.
* No-op when not configured / hasSkills false / directory missing.
* @returns disposer that unregisters all pack skills.
*/
export async function registerKbPackSkills(
ctx: Context,
snapshot: KbSnapshot,
options: {
enabled: boolean
providerLabel?: string
},
): Promise<() => void> {
if (!options.enabled || !kbPackSkillsEnabled(snapshot)) {
return () => {}
}
const skillsApi = (ctx as { skills?: { register: (skill: {
name: string
description: string
content: string
path?: string
resourceBase?: { kind: 'directory'; path: string }
provider?: string
source: string
}) => () => void } }).skills
if (!skillsApi || typeof skillsApi.register !== 'function') {
return () => {}
}
const root = join(snapshot.realRoot, '_skills')
let entries: string[]
try {
entries = await readdir(root)
} catch {
ctx.logger.info('netxops: kb pack skills dir missing at %s — skip', root)
return () => {}
}
const disposers: Array<() => void> = []
const provider = options.providerLabel ?? 'netxops-kb-pack'
for (const entry of entries) {
const full = join(root, entry)
let isDir = false
try {
isDir = (await stat(full)).isDirectory()
} catch {
continue
}
if (!isDir) continue
let skill
try {
skill = await loadSkillBundle(full)
} catch (error) {
ctx.logger.warn(
'netxops: kb pack skill load failed at %s: %s',
full,
error instanceof Error ? error.message : String(error),
)
continue
}
if (!skill) {
ctx.logger.warn('netxops: kb pack skill skipped (invalid SKILL.md): %s', full)
continue
}
disposers.push(skillsApi.register({
name: skill.name,
description: skill.description,
content: skill.content,
path: skill.path,
resourceBase: { kind: 'directory', path: skill.directory },
provider,
source: 'custom',
}))
}
if (disposers.length > 0) {
ctx.logger.info(
'netxops: kb pack skills registered count=%s root=%s provider=%s',
disposers.length,
root,
provider,
)
}
return () => {
for (const dispose of disposers) dispose()
}
}

View file

@ -22,6 +22,13 @@ export interface NetxToolConnection {
toolCallTimeoutMs: number
/** Capability-group exposure; omitted → package defaults. */
groups?: NetxCapabilityGroups
/**
* Mount operator-subset `_skills/kb-*` into the Netx Ops preset (default true).
* Gated also by KB configured + content.hasSkills.
*/
groupKbInPreset?: boolean
/** Mount kb pack skills on the host public skill layer (default false). */
groupKbPublic?: boolean
}
type Handler = (client: NetxClient, args: NetxJson, signal?: AbortSignal) => Promise<NetxJson>