Fix KB loading bugs and add jailed KB read/search tools (0.1.71).
Some checks failed
Release package / package (macos-latest) (push) Has been cancelled
Release package / package (ubuntu-latest) (push) Has been cancelled
Release package / package (windows-latest) (push) Has been cancelled

- Strip UTF-8 BOM before parsing MANIFEST.json (PowerShell 5.1 output).
- kb.reload force-republishes so edited SKILL.md / new local_skills / identity apply.
- Duplicate or rejected pack skills are skipped instead of unregistering the whole pack.
- kb-context and every pack skill carry resolved absolute paths.* anchors; KB_PATHS env.
- Inline {paths.local}/identity/*.md into kb-context (capped).
- New netx__kbSearch / netx__kbRead host tools, realpath-jailed to kbRoot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-12 21:19:23 +08:00
parent 9b2166e5b6
commit 2def53e7c7
22 changed files with 4286 additions and 1013 deletions

View file

@ -1,6 +1,6 @@
// src/netx/group-tools-plugin.ts
import { readFileSync as readFileSync3 } from "node:fs";
import { dirname as dirname5, join as join7 } from "node:path";
import { readFileSync as readFileSync6 } from "node:fs";
import { dirname as dirname6, join as join10 } from "node:path";
import { fileURLToPath as fileURLToPath3 } from "node:url";
// src/netx/config-values.ts
@ -324,9 +324,56 @@ Use only tools present in this session. Native layout supports catalog and move_
};
}
// src/netx/kb-context-skill.ts
import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync3 } from "node:fs";
import { join as join4 } from "node:path";
// src/netx/kb-manifest.ts
import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "node:fs";
import { basename, dirname as dirname2, join as join2, resolve } from "node:path";
var EMPTY_CONTENT = {
hasRegions: false,
hasTheory: false,
hasPacket: false,
hasCommon: false,
hasSkills: false
};
function unconfiguredKbSnapshot() {
return {
status: "unconfigured",
realRoot: "",
operatorName: "",
country: "",
version: "",
content: { ...EMPTY_CONTENT },
paths: {},
errorMessage: ""
};
}
function resolveKbPaths(snapshot) {
if (snapshot.status !== "configured")
return [];
const paths = { skills: "_skills", ...snapshot.paths };
return Object.entries(paths).filter((entry) => typeof entry[1] === "string" && entry[1].trim() !== "").map(([key, rel]) => {
const absolute = resolve(snapshot.realRoot, rel);
return { key, relative: rel, absolute, exists: existsSync2(absolute) };
});
}
function renderKbPathTable(snapshot) {
const rows = resolveKbPaths(snapshot);
if (rows.length === 0)
return "(MANIFEST has no paths — legacy package; ask HQ for a repack)";
return [
"| paths.* | Absolute path |",
"| --- | --- |",
...rows.map((row) => `| ${row.key} | \`${row.absolute}\`${row.exists ? "" : " (missing)"} |`)
].join(`
`);
}
// src/netx/kb-local-path.ts
import { existsSync as existsSync2, realpathSync, statSync } from "node:fs";
import { join as join2, relative, resolve, sep } from "node:path";
import { existsSync as existsSync3, realpathSync, statSync as statSync2 } from "node:fs";
import { join as join3, relative, resolve as resolve2, sep } from "node:path";
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
"memories",
"drafts",
@ -352,10 +399,7 @@ function resolveKbLocalRoot(snapshot) {
const rel = snapshot.paths.local?.trim();
if (!rel)
return null;
return resolve(snapshot.realRoot, rel);
}
function kbLocalToolsEnabled(snapshot) {
return resolveKbLocalRoot(snapshot) !== null;
return resolve2(snapshot.realRoot, rel);
}
function sanitizeSlug(raw, fallback = "entry") {
const cleaned = raw.trim().replace(/[/\\]+/g, "-").replace(/\.\.+/g, "").replace(/[^\w.\u4e00-\u9fff-]+/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
@ -407,7 +451,7 @@ function resolveWritableLocalPath(localRoot, ...segments) {
if (segments.some((s) => s.includes("\x00"))) {
throw new Error("path segment contains NUL");
}
const absolutePath = resolve(localRoot, ...segments);
const absolutePath = resolve2(localRoot, ...segments);
const rel = relative(localRoot, absolutePath);
if (!rel || rel.startsWith("..") || rel.split(/[/\\]/).includes("..")) {
throw new Error(`path escapes local root: ${absolutePath}`);
@ -426,18 +470,18 @@ function resolveExistingWritableFile(localRoot, pathArg) {
const trimmed = pathArg.trim();
if (!trimmed)
throw new Error("path is empty");
const candidate = resolve(trimmed);
const candidate = resolve2(trimmed);
let absolutePath;
const localResolved = resolve(localRoot);
const localResolved = resolve2(localRoot);
const underLocal = candidate === localResolved || candidate.startsWith(localResolved + sep);
if (underLocal) {
absolutePath = candidate;
} else {
absolutePath = resolve(localRoot, trimmed);
absolutePath = resolve2(localRoot, trimmed);
}
const checked = resolveWritableLocalPath(localRoot, ...relative(localRoot, absolutePath).split(/[/\\]/).filter(Boolean));
try {
if (existsSync2(checked.absolutePath)) {
if (existsSync3(checked.absolutePath)) {
const realFile = realpathSync(checked.absolutePath);
const realLocal = realpathSync(localRoot);
const realRel = relative(realLocal, realFile);
@ -448,7 +492,7 @@ function resolveExistingWritableFile(localRoot, pathArg) {
if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) {
throw new Error(`path not under writable roots: ${realRel}`);
}
if (!statSync(realFile).isFile()) {
if (!statSync2(realFile).isFile()) {
throw new Error(`not a file: ${realFile}`);
}
return {
@ -465,16 +509,16 @@ function resolveExistingWritableFile(localRoot, pathArg) {
return checked;
}
function memoryDir(bucket) {
return join2("memories", MEMORY_BUCKET_DIR[bucket]);
return join3("memories", MEMORY_BUCKET_DIR[bucket]);
}
function suggestionDir(kind) {
return join2("suggestions", kind);
return join3("suggestions", kind);
}
function draftDir(domain) {
const d = domain?.trim();
if (!d)
return "drafts";
return join2("drafts", sanitizeSlug(d, "misc"));
return join3("drafts", sanitizeSlug(d, "misc"));
}
function refDir(area, device) {
if (!REF_AREAS.includes(area)) {
@ -485,18 +529,63 @@ function refDir(area, device) {
if (!host) {
throw new Error("refs/devices requires device (host_name)");
}
return join2("refs", "devices", host);
return join3("refs", "devices", host);
}
return join2("refs", area);
return join3("refs", area);
}
// src/netx/kb-context-skill.ts
var SKILL_NAME = "kb-context";
var IDENTITY_FILE_MAX = 8000;
var IDENTITY_TOTAL_MAX = 24000;
function readKbIdentity(snapshot) {
const localRoot = resolveKbLocalRoot(snapshot);
if (!localRoot)
return [];
const dir = join4(localRoot, "identity");
let names;
try {
names = readdirSync2(dir).filter((name) => /\.md$/i.test(name) && !/^readme\.md$/i.test(name)).sort();
} catch {
return [];
}
const out = [];
let budget = IDENTITY_TOTAL_MAX;
for (const name of names) {
if (budget <= 0)
break;
const full = join4(dir, name);
let text;
try {
if (!statSync3(full).isFile())
continue;
text = readFileSync2(full, "utf8").replace(/^\uFEFF/, "").trim();
} catch {
continue;
}
if (!text)
continue;
const cap = Math.min(IDENTITY_FILE_MAX, budget);
if (text.length > cap)
text = `${text.slice(0, cap)}
…(truncated — read \`${full}\` for the rest)`;
budget -= text.length;
out.push({ name, text });
}
return out;
}
function skillBody(snapshot) {
if (snapshot.status === "configured") {
const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)";
const localRoot = resolveKbLocalRoot(snapshot);
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
const identity = readKbIdentity(snapshot);
const identityBlock = identity.length > 0 ? [
"",
"### Site identity (`identity/`, authoritative — follow over generic advice)",
...identity.flatMap(({ name, text }) => ["", `#### ${name}`, "", text])
] : [];
const localGuide = localRoot ? [
"",
"### Dual plane (mandatory)",
@ -521,7 +610,7 @@ function skillBody(snapshot) {
"No local write tools until `paths.local` is present in MANIFEST."
];
return {
description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).",
description: `Load FIRST before any kb-* skill or KB file access: resolved absolute paths (rca / theory / packet / neIndex / cmdLib / local …) for operator ${snapshot.operatorName} (${snapshot.country}) KB v${snapshot.version}, site identity rules, and KB write rules.`,
content: [
"## Knowledge base (configured)",
"",
@ -537,13 +626,22 @@ function skillBody(snapshot) {
`| kbCountry | ${snapshot.country} |`,
`| kbVersion | ${snapshot.version} |`,
`| kbContent (on) | ${flags} |`,
"",
"### Path anchors (resolved from MANIFEST `paths.*` — use verbatim, never compose `regions/<country>/<operator>/…`)",
"",
renderKbPathTable(snapshot),
...identityBlock,
...localGuide,
"",
"Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.",
"### Reading the HQ pack",
"- `netx__kbSearch` — keyword/regex search across rca / theory / packet / common / local (returns file + line hits).",
"- `netx__kbRead` — read one KB file (path relative to kbRoot or absolute inside it; optional line window).",
"- Start from the indexes: `neIndex` (NE → cases), `patternMap`, `classification`, then open the cited RCA.",
"",
"Business playbooks (kb-troubleshoot, kb-retrieve, …) register from",
`${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`,
"plus paths.localSkills when present — use those skills for KB triage;",
"Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_PATHS (JSON of the anchors above), KB_STATUS.",
"",
"Business playbooks (kb-troubleshoot, kb-retrieve, …) register from 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(`
`)
@ -584,15 +682,15 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
// src/netx/kb-local-ops.ts
import {
existsSync as existsSync3,
existsSync as existsSync4,
mkdirSync,
readdirSync,
readFileSync,
readdirSync as readdirSync3,
readFileSync as readFileSync3,
rmSync,
statSync as statSync2,
statSync as statSync4,
writeFileSync
} from "node:fs";
import { dirname as dirname2, join as join3 } from "node:path";
import { dirname as dirname3, join as join5 } from "node:path";
function requireLocalRoot(snapshot) {
const root = resolveKbLocalRoot(snapshot);
if (!root) {
@ -638,11 +736,11 @@ function createMemory(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const name = memoryFileName({ date: args.date, slug: args.slug });
const target = resolveWritableLocalPath(localRoot, memoryDir(args.bucket), name);
if (existsSync3(target.absolutePath) && !args.overwrite) {
if (existsSync4(target.absolutePath) && !args.overwrite) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
}
mkdirSync(dirname2(target.absolutePath), { recursive: true });
const existed = existsSync3(target.absolutePath);
mkdirSync(dirname3(target.absolutePath), { recursive: true });
const existed = existsSync4(target.absolutePath);
writeFileSync(target.absolutePath, args.body.endsWith(`
`) ? args.body : `${args.body}
`, "utf8");
@ -657,11 +755,11 @@ function createDraft(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const name = draftFileName({ date: args.date, slug: args.slug });
const target = resolveWritableLocalPath(localRoot, draftDir(args.domain), name);
if (existsSync3(target.absolutePath) && !args.overwrite) {
if (existsSync4(target.absolutePath) && !args.overwrite) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
}
mkdirSync(dirname2(target.absolutePath), { recursive: true });
const existed = existsSync3(target.absolutePath);
mkdirSync(dirname3(target.absolutePath), { recursive: true });
const existed = existsSync4(target.absolutePath);
writeFileSync(target.absolutePath, ensureDraftBody(args.body), "utf8");
return {
ok: true,
@ -674,11 +772,11 @@ function createSuggestion(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const name = suggestionFileName({ date: args.date, slug: args.slug });
const target = resolveWritableLocalPath(localRoot, suggestionDir(args.kind), name);
if (existsSync3(target.absolutePath) && !args.overwrite) {
if (existsSync4(target.absolutePath) && !args.overwrite) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
}
mkdirSync(dirname2(target.absolutePath), { recursive: true });
const existed = existsSync3(target.absolutePath);
mkdirSync(dirname3(target.absolutePath), { recursive: true });
const existed = existsSync4(target.absolutePath);
writeFileSync(target.absolutePath, args.body.endsWith(`
`) ? args.body : `${args.body}
`, "utf8");
@ -693,11 +791,11 @@ function createRef(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const name = refFileName(args.slug);
const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name);
if (existsSync3(target.absolutePath) && !args.overwrite) {
if (existsSync4(target.absolutePath) && !args.overwrite) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
}
mkdirSync(dirname2(target.absolutePath), { recursive: true });
const existed = existsSync3(target.absolutePath);
mkdirSync(dirname3(target.absolutePath), { recursive: true });
const existed = existsSync4(target.absolutePath);
writeFileSync(target.absolutePath, args.body.endsWith(`
`) ? args.body : `${args.body}
`, "utf8");
@ -711,7 +809,7 @@ function createRef(snapshot, args) {
function updateLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path);
if (!existsSync3(target.absolutePath)) {
if (!existsSync4(target.absolutePath)) {
throw new Error(`file not found: ${target.relativePath}`);
}
const body = target.writableRoot === "drafts" ? ensureDraftBody(args.body) : args.body.endsWith(`
@ -728,7 +826,7 @@ function updateLocalFile(snapshot, args) {
function deleteLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path);
if (!existsSync3(target.absolutePath)) {
if (!existsSync4(target.absolutePath)) {
throw new Error(`file not found: ${target.relativePath}`);
}
rmSync(target.absolutePath, { force: false });
@ -742,17 +840,17 @@ function deleteLocalFile(snapshot, args) {
function walkFiles(dir, baseLocal, out) {
let entries;
try {
entries = readdirSync(dir);
entries = readdirSync3(dir);
} catch {
return;
}
for (const name of entries) {
if (name === "." || name === "..")
continue;
const full = join3(dir, name);
const full = join5(dir, name);
let st;
try {
st = statSync2(full);
st = statSync4(full);
} catch {
continue;
}
@ -774,7 +872,7 @@ function listLocalFiles(snapshot, args = {}) {
const roots = args.root && args.root !== "all" ? [args.root] : [...KB_LOCAL_WRITABLE_ROOTS];
const entries = [];
for (const r of roots) {
walkFiles(join3(localRoot, r), localRoot, entries);
walkFiles(join5(localRoot, r), localRoot, entries);
}
entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
const limit = Math.min(Math.max(args.limit ?? 50, 1), 200);
@ -785,26 +883,240 @@ function listLocalFiles(snapshot, args = {}) {
};
}
// src/netx/kb-manifest.ts
var EMPTY_CONTENT = {
hasRegions: false,
hasTheory: false,
hasPacket: false,
hasCommon: false,
hasSkills: false
};
function unconfiguredKbSnapshot() {
// src/netx/kb-read-ops.ts
import { readdirSync as readdirSync4, readFileSync as readFileSync4, realpathSync as realpathSync2, statSync as statSync5 } from "node:fs";
import { extname, join as join6, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path";
var TEXT_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".json", ".yml", ".yaml", ".csv", ".ps1", ".sh", ".cfg", ".conf", ".log"]);
var SKIP_DIRS = new Set([".git", "node_modules"]);
var READ_DEFAULT_LINES = 400;
var READ_MAX_LINES = 2000;
var READ_MAX_CHARS = 80000;
var SEARCH_MAX_FILE_BYTES = 2 * 1024 * 1024;
var SEARCH_HITS_PER_FILE = 4;
var CACHE_MAX_CHARS = 64 * 1024 * 1024;
function requireRoot(snapshot) {
if (snapshot.status !== "configured" || !snapshot.realRoot) {
throw new Error("knowledge base not configured (set kbRoot to a folder with MANIFEST.json)");
}
return snapshot.realRoot;
}
function toPosix(rel) {
return rel.split(sep2).join("/");
}
function isInside(root, target) {
const rel = relative2(root, target);
return rel === "" || !rel.startsWith("..") && !rel.split(/[/\\]/).includes("..") && !/^[a-zA-Z]:/.test(rel);
}
function resolveKbReadPath(snapshot, pathArg) {
const root = requireRoot(snapshot);
const trimmed = pathArg.trim().replace(/^["']|["']$/g, "");
if (trimmed.includes("\x00"))
throw new Error("path contains NUL");
const anchor = resolveKbPaths(snapshot).find((row) => row.key === trimmed);
const candidate = anchor ? anchor.absolute : !trimmed || trimmed === "." || trimmed === "/" ? root : resolve3(root, trimmed);
if (!isInside(root, candidate))
throw new Error(`path escapes kbRoot: ${candidate}`);
let real;
try {
real = realpathSync2(candidate);
} catch {
throw new Error(`not found in KB: ${toPosix(relative2(root, candidate)) || "."}`);
}
const realRoot = realpathSync2(root);
if (!isInside(realRoot, real))
throw new Error(`path escapes kbRoot after realpath: ${real}`);
return { absolutePath: real, relativePath: toPosix(relative2(realRoot, real)) || "." };
}
function readKbFile(snapshot, args) {
const target = resolveKbReadPath(snapshot, args.path);
const st = statSync5(target.absolutePath);
if (st.isDirectory()) {
const entries = readdirSync4(target.absolutePath, { withFileTypes: true }).filter((entry) => !SKIP_DIRS.has(entry.name)).map((entry) => {
if (entry.isDirectory())
return { name: entry.name, type: "dir" };
let size;
try {
size = statSync5(join6(target.absolutePath, entry.name)).size;
} catch {}
return { name: entry.name, type: "file", size };
}).sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1);
return { ok: true, kind: "directory", ...target, entries };
}
if (!TEXT_EXTENSIONS.has(extname(target.absolutePath).toLowerCase())) {
throw new Error(`not a text KB file: ${target.relativePath}`);
}
const lines = readFileSync4(target.absolutePath, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, `
`).split(`
`);
const start = Math.max(1, Math.floor(args.offset ?? 1));
const limit = Math.min(Math.max(1, Math.floor(args.limit ?? READ_DEFAULT_LINES)), READ_MAX_LINES);
let slice = lines.slice(start - 1, start - 1 + limit);
let text = slice.join(`
`);
let truncated = start - 1 + slice.length < lines.length;
if (text.length > READ_MAX_CHARS) {
let used = 0;
let keep = 0;
for (const line of slice) {
if (used + line.length + 1 > READ_MAX_CHARS)
break;
used += line.length + 1;
keep += 1;
}
slice = slice.slice(0, Math.max(keep, 1));
text = slice.join(`
`).slice(0, READ_MAX_CHARS);
truncated = true;
}
return {
status: "unconfigured",
realRoot: "",
operatorName: "",
country: "",
version: "",
content: { ...EMPTY_CONTENT },
paths: {},
errorMessage: ""
ok: true,
kind: "file",
...target,
totalLines: lines.length,
startLine: start,
endLine: start - 1 + slice.length,
truncated,
text
};
}
var CACHE_KEY = Symbol.for("dsh-netxops.kb-search-cache");
function searchCache() {
const root = globalThis;
root[CACHE_KEY] ??= { files: new Map, chars: 0 };
return root[CACHE_KEY];
}
function cachedText(path, mtimeMs, size) {
const cache = searchCache();
const hit = cache.files.get(path);
if (hit && hit.mtimeMs === mtimeMs && hit.size === size)
return hit;
const text = readFileSync4(path, "utf8").replace(/^\uFEFF/, "");
if (hit)
cache.chars -= hit.text.length;
if (cache.chars + text.length > CACHE_MAX_CHARS) {
cache.files.clear();
cache.chars = 0;
}
const entry = { mtimeMs, size, text, lower: text.toLowerCase() };
cache.files.set(path, entry);
cache.chars += text.length;
return entry;
}
function walkTextFiles(dir, out) {
let entries;
try {
entries = readdirSync4(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name))
continue;
const full = join6(dir, entry.name);
if (entry.isDirectory()) {
walkTextFiles(full, out);
continue;
}
if (!entry.isFile() || !TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase()))
continue;
try {
const st = statSync5(full);
if (st.size <= SEARCH_MAX_FILE_BYTES)
out.push({ path: full, mtimeMs: st.mtimeMs, size: st.size });
} catch {}
}
}
function searchKb(snapshot, args) {
const root = requireRoot(snapshot);
const query = args.query.trim();
if (!query)
throw new Error("query is empty");
const scopeArg = args.scope?.trim() || "all";
const scopes = scopeArg.split(/[,\s]+/).filter(Boolean);
const dirs = scopes.includes("all") ? [root] : scopes.map((scope) => resolveKbReadPath(snapshot, scope).absolutePath);
let matchers;
if (args.regex) {
let re;
try {
re = new RegExp(query, "i");
} catch (error) {
throw new Error(`invalid regex: ${error instanceof Error ? error.message : String(error)}`);
}
matchers = [{ term: query, test: (_lower, raw) => re.test(raw) }];
} else {
const terms = [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))];
matchers = terms.map((term) => ({ term, test: (lower) => lower.includes(term) }));
}
const files = [];
const seen = new Set;
for (const dir of dirs) {
const st = statSync5(dir);
const batch = [];
if (st.isFile())
batch.push({ path: dir, mtimeMs: st.mtimeMs, size: st.size });
else
walkTextFiles(dir, batch);
for (const file of batch) {
if (seen.has(file.path))
continue;
seen.add(file.path);
files.push(file);
}
}
const hits = [];
for (const file of files) {
let cached;
try {
cached = cachedText(file.path, file.mtimeMs, file.size);
} catch {
continue;
}
const rel = toPosix(relative2(root, file.path));
const matched = new Set;
let score = 0;
for (const m of matchers) {
if (m.test(rel.toLowerCase(), rel)) {
matched.add(m.term);
score += 3;
}
}
const inBody = matchers.filter((m) => m.test(cached.lower, cached.text));
if (inBody.length === 0 && matched.size === 0)
continue;
const lines = [];
const rows = inBody.length > 0 ? cached.text.split(/\r\n?|\n/) : [];
let lineHits = 0;
for (let i = 0;i < rows.length; i += 1) {
const row = rows[i];
const lower = row.toLowerCase();
let lineHit = false;
for (const m of inBody) {
if (m.test(lower, row)) {
matched.add(m.term);
lineHit = true;
}
}
if (!lineHit)
continue;
lineHits += 1;
if (lines.length < SEARCH_HITS_PER_FILE) {
const trimmed = row.trim();
lines.push({ line: i + 1, text: trimmed.length > 240 ? `${trimmed.slice(0, 240)}…` : trimmed });
}
}
score += matched.size * 10 + Math.min(lineHits, 30) * 0.1;
hits.push({
relativePath: rel,
absolutePath: file.path,
score: Math.round(score * 10) / 10,
matchedTerms: [...matched],
lines
});
}
hits.sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath));
const limit = Math.min(Math.max(Math.floor(args.limit ?? 20), 1), 100);
return { ok: true, scope: scopeArg, scannedFiles: files.length, totalMatches: hits.length, hits: hits.slice(0, limit) };
}
// src/netx/kb-runtime.ts
var STORE_KEY = Symbol.for("dsh-netxops.kb-store");
@ -862,7 +1174,7 @@ function omitUndefined(record) {
// src/netx/tool-mount-status.ts
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
import { homedir } from "node:os";
import { dirname as dirname3, join as join4 } from "node:path";
import { dirname as dirname4, join as join7 } from "node:path";
var KEY = Symbol.for("dsh-netxops.mount-inventory");
function store2() {
const root = globalThis;
@ -886,8 +1198,8 @@ function getToolMountStatus() {
}
function persist() {
try {
const path = join4(process.env.DSH_HOME?.trim() || join4(homedir(), ".dsh"), "netxops-mount-status.json");
mkdirSync2(dirname3(path), { recursive: true });
const path = join7(process.env.DSH_HOME?.trim() || join7(homedir(), ".dsh"), "netxops-mount-status.json");
mkdirSync2(dirname4(path), { recursive: true });
writeFileSync2(path, `${JSON.stringify({ mount: getToolMountStatus() ?? null, mounts: getToolMountStatuses() }, null, 2)}
`, "utf8");
} catch {}
@ -937,14 +1249,8 @@ function tool(name, description, parameters, execute) {
}
});
}
function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) {
if (!kbLocalToolsEnabled(snapshot))
return () => {};
const localRoot = resolveKbLocalRoot(snapshot);
const toolsApi = ctx.tools;
if (!toolsApi || typeof toolsApi.register !== "function")
return () => {};
const entries = [
function writeTools(localRoot) {
return [
tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", {
bucket: {
type: "string",
@ -1024,7 +1330,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) {
}, (args) => deleteLocalFile(liveSnapshot(), {
path: String(args.path ?? "")
})),
tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", {
tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents with netx__kbRead using the listed paths.", {
root: {
type: "string",
enum: ["memories", "drafts", "suggestions", "refs", "all"],
@ -1036,6 +1342,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) {
limit: typeof args.limit === "number" ? args.limit : undefined
}))
];
}
function readTools(snapshot) {
return [
tool("netx__kbSearch", `Search the operator knowledge base at ${snapshot.realRoot} (read-only HQ pack + _local). ` + "Plain mode: space-separated keywords (NE host_name, alarm, protocol, symptom…), case-insensitive; " + "files ranked by how many distinct terms match (filename hits weigh more), up to 4 hit lines each. " + "Then open the best hits with netx__kbRead.", {
query: reqStr("Keywords (space-separated) or a regex when regex=true"),
scope: str("MANIFEST paths key(s) — e.g. rca, theory, theoryVendor, packet, commonPatterns, local, neIndex — or a relative dir; comma-separate several; default all"),
regex: bool("Treat query as one case-insensitive JS regex"),
limit: num("Max files 1–100 (default 20)")
}, (args) => searchKb(liveSnapshot(), {
query: String(args.query ?? ""),
scope: args.scope != null ? String(args.scope) : undefined,
regex: args.regex === true,
limit: typeof args.limit === "number" ? args.limit : undefined
})),
tool("netx__kbRead", `Read one file (or list one directory) inside the knowledge base at ${snapshot.realRoot}. ` + "path: relative to kbRoot, absolute inside it, or a MANIFEST paths key (rca, neIndex, cmdLib, patternMap, …). " + "Long files: page with offset/limit (1-based lines; default 400).", {
path: reqStr("KB-relative path, absolute path inside kbRoot, or a paths.* key"),
offset: num("First line (1-based, default 1)"),
limit: num("Max lines 1–2000 (default 400)")
}, (args) => readKbFile(liveSnapshot(), {
path: String(args.path ?? ""),
offset: typeof args.offset === "number" ? args.offset : undefined,
limit: typeof args.limit === "number" ? args.limit : undefined
}))
];
}
function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) {
if (snapshot.status !== "configured")
return () => {};
const localRoot = resolveKbLocalRoot(snapshot);
const toolsApi = ctx.tools;
if (!toolsApi || typeof toolsApi.register !== "function")
return () => {};
const entries = [...readTools(snapshot), ...localRoot ? writeTools(localRoot) : []];
const disposers = [];
const source = options.source ?? "netxops-kb-local";
const id = getToolMountId(ctx, source);
@ -1057,7 +1396,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) {
publishToolMountStatus({ ...status, names: [], count: 0, error: String(error) });
throw error;
}
ctx.logger.info("netxops: kb local tools registered count=%s localRoot=%s", disposers.length, localRoot);
ctx.logger.info("netxops: kb tools registered count=%s kbRoot=%s localRoot=%s", disposers.length, snapshot.realRoot, localRoot ?? "(none — read-only)");
return () => {
for (const dispose of disposers)
dispose();
@ -1067,19 +1406,35 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) {
// src/netx/kb-pack-skills.ts
import { readdir as readdir2, stat as stat2 } from "node:fs/promises";
import { join as join5 } from "node:path";
import { join as join8 } from "node:path";
function kbPackSkillsEnabled(snapshot) {
return snapshot.status === "configured" && snapshot.content.hasSkills === true;
}
function resolveKbSkillsRoot(snapshot) {
const rel = snapshot.paths.skills?.trim() || "_skills";
return join5(snapshot.realRoot, rel);
return join8(snapshot.realRoot, rel);
}
function kbPackSkillFooter(snapshot) {
return [
"",
"",
`## Injected KB anchors (netxops · ${snapshot.operatorName} / ${snapshot.country} v${snapshot.version})`,
"",
`kbStatus=configured · kbRoot=\`${snapshot.realRoot}\``,
"",
renderKbPathTable(snapshot),
"",
"These are already resolved from `MANIFEST.json` `paths.*`; use them verbatim.",
"Read/search the pack with `netx__kbRead` / `netx__kbSearch` when present; write only via `netx__kbWrite*`.",
""
].join(`
`);
}
function resolveKbLocalSkillsRoot(snapshot) {
const rel = snapshot.paths.localSkills?.trim();
if (!rel)
return null;
return join5(snapshot.realRoot, rel);
return join8(snapshot.realRoot, rel);
}
async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) {
let entries;
@ -1098,7 +1453,7 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options)
for (const entry of entries) {
if (options.signal?.aborted)
break;
const full = join5(root, entry);
const full = join8(root, entry);
let isDir = false;
try {
isDir = (await stat2(full)).isDirectory();
@ -1120,15 +1475,24 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options)
}
if (options.signal?.aborted)
break;
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 (options.seen.has(skill.name)) {
ctx.logger.warn("netxops: kb %s skill %s duplicates an already registered pack skill — skip %s", options.label, skill.name, full);
continue;
}
try {
disposers.push(skillsApi.register({
name: skill.name,
description: skill.description,
content: `${skill.content.trimEnd()}${options.footer}`,
path: skill.path,
resourceBase: { kind: "directory", path: skill.directory },
provider,
source: "custom"
}));
options.seen.add(skill.name);
} catch (error) {
ctx.logger.warn("netxops: kb %s skill %s register failed: %s", options.label, skill.name, error instanceof Error ? error.message : String(error));
}
}
} catch (error) {
for (const dispose of disposers.splice(0))
@ -1149,17 +1513,23 @@ async function registerKbPackSkills(ctx, snapshot, options) {
const disposers = [];
const skillsRoot = resolveKbSkillsRoot(snapshot);
const localRoot = resolveKbLocalSkillsRoot(snapshot);
const footer = kbPackSkillFooter(snapshot);
const seen = new Set;
try {
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, {
logMissing: true,
label: "pack",
signal: options.signal
signal: options.signal,
footer,
seen
}));
if (localRoot) {
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, {
logMissing: false,
label: "local",
signal: options.signal
signal: options.signal,
footer,
seen
}));
}
} catch (error) {
@ -1361,8 +1731,8 @@ function watchNetxConnection(listener) {
}
// src/netx/tools.ts
import { readFileSync as readFileSync2 } from "node:fs";
import { dirname as dirname4, join as join6 } from "node:path";
import { readFileSync as readFileSync5 } from "node:fs";
import { dirname as dirname5, join as join9 } from "node:path";
import { fileURLToPath as fileURLToPath2 } from "node:url";
import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools";
@ -2804,8 +3174,8 @@ async function layoutTopologyView(client, args, signal) {
// src/netx/tools.ts
function packageVersion() {
try {
const here = dirname4(fileURLToPath2(import.meta.url));
const pkg = JSON.parse(readFileSync2(join6(here, "..", "package.json"), "utf8"));
const here = dirname5(fileURLToPath2(import.meta.url));
const pkg = JSON.parse(readFileSync5(join9(here, "..", "package.json"), "utf8"));
return typeof pkg.version === "string" ? pkg.version : "0";
} catch {
return "0";
@ -3276,8 +3646,8 @@ function registerNetxTools(ctx, connection, options) {
// src/netx/group-tools-plugin.ts
function packageVersion2() {
try {
const here = dirname5(fileURLToPath3(import.meta.url));
const pkg = JSON.parse(readFileSync3(join7(here, "..", "package.json"), "utf8"));
const here = dirname6(fileURLToPath3(import.meta.url));
const pkg = JSON.parse(readFileSync6(join10(here, "..", "package.json"), "utf8"));
return typeof pkg.version === "string" ? pkg.version : "0";
} catch {
return "0";