mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-20 03:51:01 +08:00
Fix KB loading bugs and add jailed KB read/search tools (0.1.71).
- 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:
parent
9b2166e5b6
commit
2def53e7c7
22 changed files with 4286 additions and 1013 deletions
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue