mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-09 18:20:46 +08:00
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:
parent
e6f9d062ed
commit
de7c6a0c4b
23 changed files with 2694 additions and 68 deletions
|
|
@ -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`)
|
||||
})
|
||||
|
||||
|
|
|
|||
85
src/netx/kb-context-skill.ts
Normal file
85
src/netx/kb-context-skill.ts
Normal 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
261
src/netx/kb-manifest.ts
Normal 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
90
src/netx/kb-runtime.ts
Normal 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())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue