mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-09 04:00:46 +08:00
Register MANIFEST paths.localSkills alongside pack _skills.
Parse packaging-generated paths and load each local_skills/*/SKILL.md with the same hasSkills toggles and provider as shared packs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6dee69be31
commit
53c47fbc6a
18 changed files with 662 additions and 156 deletions
|
|
@ -31,6 +31,13 @@ export function asKbSnapshot(value: unknown): KbSnapshot {
|
|||
content[key] = flag === true
|
||||
}
|
||||
}
|
||||
const pathsRaw = row.paths
|
||||
const paths: Record<string, string> = {}
|
||||
if (pathsRaw !== null && typeof pathsRaw === 'object' && !Array.isArray(pathsRaw)) {
|
||||
for (const [key, value] of Object.entries(pathsRaw as Record<string, unknown>)) {
|
||||
if (typeof value === 'string' && value.trim()) paths[key] = value.trim()
|
||||
}
|
||||
}
|
||||
return {
|
||||
status,
|
||||
realRoot: typeof row.realRoot === 'string' ? row.realRoot : '',
|
||||
|
|
@ -38,6 +45,7 @@ export function asKbSnapshot(value: unknown): KbSnapshot {
|
|||
country: typeof row.country === 'string' ? row.country : '',
|
||||
version: typeof row.version === 'string' ? row.version : '',
|
||||
content,
|
||||
paths,
|
||||
errorMessage: typeof row.errorMessage === 'string' ? row.errorMessage : '',
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
|||
groupInPreset: 'In Netx Ops preset',
|
||||
groupPublic: 'Publish to other presets',
|
||||
kbRoot: 'Package root',
|
||||
kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Browse auto-saves. Pack skills load from _skills/ when hasSkills.',
|
||||
kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Browse auto-saves. Pack skills load from paths.skills/_skills and paths.localSkills when hasSkills.',
|
||||
kbBrowse: 'Browse…',
|
||||
kbBrowseUnavailable: 'Directory picker unavailable — paste an absolute folder path, then Save.',
|
||||
kbBrowseFailed: 'Browse failed: {detail}',
|
||||
|
|
@ -168,7 +168,7 @@ export const zh: Record<NetxopsLocaleKey, string> = {
|
|||
groupInPreset: '在 Netx Ops 预设中启用',
|
||||
groupPublic: '对其他预设公开',
|
||||
kbRoot: '知识包根目录',
|
||||
kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存;hasSkills 时加载 _skills/。',
|
||||
kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存;hasSkills 时加载 paths.skills/_skills 与 paths.localSkills。',
|
||||
kbBrowse: '浏览…',
|
||||
kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对目录路径后点保存。',
|
||||
kbBrowseFailed: '浏览失败:{detail}',
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string
|
|||
'Environment mirrors: KB_ROOT, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.',
|
||||
'',
|
||||
'Business playbooks (kb-troubleshoot, kb-retrieve, …) register from',
|
||||
`${snapshot.realRoot}/_skills/ when hasSkills is true — use those skills for KB triage;`,
|
||||
`${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`,
|
||||
'plus paths.localSkills when present — use those skills for KB triage;',
|
||||
'keep netx-ops for live netx evidence only.',
|
||||
].join('\n'),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
* - packageType === "operator-subset"
|
||||
* - operator.name / operator.country, version required
|
||||
* - content.* boolean flags (missing → false)
|
||||
* - paths.* optional relative dirs (packaging-generated; missing keys skipped)
|
||||
*
|
||||
* Location: `${kbRoot}/MANIFEST.json`, else recurse ≤ maxDepth and accept
|
||||
* exactly one hit (0 or >1 → error).
|
||||
|
|
@ -24,6 +25,18 @@ export interface KbContentFlags {
|
|||
[key: string]: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Packaging-generated relative paths under the package root.
|
||||
* Plugin uses `skills` / `localSkills` for skill registration; other keys are preserved.
|
||||
*/
|
||||
export interface KbPaths {
|
||||
/** Shared skill packs (default `_skills` when absent). */
|
||||
skills?: string
|
||||
/** Operator-local skills (optional; missing/empty → skip). */
|
||||
localSkills?: string
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
|
||||
export type KbStatus = 'unconfigured' | 'configured' | 'error'
|
||||
|
||||
export interface KbSnapshot {
|
||||
|
|
@ -34,6 +47,8 @@ export interface KbSnapshot {
|
|||
country: string
|
||||
version: string
|
||||
content: KbContentFlags
|
||||
/** Relative paths from MANIFEST `paths` (empty when absent). */
|
||||
paths: KbPaths
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +78,7 @@ export function unconfiguredKbSnapshot(): KbSnapshot {
|
|||
country: '',
|
||||
version: '',
|
||||
content: { ...EMPTY_CONTENT },
|
||||
paths: {},
|
||||
errorMessage: '',
|
||||
}
|
||||
}
|
||||
|
|
@ -75,6 +91,7 @@ function errorSnapshot(message: string): KbSnapshot {
|
|||
country: '',
|
||||
version: '',
|
||||
content: { ...EMPTY_CONTENT },
|
||||
paths: {},
|
||||
errorMessage: message,
|
||||
}
|
||||
}
|
||||
|
|
@ -191,6 +208,25 @@ function parseContent(raw: unknown): KbContentFlags {
|
|||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse optional MANIFEST `paths` map (relative dirs under package root).
|
||||
* Missing / null → {}; non-string or blank values skipped; invalid type → error.
|
||||
*/
|
||||
export function parsePaths(raw: unknown): KbPaths {
|
||||
if (raw === undefined || raw === null) return {}
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new Error('MANIFEST paths must be an object')
|
||||
}
|
||||
const out: KbPaths = {}
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (typeof value !== 'string') continue
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) continue
|
||||
out[key] = trimmed
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate MANIFEST JSON text (UTF-8).
|
||||
* @throws Error with a short message when invalid.
|
||||
|
|
@ -200,6 +236,7 @@ export function parseManifest(raw: string): {
|
|||
country: string
|
||||
version: string
|
||||
content: KbContentFlags
|
||||
paths: KbPaths
|
||||
} {
|
||||
let data: unknown
|
||||
try {
|
||||
|
|
@ -233,6 +270,7 @@ export function parseManifest(raw: string): {
|
|||
country: asNonEmptyString(op.country, 'operator.country'),
|
||||
version: asNonEmptyString(row.version, 'version'),
|
||||
content: parseContent(row.content),
|
||||
paths: parsePaths(row.paths),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -290,6 +328,7 @@ export function resolveKbRoot(kbRoot: string, maxDepth = 3): KbSnapshot {
|
|||
country: parsed.country,
|
||||
version: parsed.version,
|
||||
content: parsed.content,
|
||||
paths: { ...parsed.paths },
|
||||
errorMessage: '',
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
/**
|
||||
* Register operator-subset knowledge pack skills (_skills dir) into DSH when KB is configured.
|
||||
* Register operator-subset knowledge pack skills into DSH when KB is configured.
|
||||
*
|
||||
* Sources (same plane / toggles / loadSkillBundle rules):
|
||||
* - `paths.skills` or default `_skills/` when `content.hasSkills`
|
||||
* - `paths.localSkills` when present (missing field or empty/missing dir → skip)
|
||||
*/
|
||||
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
|
|
@ -10,52 +14,55 @@ import { loadSkillBundle } from './group-skills.ts'
|
|||
|
||||
export type KbSkillPlane = 'preset' | 'public'
|
||||
|
||||
type SkillsRegister = (skill: {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
path?: string
|
||||
resourceBase?: { kind: 'directory'; path: string }
|
||||
provider?: string
|
||||
source: string
|
||||
}) => () => void
|
||||
|
||||
/** True when MANIFEST says the package ships skill packs. */
|
||||
export function kbPackSkillsEnabled(snapshot: KbSnapshot): boolean {
|
||||
return snapshot.status === 'configured' && snapshot.content.hasSkills === true
|
||||
}
|
||||
|
||||
/** Shared pack root: MANIFEST `paths.skills` or legacy `_skills`. */
|
||||
export function resolveKbSkillsRoot(snapshot: KbSnapshot): string {
|
||||
const rel = snapshot.paths.skills?.trim() || '_skills'
|
||||
return join(snapshot.realRoot, rel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register every valid skill under realRoot/_skills.
|
||||
* No-op when not configured / hasSkills false / directory missing.
|
||||
* @returns disposer that unregisters all pack skills.
|
||||
* Absolute local-skills root from MANIFEST `paths.localSkills`, or null when absent.
|
||||
*/
|
||||
export async function registerKbPackSkills(
|
||||
export function resolveKbLocalSkillsRoot(snapshot: KbSnapshot): string | null {
|
||||
const rel = snapshot.paths.localSkills?.trim()
|
||||
if (!rel) return null
|
||||
return join(snapshot.realRoot, rel)
|
||||
}
|
||||
|
||||
async function registerSkillsUnderRoot(
|
||||
ctx: Context,
|
||||
snapshot: KbSnapshot,
|
||||
options: {
|
||||
enabled: boolean
|
||||
providerLabel?: string
|
||||
},
|
||||
): Promise<() => void> {
|
||||
if (!options.enabled || !kbPackSkillsEnabled(snapshot)) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const skillsApi = (ctx as { skills?: { register: (skill: {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
path?: string
|
||||
resourceBase?: { kind: 'directory'; path: string }
|
||||
provider?: string
|
||||
source: string
|
||||
}) => () => void } }).skills
|
||||
if (!skillsApi || typeof skillsApi.register !== 'function') {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const root = join(snapshot.realRoot, '_skills')
|
||||
skillsApi: { register: SkillsRegister },
|
||||
root: string,
|
||||
provider: string,
|
||||
options: { logMissing: boolean; label: string },
|
||||
): Promise<Array<() => void>> {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await readdir(root)
|
||||
} catch {
|
||||
ctx.logger.info('netxops: kb pack skills dir missing at %s — skip', root)
|
||||
return () => {}
|
||||
if (options.logMissing) {
|
||||
ctx.logger.info('netxops: kb %s dir missing at %s — skip', options.label, root)
|
||||
}
|
||||
return []
|
||||
}
|
||||
if (entries.length === 0) return []
|
||||
|
||||
const disposers: Array<() => void> = []
|
||||
const provider = options.providerLabel ?? 'netxops-kb-pack'
|
||||
for (const entry of entries) {
|
||||
const full = join(root, entry)
|
||||
let isDir = false
|
||||
|
|
@ -70,14 +77,15 @@ export async function registerKbPackSkills(
|
|||
skill = await loadSkillBundle(full)
|
||||
} catch (error) {
|
||||
ctx.logger.warn(
|
||||
'netxops: kb pack skill load failed at %s: %s',
|
||||
'netxops: kb %s skill load failed at %s: %s',
|
||||
options.label,
|
||||
full,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (!skill) {
|
||||
ctx.logger.warn('netxops: kb pack skill skipped (invalid SKILL.md): %s', full)
|
||||
ctx.logger.warn('netxops: kb %s skill skipped (invalid SKILL.md): %s', options.label, full)
|
||||
continue
|
||||
}
|
||||
disposers.push(skillsApi.register({
|
||||
|
|
@ -90,12 +98,55 @@ export async function registerKbPackSkills(
|
|||
source: 'custom',
|
||||
}))
|
||||
}
|
||||
return disposers
|
||||
}
|
||||
|
||||
/**
|
||||
* Register every valid skill under paths.skills (or `_skills`) and paths.localSkills.
|
||||
* No-op when not configured / hasSkills false.
|
||||
* localSkills: field missing or directory empty/missing → skip (no error).
|
||||
* @returns disposer that unregisters all pack skills.
|
||||
*/
|
||||
export async function registerKbPackSkills(
|
||||
ctx: Context,
|
||||
snapshot: KbSnapshot,
|
||||
options: {
|
||||
enabled: boolean
|
||||
providerLabel?: string
|
||||
},
|
||||
): Promise<() => void> {
|
||||
if (!options.enabled || !kbPackSkillsEnabled(snapshot)) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const skillsApi = (ctx as { skills?: { register: SkillsRegister } }).skills
|
||||
if (!skillsApi || typeof skillsApi.register !== 'function') {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const provider = options.providerLabel ?? 'netxops-kb-pack'
|
||||
const disposers: Array<() => void> = []
|
||||
|
||||
const skillsRoot = resolveKbSkillsRoot(snapshot)
|
||||
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, {
|
||||
logMissing: true,
|
||||
label: 'pack',
|
||||
}))
|
||||
|
||||
const localRoot = resolveKbLocalSkillsRoot(snapshot)
|
||||
if (localRoot) {
|
||||
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, {
|
||||
logMissing: false,
|
||||
label: 'local',
|
||||
}))
|
||||
}
|
||||
|
||||
if (disposers.length > 0) {
|
||||
ctx.logger.info(
|
||||
'netxops: kb pack skills registered count=%s root=%s provider=%s',
|
||||
'netxops: kb pack skills registered count=%s skillsRoot=%s localSkills=%s provider=%s',
|
||||
disposers.length,
|
||||
root,
|
||||
skillsRoot,
|
||||
localRoot ?? '(none)',
|
||||
provider,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,12 @@ function store(): Store {
|
|||
|
||||
/** @returns the last published KB snapshot. */
|
||||
export function getKbContext(): KbSnapshot {
|
||||
return { ...store().snapshot, content: { ...store().snapshot.content } }
|
||||
const snap = store().snapshot
|
||||
return {
|
||||
...snap,
|
||||
content: { ...snap.content },
|
||||
paths: { ...snap.paths },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -51,6 +56,7 @@ export function publishKbContext(next: KbSnapshot): void {
|
|||
state.snapshot = {
|
||||
...next,
|
||||
content: { ...next.content },
|
||||
paths: { ...next.paths },
|
||||
}
|
||||
for (const listener of state.listeners) listener()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue