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:
oliver 2026-09-15 08:23:05 +08:00
parent 6dee69be31
commit 53c47fbc6a
18 changed files with 662 additions and 156 deletions

View file

@ -810,7 +810,8 @@ function skillBody(snapshot) {
"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(`
`)
@ -871,6 +872,7 @@ function unconfiguredKbSnapshot() {
country: "",
version: "",
content: { ...EMPTY_CONTENT },
paths: {},
errorMessage: ""
};
}
@ -882,6 +884,7 @@ function errorSnapshot(message) {
country: "",
version: "",
content: { ...EMPTY_CONTENT },
paths: {},
errorMessage: message
};
}
@ -988,6 +991,23 @@ function parseContent(raw) {
}
return out;
}
function parsePaths(raw) {
if (raw === undefined || raw === null)
return {};
if (typeof raw !== "object" || Array.isArray(raw)) {
throw new Error("MANIFEST paths must be an object");
}
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (typeof value !== "string")
continue;
const trimmed = value.trim();
if (!trimmed)
continue;
out[key] = trimmed;
}
return out;
}
function parseManifest(raw) {
let data;
try {
@ -1014,7 +1034,8 @@ function parseManifest(raw) {
operatorName: asNonEmptyString(op.name, "operator.name"),
country: asNonEmptyString(op.country, "operator.country"),
version: asNonEmptyString(row.version, "version"),
content: parseContent(row.content)
content: parseContent(row.content),
paths: parsePaths(row.paths)
};
}
function resolveKbRoot(kbRoot, maxDepth = 3) {
@ -1061,6 +1082,7 @@ function resolveKbRoot(kbRoot, maxDepth = 3) {
country: parsed.country,
version: parsed.version,
content: parsed.content,
paths: { ...parsed.paths },
errorMessage: ""
};
} catch (error) {
@ -1074,24 +1096,29 @@ import { join as join4 } from "node:path";
function kbPackSkillsEnabled(snapshot) {
return snapshot.status === "configured" && snapshot.content.hasSkills === true;
}
async function registerKbPackSkills(ctx, snapshot, options) {
if (!options.enabled || !kbPackSkillsEnabled(snapshot)) {
return () => {};
}
const skillsApi = ctx.skills;
if (!skillsApi || typeof skillsApi.register !== "function") {
return () => {};
}
const root = join4(snapshot.realRoot, "_skills");
function resolveKbSkillsRoot(snapshot) {
const rel = snapshot.paths.skills?.trim() || "_skills";
return join4(snapshot.realRoot, rel);
}
function resolveKbLocalSkillsRoot(snapshot) {
const rel = snapshot.paths.localSkills?.trim();
if (!rel)
return null;
return join4(snapshot.realRoot, rel);
}
async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) {
let entries;
try {
entries = await readdir2(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 = [];
const provider = options.providerLabel ?? "netxops-kb-pack";
for (const entry of entries) {
const full = join4(root, entry);
let isDir = false;
@ -1106,11 +1133,11 @@ async function registerKbPackSkills(ctx, snapshot, options) {
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));
ctx.logger.warn("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({
@ -1123,8 +1150,32 @@ async function registerKbPackSkills(ctx, snapshot, options) {
source: "custom"
}));
}
return disposers;
}
async function registerKbPackSkills(ctx, snapshot, options) {
if (!options.enabled || !kbPackSkillsEnabled(snapshot)) {
return () => {};
}
const skillsApi = ctx.skills;
if (!skillsApi || typeof skillsApi.register !== "function") {
return () => {};
}
const provider = options.providerLabel ?? "netxops-kb-pack";
const disposers = [];
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", disposers.length, root, provider);
ctx.logger.info("netxops: kb pack skills registered count=%s skillsRoot=%s localSkills=%s provider=%s", disposers.length, skillsRoot, localRoot ?? "(none)", provider);
}
return () => {
for (const dispose of disposers)
@ -1152,13 +1203,19 @@ function store2() {
return current;
}
function getKbContext() {
return { ...store2().snapshot, content: { ...store2().snapshot.content } };
const snap = store2().snapshot;
return {
...snap,
content: { ...snap.content },
paths: { ...snap.paths }
};
}
function publishKbContext(next) {
const state = store2();
state.snapshot = {
...next,
content: { ...next.content }
content: { ...next.content },
paths: { ...next.paths }
};
for (const listener of state.listeners)
listener();