Add operator-subset knowledge base wiring for Netx Ops P1.

Locate and validate MANIFEST under kbRoot, show anti-mixup status in settings, and inject KB_* env plus kb-context so sessions degrade to pure netx when unset.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-13 10:39:28 +08:00
parent e6f9d062ed
commit de7c6a0c4b
23 changed files with 2694 additions and 68 deletions

View file

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

View file

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

261
src/netx/kb-manifest.ts Normal file
View file

@ -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<string, unknown>)) {
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<string, unknown>
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<string, unknown>
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))
}
}

90
src/netx/kb-runtime.ts Normal file
View file

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