Compare commits

..

No commits in common. "4dd628b7ccebb5c0c542dadb0b2813f60c30d3d3" and "3a731389f0459334b9f84989701a375a6a8033e6" have entirely different histories.

40 changed files with 828 additions and 8509 deletions

2
.gitignore vendored
View file

@ -4,11 +4,9 @@ dist/
lib/*
!lib/index.js
!lib/agent-tools.js
!lib/agent-tools-ops.js
!lib/agent-tools-nms.js
!lib/agent-tools-common.js
!lib/agent-tools-topology.js
!lib/agent-tools-biz-monitor.js
!lib/client.js
*.log
.DS_Store

View file

@ -1,4 +1,4 @@
# Install Netx Ops on DeepSeek Harness
# Install Netx Ops on DeepSeek Harness
## What `dsh plugin add` gives you
@ -16,9 +16,9 @@ You do **not** run `link-preset.ps1` for normal use. That script is only a manua
## Still required outside the npm package
1. **netx API** reachable (default `http://127.0.0.1:8890`) — the data plane.
2. **API token** with scopes matching the tools you use (`alarms:read`, `ne:read`, `ne:exec`, `biz-monitor:read`, `sql:query`, …).
2. **API token** with scopes matching the tools you use (`alarms:read`, `ne:read`, `ne:exec`, `sql:query`, …).
No local Python / `netx_mcp` install is required for DSH. (OpenClaw and other MCP hosts can still use `python -m netx_mcp` separately for NMS/CLI — **not** for biz-monitor; that is netxops host tools only.)
No local Python / `netx_mcp` install is required for DSH. (OpenClaw and other MCP hosts can still use `python -m netx_mcp` separately.)
## Clone / `pnpm install`(仓库源码)
@ -67,7 +67,7 @@ One command, three **direct** profile bundles. Do **not** nest IM/cron under net
1. Plugins card **Netx Ops** visible.
2. With key-alarm push on: card badge shows **Connected** (or reconnecting / auth failed with detail).
3. Agent presets → Custom → **Netx Ops**.
4. Tools include `netx__queryNmsAlarms` / `netx__execManagedNe` / `netx__findTopologyPaths` (**ops** → skill `netx-ops`). Cutover analysis: `netx__getBizMonitorContext` … (**bizMonitor** → `netx-biz-monitor`). Canvas / dual_unit need **topology** (`netx-topology`).
4. Tools include `netx__queryNmsAlarms` / `netx__execManagedNe` / `netx__findTopologyPaths` (**ops** → skill `netx-ops`). Canvas / dual_unit need **topology** (`netx-topology`).
5. Export section shows a session count; **下载 ZIP** starts a browser download.
6. Optional knowledge-base badge shows operator/country/version when `kbRoot` + MANIFEST are valid.

View file

@ -7,7 +7,7 @@ Operator-subset knowledge packages are wired through **netxops settings**, not i
| Surface | Responsibility |
|---------|----------------|
| `netx-ops` | Live netx evidence (alarms / inventory / CLI) — **pure netx**, no KB playbooks |
| `kb-context` | MANIFEST annotation (`KB_*`) + dual-plane write rules |
| `kb-context` | Identity annotation from MANIFEST (`KB_*`) |
| `_skills/kb-*` | Shared operator KB triage / ingest / retrieve / export / package |
| `paths.localSkills` | Operator-local skills (alongside `_skills/`; same toggles) |
@ -38,11 +38,11 @@ Authoritative packaging prose lives in the workspace contract notes (`插件` /
| Channel | Fields |
|---------|--------|
| `process.env` | `KB_ROOT`, `KB_LOCAL` (when `paths.local` set), `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT` (JSON of `has*`), `KB_STATUS` |
| Skill `kb-context` | Same MANIFEST fields + `kbLocal` + dual-plane write rules (refs / memories / drafts / suggestions) |
| Skill `kb-context` | Same identity + `kbLocal` + local create/update/delete tool names |
| Skills from pack | Registered when `configured` **and** `hasSkills` **and** the inPreset/public toggle for that plane |
| Local FS tools | When `configured` + `paths.local` + same KB toggles: `netx__kbWriteRef` / `WriteMemory` / `WriteDraft` / `WriteSuggestion` / `UpdateLocal` / `DeleteLocal` / `ListLocal` — jail under `refs\|memories\|drafts\|suggestions`. HQ pack is read-only; evolve site product knowledge with `kbWriteRef` (per-`host_name` PROFILE) and capture experience with `kbWriteMemory` as it appears. |
| Local FS tools | When `configured` + `paths.local` + same KB toggles: `netx__kbWriteMemory` / `WriteDraft` / `WriteSuggestion` / `UpdateLocal` / `DeleteLocal` / `ListLocal` — jail under `memories\|drafts\|suggestions` only (`identity/` host persona, agent read-only) |
RPC (channel `/netxops`): `kb.status` (published snapshot), `kb.reload` (re-resolve live `kbRoot` + republish), `kb.resolve` with `{ path }` (preview unsaved paths).
RPC (channel `/netxops`): `kb.status` (saved snapshot), `kb.resolve` with `{ path }` (preview unsaved paths).
## Pack skill registration

View file

@ -6,10 +6,9 @@ Tools register by capability group. **One group ↔ one skill.**
|-------|-------|------------------------|
| **ops** | `netx-ops` | on |
| **topology** | `netx-topology` | off |
| **bizMonitor** | `netx-biz-monitor` | on |
Public flags mount the same group on the host for other presets.
Forced mounts: `dsh-netxops/tools-ops` | `tools-topology` | `tools-biz-monitor` (legacy aliases: `tools-nms` / `tools-common` → ops).
Forced mounts: `dsh-netxops/tools-ops` | `tools-topology` (legacy aliases: `tools-nms` / `tools-common` → ops).
Model names: `netx__<stem>`. NMS tools use `Nms`; adapter `nmsProvider=zte-ume` still hits `/v1/ume/*`.
@ -21,19 +20,6 @@ Model names: `netx__<stem>`. NMS tools use `Nms`; adapter `nmsProvider=zte-ume`
| `listManagedNe` / `getManagedNe` / `execManagedNe` / `listCliTargets` | Managed CLI (login / show) |
| `findTopologyPaths` | Fabric path lookup |
## bizMonitor → `netx-biz-monitor`
Host tools call netx REST directly (**not** netx-mcp). Token needs `biz-monitor:read`.
| Tool | Role |
|------|------|
| `listBizMonitors` | Catalog: cutover projects + biz_state tasks (no id required) |
| `listBizMonitorBatches` | Cutover / collect batch ids (+ optional evaluate runs) |
| `getBizMonitorContext` | Project + templates + normalize + port map + tasks |
| `getBizMonitorBoard` | Batch / evaluate progress |
| `listBizMonitorReds` / `getBizMonitorDiffs` | Reds & diffs with evidence (raw A/B + show cmd) |
| `getBizCollectBatch` / `getBizCollectCommandRaw` | Collect audit + full CLI raw |
## topology → `netx-topology`
| Tool | Role |
@ -49,14 +35,13 @@ Execution: HTTP `apiUrl` + Bearer `NETX_API_TOKEN`.
## knowledge base → `groupKb*` (not ops/topology)
Registered when KB is configured, MANIFEST has `paths.local`, and the KB in-preset / public toggle is on. Host-side FS writes (bypass workspace sandbox). Jail: `{kbLocal}/refs|memories|drafts|suggestions`.
Registered when KB is configured, MANIFEST has `paths.local`, and the KB in-preset / public toggle is on. Host-side FS writes (bypass workspace sandbox). Jail: `{kbLocal}/memories|drafts|suggestions` only — **not** `identity/`.
| Tool | Role |
|------|------|
| `kbWriteRef` | Site product KB under `refs/{inventory\|devices\|topology\|business\|commands\|handbooks}/`. `devices` requires `device=host_name`; primary profile slug=`PROFILE`. |
| `kbWriteMemory` | Diary under `memories/{日常笔记\|排障复盘\|AI思维链}/` — write as soon as reusable experience appears. |
| `kbWriteMemory` | New diary entry under `memories/{日常笔记\|排障复盘\|AI思维链}/` (`overwrite` optional) |
| `kbWriteDraft` | New `DRAFT-…` under `drafts/` (+ `status: draft`) |
| `kbWriteSuggestion` | New file under `suggestions/{theory\|improvement}/` |
| `kbUpdateLocal` | Replace body of an existing jailed file |
| `kbDeleteLocal` | Delete one jailed file |
| `kbListLocal` | List recent `.md` under writable trees (`root` may be `refs`) |
| `kbListLocal` | List recent `.md` under writable trees |

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,7 @@
// src/netx/capability-groups.ts
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
ops: Object.freeze({ inPreset: true, public: false }),
topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false })
topology: Object.freeze({ inPreset: false, public: false })
});
var TOOLS_BY_GROUP = Object.freeze({
ops: Object.freeze([
@ -38,27 +37,15 @@ var TOOLS_BY_GROUP = Object.freeze({
"netx__suggestSinkHubs",
"netx__analyzeTopologyViewLayout",
"netx__sinkTopologyDualUnits"
]),
bizMonitor: Object.freeze([
"netx__listBizMonitors",
"netx__listBizMonitorBatches",
"netx__getBizMonitorContext",
"netx__getBizMonitorBoard",
"netx__listBizMonitorReds",
"netx__getBizMonitorDiffs",
"netx__getBizCollectBatch",
"netx__getBizCollectCommandRaw"
])
});
var SKILL_DIR_BY_GROUP = Object.freeze({
ops: "ops",
topology: "topology",
bizMonitor: "biz-monitor"
topology: "topology"
});
var CAPABILITY_GROUP_IDS = Object.freeze([
"ops",
"topology",
"bizMonitor"
"topology"
]);
function groupsForPlane(groups, plane, only) {
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS;
@ -73,12 +60,12 @@ function groupsForced(only) {
return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id));
}
function toolNamesForGroups(groupIds) {
const out = new Set;
const names = new Set;
for (const id of groupIds) {
for (const name of TOOLS_BY_GROUP[id] ?? [])
out.add(name);
for (const tool of TOOLS_BY_GROUP[id])
names.add(tool);
}
return out;
return names;
}
// src/netx/group-skills.ts
@ -234,16 +221,7 @@ import { join as join2, relative, resolve, sep } from "node:path";
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
"memories",
"drafts",
"suggestions",
"refs"
]);
var REF_AREAS = Object.freeze([
"inventory",
"devices",
"topology",
"business",
"commands",
"handbooks"
"suggestions"
]);
var MEMORY_BUCKET_DIR = Object.freeze({
note: "日常笔记",
@ -303,10 +281,6 @@ function suggestionFileName(opts) {
const date = normalizeIsoDate(opts.date, opts.now);
return `${date}-${sanitizeSlug(opts.slug)}.md`;
}
function refFileName(slug) {
const base = sanitizeSlug(slug, "note");
return base.toLowerCase().endsWith(".md") ? base : `${base}.md`;
}
function resolveWritableLocalPath(localRoot, ...segments) {
if (segments.some((s) => s.includes("\x00"))) {
throw new Error("path segment contains NUL");
@ -380,19 +354,6 @@ function draftDir(domain) {
return "drafts";
return join2("drafts", sanitizeSlug(d, "misc"));
}
function refDir(area, device) {
if (!REF_AREAS.includes(area)) {
throw new Error(`invalid refs area: ${area}`);
}
if (area === "devices") {
const host = sanitizeSlug(device?.trim() || "", "");
if (!host) {
throw new Error("refs/devices requires device (host_name)");
}
return join2("refs", "devices", host);
}
return join2("refs", area);
}
// src/netx/kb-context-skill.ts
var SKILL_NAME = "kb-context";
@ -403,29 +364,18 @@ function skillBody(snapshot) {
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
const localGuide = localRoot ? [
"",
"### Dual plane (mandatory)",
"",
"- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
`- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
"- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
" `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /",
" `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.",
"",
"### refs — site product knowledge (evolve every task)",
"- On any NE: **read** `refs/devices/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
"- `area=devices` requires `device=<host_name>` (never UUID); primary file slug=`PROFILE`.",
"- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.",
"- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).",
"",
"### memories — write immediately when valuable",
"- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.",
"- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`."
"**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,",
"`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.",
"They jail writes to `memories/` / `drafts/` / `suggestions/` only.",
"Do **not** use workspace Write/bash for KB paths (sandbox).",
"`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;",
"file boundary issues as `suggestions/` instead."
] : [
"",
"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: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).",
content: [
"## Knowledge base (configured)",
"",
@ -593,25 +543,6 @@ function createSuggestion(snapshot, args) {
relativePath: target.relativePath
};
}
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) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
}
mkdirSync(dirname2(target.absolutePath), { recursive: true });
const existed = existsSync3(target.absolutePath);
writeFileSync(target.absolutePath, args.body.endsWith(`
`) ? args.body : `${args.body}
`, "utf8");
return {
ok: true,
action: existed ? "updated" : "created",
absolutePath: target.absolutePath,
relativePath: target.relativePath
};
}
function updateLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path);
@ -737,41 +668,10 @@ function watchKbContext(listener) {
};
}
// src/netx/json-safe.ts
function toLosslessJson(value) {
try {
const text = JSON.stringify(value);
if (text === undefined) {
return { ok: false, error: "tool_result_not_json", detail: "undefined_root" };
}
const parsed = JSON.parse(text);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
return { ok: true, data: { value: parsed } };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) };
}
}
function omitUndefined(record) {
const out = {};
for (const [key, value] of Object.entries(record)) {
if (value !== undefined)
out[key] = value;
}
return out;
}
// src/netx/kb-local-tools.ts
var str = (description) => ({ type: "string", ...description ? { description } : {} });
var bool = (description) => ({ type: "boolean", ...description ? { description } : {} });
var num = (description) => ({ type: "number", ...description ? { description } : {} });
var reqStr = (description) => ({
type: "string",
required: true,
...description ? { description } : {}
});
function renderJson(_args, value) {
return [{ type: "text", text: JSON.stringify(value, null, 0) }];
}
@ -792,7 +692,7 @@ function tool(name, description, parameters, execute) {
isConcurrencySafe: () => false,
async execute(args) {
try {
return toLosslessJson(await execute(args));
return await execute(args);
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error));
}
@ -808,16 +708,20 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
return () => {};
const entries = [
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",
required: true,
enum: ["note", "rca_review", "ai_trace"],
description: "Memory subdirectory"
},
slug: reqStr("Short filename stem (no path separators)"),
body: reqStr("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
type: "object",
additionalProperties: false,
required: ["bucket", "slug", "body"],
properties: {
bucket: {
type: "string",
enum: ["note", "rca_review", "ai_trace"],
description: "Memory subdirectory"
},
slug: str("Short filename stem (no path separators)"),
body: str("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
}
}, (args) => createMemory(liveSnapshot(), {
bucket: args.bucket,
slug: String(args.slug ?? ""),
@ -825,12 +729,17 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", {
slug: reqStr("Case stem (DRAFT- prefix added if missing)"),
body: reqStr("Markdown body (RCA-ish draft)"),
domain: str("Optional fault-domain subfolder under drafts/"),
date: str("Optional event date YYYY-MM-DD or YYYYMMDD"),
overwrite: bool("Replace if the target file already exists")
tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", {
type: "object",
additionalProperties: false,
required: ["slug", "body"],
properties: {
slug: str("Case stem (DRAFT- prefix added if missing)"),
body: str("Markdown body (RCA-ish draft)"),
domain: str("Optional fault-domain subfolder under drafts/"),
date: str("Optional event date YYYY-MM-DD or YYYYMMDD"),
overwrite: bool("Replace if the target file already exists")
}
}, (args) => createDraft(liveSnapshot(), {
slug: String(args.slug ?? ""),
body: String(args.body ?? ""),
@ -838,17 +747,21 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", {
kind: {
type: "string",
required: true,
enum: ["theory", "improvement"],
description: "theory = knowledge fix; improvement = tools/skills/process"
},
slug: reqStr("Short filename stem"),
body: reqStr("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", {
type: "object",
additionalProperties: false,
required: ["kind", "slug", "body"],
properties: {
kind: {
type: "string",
enum: ["theory", "improvement"],
description: "theory = knowledge fix; improvement = tools/skills/process"
},
slug: str("Short filename stem"),
body: str("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
}
}, (args) => createSuggestion(liveSnapshot(), {
kind: args.kind,
slug: String(args.slug ?? ""),
@ -856,43 +769,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", {
area: {
type: "string",
required: true,
enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"],
description: "refs/ subtree"
},
slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"),
body: reqStr("Full markdown body"),
device: str("Required when area=devices — host_name (never UUID)"),
overwrite: bool("Replace if the target file already exists")
}, (args) => createRef(liveSnapshot(), {
area: args.area,
slug: String(args.slug ?? ""),
body: String(args.body ?? ""),
device: args.device != null ? String(args.device) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", {
path: reqStr("Absolute path or path relative to KB local root"),
body: reqStr("New full markdown body")
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", {
type: "object",
additionalProperties: false,
required: ["path", "body"],
properties: {
path: str("Absolute path or path relative to KB local root"),
body: str("New full markdown body")
}
}, (args) => updateLocalFile(liveSnapshot(), {
path: String(args.path ?? ""),
body: String(args.body ?? "")
})),
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, {
path: reqStr("Absolute path or path relative to KB local root")
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, {
type: "object",
additionalProperties: false,
required: ["path"],
properties: {
path: str("Absolute path or path relative to KB local root")
}
}, (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.", {
root: {
type: "string",
enum: ["memories", "drafts", "suggestions", "refs", "all"],
description: "Subtree to list (default all writable)"
},
limit: num("Max entries 1–200 (default 50)")
tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", {
type: "object",
additionalProperties: false,
properties: {
root: {
type: "string",
enum: ["memories", "drafts", "suggestions", "all"],
description: "Subtree to list (default all writable)"
},
limit: num("Max entries 1–200 (default 50)")
}
}, (args) => listLocalFiles(liveSnapshot(), {
root: args.root ?? "all",
limit: typeof args.limit === "number" ? args.limit : undefined
@ -1497,347 +1406,6 @@ async function findTopologyPaths(client, args, signal) {
body.to_managed_ne_id = toMid;
return client.post("/v1/topology/fabric/paths", body, signal, 30000);
}
function taskBriefNe(brief) {
const rec = asRecord(brief);
const name = typeof rec.ne_name === "string" ? rec.ne_name.trim() : "";
const ip = typeof rec.ne_ip === "string" ? rec.ne_ip.trim() : "";
if (name && ip)
return `${name} (${ip})`;
return name || ip;
}
function slimMigrationProject(row) {
const mt = asRecord(row.monitor_template);
return omitUndefined({
id: row.id,
name: row.name,
status: row.status,
note: row.note,
monitor_template_id: row.monitor_template_id,
monitor_template_name: typeof mt.name === "string" ? mt.name : "",
old_task_id: row.old_task_id,
new_task_id: row.new_task_id,
old_hf_task_id: row.old_hf_task_id,
new_hf_task_id: row.new_hf_task_id,
old_ne: taskBriefNe(row.old_task),
new_ne: taskBriefNe(row.new_task),
hf_interval_sec: row.hf_interval_sec,
hf_start_at: row.hf_start_at,
hf_end_at: row.hf_end_at,
updated_at: row.updated_at
});
}
function matchesBizMonitorQ(row, q) {
if (!q)
return true;
const hay = [
row.id,
row.name,
row.note,
row.status,
row.ne_name,
row.ne_ip,
row.ne_id,
row.old_ne,
row.new_ne,
row.monitor_template_name,
row.purpose,
row.vendor
].map((v) => typeof v === "string" || typeof v === "number" ? String(v).toLowerCase() : "").join(" ");
return hay.includes(q);
}
async function listBizMonitors(client, args, signal) {
const kindRaw = str2(args, "kind", "all").trim().toLowerCase() || "all";
const kind = kindRaw === "project" ? "projects" : kindRaw === "task" ? "tasks" : kindRaw;
if (kind !== "all" && kind !== "projects" && kind !== "tasks") {
return { ok: false, error: "kind_invalid", detail: "kind must be all|projects|tasks" };
}
const purpose = str2(args, "purpose").trim();
const status = str2(args, "status").trim().toLowerCase();
const q = str2(args, "q").trim().toLowerCase();
const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500);
const wantProjects = kind === "all" || kind === "projects";
const wantTasks = kind === "all" || kind === "tasks";
const fetches = [];
if (wantProjects)
fetches.push(client.get("/v1/biz-migration/projects", {}, signal));
else
fetches.push(Promise.resolve({ ok: true, data: { items: [] } }));
if (wantTasks) {
const params = {};
if (purpose)
params.purpose = purpose;
fetches.push(client.get("/v1/biz-state/tasks", params, signal));
} else {
fetches.push(Promise.resolve({ ok: true, data: { items: [] } }));
}
const [projRes, taskRes] = await Promise.all(fetches);
if (wantProjects && projRes.ok === false)
return projRes;
if (wantTasks && taskRes.ok === false)
return taskRes;
const projItems = Array.isArray(asRecord(projRes.data).items) ? asRecord(projRes.data).items : [];
const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : [];
let projects = projItems.map((row) => slimMigrationProject(asRecord(row))).filter((row) => {
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return matchesBizMonitorQ(row, q);
});
let tasks = taskItems.map((row) => asRecord(row)).filter((row) => {
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return matchesBizMonitorQ(row, q);
});
const projectsTotal = projects.length;
const tasksTotal = tasks.length;
projects = projects.slice(0, limit);
tasks = tasks.slice(0, limit);
return {
ok: true,
data: omitUndefined({
kind,
projects: wantProjects ? projects : undefined,
tasks: wantTasks ? tasks : undefined,
counts: {
projects: wantProjects ? projectsTotal : 0,
tasks: wantTasks ? tasksTotal : 0,
projects_returned: wantProjects ? projects.length : 0,
tasks_returned: wantTasks ? tasks.length : 0
},
next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id."
})
};
}
function slimCutoverBatch(row) {
return omitUndefined({
id: row.id,
project_id: row.project_id,
batch_label: row.batch_label,
status: row.status,
accept_status: row.accept_status,
accept_run_id: row.accept_run_id,
started_at: row.started_at,
ended_at: row.ended_at,
note: row.note,
created_at: row.created_at,
updated_at: row.updated_at
});
}
function slimCollectBatch(row, taskId = "") {
return omitUndefined({
id: row.id,
task_id: row.task_id || taskId || undefined,
status: row.status,
ne_name: row.ne_name,
command_count: row.command_count,
row_count: row.row_count,
is_baseline: row.is_baseline,
protected: row.protected,
message: row.message,
started_at: row.started_at,
ended_at: row.ended_at
});
}
function slimEvalRun(row) {
const summary = asRecord(row.summary);
const progress = asRecord(summary.progress);
return omitUndefined({
id: row.id,
batch_id: row.batch_id,
purpose: row.purpose,
old_batch_id: row.old_batch_id,
new_batch_id: row.new_batch_id,
created_at: row.created_at,
anomaly: summary.anomaly,
progress_ok: progress.ok ?? summary.progress_ok,
progress_total: progress.total ?? summary.progress_total
});
}
async function listBizMonitorBatches(client, args, signal) {
const projectId = str2(args, "project_id").trim();
const taskId = str2(args, "task_id").trim();
const batchId = str2(args, "batch_id").trim();
const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto";
const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1;
const status = str2(args, "status").trim().toLowerCase();
const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200);
const runsLimit = clampInt(num2(args, "runs_limit") ?? 10, 10, 1, 50);
let kind = kindRaw;
if (kind === "auto") {
if (batchId && !projectId && !taskId)
kind = "runs";
else if (projectId && !taskId)
kind = "cutover";
else if (taskId && !projectId)
kind = "collect";
else if (projectId && taskId)
kind = "all";
else if (batchId)
kind = "runs";
else {
return {
ok: false,
error: "project_id_or_task_id_or_batch_id_required",
detail: "Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs)."
};
}
}
if (!["auto", "all", "cutover", "collect", "runs"].includes(kind)) {
return { ok: false, error: "kind_invalid", detail: "kind must be auto|all|cutover|collect|runs" };
}
const wantCutover = kind === "all" || kind === "cutover";
const wantCollect = kind === "all" || kind === "collect";
const wantRuns = kind === "runs" || includeRuns && (wantCutover || Boolean(batchId));
if (wantCutover && !projectId) {
return { ok: false, error: "project_id_required", detail: "kind=cutover|all needs project_id" };
}
if (wantCollect && !taskId) {
return { ok: false, error: "task_id_required", detail: "kind=collect|all needs task_id" };
}
if (kind === "runs" && !batchId) {
return { ok: false, error: "batch_id_required", detail: "kind=runs needs cutover batch_id" };
}
let cutoverBatches = [];
let collectBatches = [];
let runs = [];
if (wantCutover) {
const res = await client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, {}, signal);
if (res.ok === false)
return res;
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
cutoverBatches = items.map((row) => slimCutoverBatch(asRecord(row))).filter((row) => {
if (batchId && String(row.id || "") !== batchId)
return false;
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return true;
}).slice(0, limit);
}
if (wantCollect) {
const res = await client.get(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`, { limit }, signal);
if (res.ok === false)
return res;
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
collectBatches = items.map((row) => slimCollectBatch(asRecord(row), taskId)).filter((row) => {
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return true;
}).slice(0, limit);
}
if (wantRuns) {
const runBatchIds = kind === "runs" ? [batchId] : cutoverBatches.map((b) => String(b.id || "").trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0);
if (kind === "runs" && batchId) {
const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`, { limit: runsLimit }, signal);
if (res.ok === false)
return res;
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit);
} else if (includeRuns && runBatchIds.length > 0) {
const settled = await Promise.all(runBatchIds.map(async (id) => {
const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`, { limit: runsLimit }, signal);
if (res.ok === false)
return { batch_id: id, error: res, items: [] };
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
return {
batch_id: id,
items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit)
};
}));
for (const block of settled) {
if ("error" in block && block.error) {
const failed = cutoverBatches.find((b) => b.id === block.batch_id);
if (failed)
failed.runs_error = asRecord(block.error).error || "runs_fetch_failed";
continue;
}
const target = cutoverBatches.find((b) => b.id === block.batch_id);
if (target)
target.runs = block.items;
for (const r of block.items)
runs.push(r);
}
}
}
return {
ok: true,
data: omitUndefined({
kind,
project_id: projectId || undefined,
task_id: taskId || undefined,
batch_id: batchId || undefined,
cutover_batches: wantCutover ? cutoverBatches : undefined,
collect_batches: wantCollect ? collectBatches : undefined,
runs: kind === "runs" ? runs : undefined,
counts: {
cutover_batches: cutoverBatches.length,
collect_batches: collectBatches.length,
runs: runs.length
},
next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)."
})
};
}
async function getBizMonitorContext(client, args, signal) {
const projectId = str2(args, "project_id").trim();
const taskId = str2(args, "task_id").trim();
if (!projectId && !taskId) {
return { ok: false, error: "project_id_or_task_id_required" };
}
const params = {};
if (projectId)
params.project_id = projectId;
if (taskId)
params.task_id = taskId;
return client.get("/v1/biz-migration/monitor-context", params, signal);
}
async function getBizMonitorBoard(client, args, signal) {
const batchId = str2(args, "batch_id").trim();
if (!batchId)
return { ok: false, error: "batch_id_required" };
const params = {};
const runId = str2(args, "run_id").trim();
if (runId)
params.run_id = runId;
return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal);
}
async function listBizMonitorReds(client, args, signal) {
const projectId = str2(args, "project_id").trim();
if (!projectId)
return { ok: false, error: "project_id_required" };
const params = {};
putStr(params, args, ["status"]);
const limit = num2(args, "limit");
if (limit !== undefined)
params.limit = clampInt(limit, 200, 1, 500);
return client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`, params, signal);
}
async function getBizMonitorDiffs(client, args, signal) {
const runId = str2(args, "run_id").trim();
if (!runId)
return { ok: false, error: "run_id_required" };
const params = {};
putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]);
const limit = num2(args, "limit");
if (limit !== undefined)
params.limit = clampInt(limit, 200, 1, 500);
const offset = num2(args, "offset");
if (offset !== undefined)
params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER);
return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal);
}
async function getBizCollectBatch(client, args, signal) {
const batchId = str2(args, "batch_id").trim();
if (!batchId)
return { ok: false, error: "batch_id_required" };
return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal);
}
async function getBizCollectCommandRaw(client, args, signal) {
const batchId = str2(args, "batch_id").trim();
const commandId = str2(args, "command_id").trim();
if (!batchId || !commandId) {
return { ok: false, error: "batch_id_and_command_id_required" };
}
return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, {}, signal);
}
// src/netx/topology-handlers.ts
function asRecord2(value) {
@ -2517,11 +2085,10 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
isConcurrencySafe: () => true,
async execute(args, exec) {
const result = await handler(getClient(), args, exec.signal);
const safe = toLosslessJson(result);
if (result.ok === false || safe.ok === false) {
throw new Error(JSON.stringify(safe));
if (result.ok === false) {
throw new Error(JSON.stringify(result));
}
return safe;
return result;
}
});
}
@ -2663,53 +2230,6 @@ function registerNetxTools(ctx, connection, options) {
layer: str4(),
detail: str4("summary | full")
}, findTopologyPaths, getClient, t),
tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.", {
kind: str4("all | projects | tasks (default all)"),
purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"),
status: str4("Filter by status (e.g. active / paused)"),
q: str4("Keyword on name / ne_name / note / id"),
limit: num4("Max rows per list (default 100, max 500)")
}, listBizMonitors, getClient, t),
tool2("netx__listBizMonitorBatches", "List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.", {
kind: str4("auto | all | cutover | collect | runs (default auto)"),
project_id: str4("Cutover project id → cutover_batches"),
task_id: str4("biz_state task id → collect_batches"),
batch_id: str4("Cutover batch id (filter or kind=runs)"),
include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"),
status: str4("Filter batch status"),
limit: num4("Max batches (default 50)"),
runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)")
}, listBizMonitorBatches, getClient, t),
tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", {
project_id: str4("Cutover / biz-migration project id"),
task_id: str4("biz_state task id (optional if project_id set)")
}, getBizMonitorContext, getClient, t),
tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", {
batch_id: { type: "string", required: true },
run_id: str4("Optional evaluate run id")
}, getBizMonitorBoard, getClient, t),
tool2("netx__listBizMonitorReds", "Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).", {
project_id: { type: "string", required: true },
status: str4("open|carried|resolved or empty=all"),
limit: num4()
}, listBizMonitorReds, getClient, t),
tool2("netx__getBizMonitorDiffs", "Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.", {
run_id: { type: "string", required: true },
metric_id: str4(),
sheet_id: str4(),
verdict: str4(),
color: str4("e.g. red"),
q: str4(),
limit: num4(),
offset: num4()
}, getBizMonitorDiffs, getClient, t),
tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", {
batch_id: { type: "string", required: true }
}, getBizCollectBatch, getClient, t),
tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", {
batch_id: { type: "string", required: true },
command_id: { type: "string", required: true }
}, getBizCollectCommandRaw, getClient, t),
tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t),
tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", {
view_id: { type: "string", required: true },

View file

@ -1,8 +1,7 @@
// src/netx/capability-groups.ts
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
ops: Object.freeze({ inPreset: true, public: false }),
topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false })
topology: Object.freeze({ inPreset: false, public: false })
});
var TOOLS_BY_GROUP = Object.freeze({
ops: Object.freeze([
@ -38,27 +37,15 @@ var TOOLS_BY_GROUP = Object.freeze({
"netx__suggestSinkHubs",
"netx__analyzeTopologyViewLayout",
"netx__sinkTopologyDualUnits"
]),
bizMonitor: Object.freeze([
"netx__listBizMonitors",
"netx__listBizMonitorBatches",
"netx__getBizMonitorContext",
"netx__getBizMonitorBoard",
"netx__listBizMonitorReds",
"netx__getBizMonitorDiffs",
"netx__getBizCollectBatch",
"netx__getBizCollectCommandRaw"
])
});
var SKILL_DIR_BY_GROUP = Object.freeze({
ops: "ops",
topology: "topology",
bizMonitor: "biz-monitor"
topology: "topology"
});
var CAPABILITY_GROUP_IDS = Object.freeze([
"ops",
"topology",
"bizMonitor"
"topology"
]);
function groupsForPlane(groups, plane, only) {
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS;
@ -73,12 +60,12 @@ function groupsForced(only) {
return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id));
}
function toolNamesForGroups(groupIds) {
const out = new Set;
const names = new Set;
for (const id of groupIds) {
for (const name of TOOLS_BY_GROUP[id] ?? [])
out.add(name);
for (const tool of TOOLS_BY_GROUP[id])
names.add(tool);
}
return out;
return names;
}
// src/netx/group-skills.ts
@ -234,16 +221,7 @@ import { join as join2, relative, resolve, sep } from "node:path";
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
"memories",
"drafts",
"suggestions",
"refs"
]);
var REF_AREAS = Object.freeze([
"inventory",
"devices",
"topology",
"business",
"commands",
"handbooks"
"suggestions"
]);
var MEMORY_BUCKET_DIR = Object.freeze({
note: "日常笔记",
@ -303,10 +281,6 @@ function suggestionFileName(opts) {
const date = normalizeIsoDate(opts.date, opts.now);
return `${date}-${sanitizeSlug(opts.slug)}.md`;
}
function refFileName(slug) {
const base = sanitizeSlug(slug, "note");
return base.toLowerCase().endsWith(".md") ? base : `${base}.md`;
}
function resolveWritableLocalPath(localRoot, ...segments) {
if (segments.some((s) => s.includes("\x00"))) {
throw new Error("path segment contains NUL");
@ -380,19 +354,6 @@ function draftDir(domain) {
return "drafts";
return join2("drafts", sanitizeSlug(d, "misc"));
}
function refDir(area, device) {
if (!REF_AREAS.includes(area)) {
throw new Error(`invalid refs area: ${area}`);
}
if (area === "devices") {
const host = sanitizeSlug(device?.trim() || "", "");
if (!host) {
throw new Error("refs/devices requires device (host_name)");
}
return join2("refs", "devices", host);
}
return join2("refs", area);
}
// src/netx/kb-context-skill.ts
var SKILL_NAME = "kb-context";
@ -403,29 +364,18 @@ function skillBody(snapshot) {
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
const localGuide = localRoot ? [
"",
"### Dual plane (mandatory)",
"",
"- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
`- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
"- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
" `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /",
" `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.",
"",
"### refs — site product knowledge (evolve every task)",
"- On any NE: **read** `refs/devices/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
"- `area=devices` requires `device=<host_name>` (never UUID); primary file slug=`PROFILE`.",
"- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.",
"- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).",
"",
"### memories — write immediately when valuable",
"- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.",
"- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`."
"**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,",
"`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.",
"They jail writes to `memories/` / `drafts/` / `suggestions/` only.",
"Do **not** use workspace Write/bash for KB paths (sandbox).",
"`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;",
"file boundary issues as `suggestions/` instead."
] : [
"",
"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: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).",
content: [
"## Knowledge base (configured)",
"",
@ -593,25 +543,6 @@ function createSuggestion(snapshot, args) {
relativePath: target.relativePath
};
}
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) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
}
mkdirSync(dirname2(target.absolutePath), { recursive: true });
const existed = existsSync3(target.absolutePath);
writeFileSync(target.absolutePath, args.body.endsWith(`
`) ? args.body : `${args.body}
`, "utf8");
return {
ok: true,
action: existed ? "updated" : "created",
absolutePath: target.absolutePath,
relativePath: target.relativePath
};
}
function updateLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path);
@ -737,41 +668,10 @@ function watchKbContext(listener) {
};
}
// src/netx/json-safe.ts
function toLosslessJson(value) {
try {
const text = JSON.stringify(value);
if (text === undefined) {
return { ok: false, error: "tool_result_not_json", detail: "undefined_root" };
}
const parsed = JSON.parse(text);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
return { ok: true, data: { value: parsed } };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) };
}
}
function omitUndefined(record) {
const out = {};
for (const [key, value] of Object.entries(record)) {
if (value !== undefined)
out[key] = value;
}
return out;
}
// src/netx/kb-local-tools.ts
var str = (description) => ({ type: "string", ...description ? { description } : {} });
var bool = (description) => ({ type: "boolean", ...description ? { description } : {} });
var num = (description) => ({ type: "number", ...description ? { description } : {} });
var reqStr = (description) => ({
type: "string",
required: true,
...description ? { description } : {}
});
function renderJson(_args, value) {
return [{ type: "text", text: JSON.stringify(value, null, 0) }];
}
@ -792,7 +692,7 @@ function tool(name, description, parameters, execute) {
isConcurrencySafe: () => false,
async execute(args) {
try {
return toLosslessJson(await execute(args));
return await execute(args);
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error));
}
@ -808,16 +708,20 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
return () => {};
const entries = [
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",
required: true,
enum: ["note", "rca_review", "ai_trace"],
description: "Memory subdirectory"
},
slug: reqStr("Short filename stem (no path separators)"),
body: reqStr("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
type: "object",
additionalProperties: false,
required: ["bucket", "slug", "body"],
properties: {
bucket: {
type: "string",
enum: ["note", "rca_review", "ai_trace"],
description: "Memory subdirectory"
},
slug: str("Short filename stem (no path separators)"),
body: str("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
}
}, (args) => createMemory(liveSnapshot(), {
bucket: args.bucket,
slug: String(args.slug ?? ""),
@ -825,12 +729,17 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", {
slug: reqStr("Case stem (DRAFT- prefix added if missing)"),
body: reqStr("Markdown body (RCA-ish draft)"),
domain: str("Optional fault-domain subfolder under drafts/"),
date: str("Optional event date YYYY-MM-DD or YYYYMMDD"),
overwrite: bool("Replace if the target file already exists")
tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", {
type: "object",
additionalProperties: false,
required: ["slug", "body"],
properties: {
slug: str("Case stem (DRAFT- prefix added if missing)"),
body: str("Markdown body (RCA-ish draft)"),
domain: str("Optional fault-domain subfolder under drafts/"),
date: str("Optional event date YYYY-MM-DD or YYYYMMDD"),
overwrite: bool("Replace if the target file already exists")
}
}, (args) => createDraft(liveSnapshot(), {
slug: String(args.slug ?? ""),
body: String(args.body ?? ""),
@ -838,17 +747,21 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", {
kind: {
type: "string",
required: true,
enum: ["theory", "improvement"],
description: "theory = knowledge fix; improvement = tools/skills/process"
},
slug: reqStr("Short filename stem"),
body: reqStr("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", {
type: "object",
additionalProperties: false,
required: ["kind", "slug", "body"],
properties: {
kind: {
type: "string",
enum: ["theory", "improvement"],
description: "theory = knowledge fix; improvement = tools/skills/process"
},
slug: str("Short filename stem"),
body: str("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
}
}, (args) => createSuggestion(liveSnapshot(), {
kind: args.kind,
slug: String(args.slug ?? ""),
@ -856,43 +769,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", {
area: {
type: "string",
required: true,
enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"],
description: "refs/ subtree"
},
slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"),
body: reqStr("Full markdown body"),
device: str("Required when area=devices — host_name (never UUID)"),
overwrite: bool("Replace if the target file already exists")
}, (args) => createRef(liveSnapshot(), {
area: args.area,
slug: String(args.slug ?? ""),
body: String(args.body ?? ""),
device: args.device != null ? String(args.device) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", {
path: reqStr("Absolute path or path relative to KB local root"),
body: reqStr("New full markdown body")
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", {
type: "object",
additionalProperties: false,
required: ["path", "body"],
properties: {
path: str("Absolute path or path relative to KB local root"),
body: str("New full markdown body")
}
}, (args) => updateLocalFile(liveSnapshot(), {
path: String(args.path ?? ""),
body: String(args.body ?? "")
})),
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, {
path: reqStr("Absolute path or path relative to KB local root")
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, {
type: "object",
additionalProperties: false,
required: ["path"],
properties: {
path: str("Absolute path or path relative to KB local root")
}
}, (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.", {
root: {
type: "string",
enum: ["memories", "drafts", "suggestions", "refs", "all"],
description: "Subtree to list (default all writable)"
},
limit: num("Max entries 1–200 (default 50)")
tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", {
type: "object",
additionalProperties: false,
properties: {
root: {
type: "string",
enum: ["memories", "drafts", "suggestions", "all"],
description: "Subtree to list (default all writable)"
},
limit: num("Max entries 1–200 (default 50)")
}
}, (args) => listLocalFiles(liveSnapshot(), {
root: args.root ?? "all",
limit: typeof args.limit === "number" ? args.limit : undefined
@ -1497,347 +1406,6 @@ async function findTopologyPaths(client, args, signal) {
body.to_managed_ne_id = toMid;
return client.post("/v1/topology/fabric/paths", body, signal, 30000);
}
function taskBriefNe(brief) {
const rec = asRecord(brief);
const name = typeof rec.ne_name === "string" ? rec.ne_name.trim() : "";
const ip = typeof rec.ne_ip === "string" ? rec.ne_ip.trim() : "";
if (name && ip)
return `${name} (${ip})`;
return name || ip;
}
function slimMigrationProject(row) {
const mt = asRecord(row.monitor_template);
return omitUndefined({
id: row.id,
name: row.name,
status: row.status,
note: row.note,
monitor_template_id: row.monitor_template_id,
monitor_template_name: typeof mt.name === "string" ? mt.name : "",
old_task_id: row.old_task_id,
new_task_id: row.new_task_id,
old_hf_task_id: row.old_hf_task_id,
new_hf_task_id: row.new_hf_task_id,
old_ne: taskBriefNe(row.old_task),
new_ne: taskBriefNe(row.new_task),
hf_interval_sec: row.hf_interval_sec,
hf_start_at: row.hf_start_at,
hf_end_at: row.hf_end_at,
updated_at: row.updated_at
});
}
function matchesBizMonitorQ(row, q) {
if (!q)
return true;
const hay = [
row.id,
row.name,
row.note,
row.status,
row.ne_name,
row.ne_ip,
row.ne_id,
row.old_ne,
row.new_ne,
row.monitor_template_name,
row.purpose,
row.vendor
].map((v) => typeof v === "string" || typeof v === "number" ? String(v).toLowerCase() : "").join(" ");
return hay.includes(q);
}
async function listBizMonitors(client, args, signal) {
const kindRaw = str2(args, "kind", "all").trim().toLowerCase() || "all";
const kind = kindRaw === "project" ? "projects" : kindRaw === "task" ? "tasks" : kindRaw;
if (kind !== "all" && kind !== "projects" && kind !== "tasks") {
return { ok: false, error: "kind_invalid", detail: "kind must be all|projects|tasks" };
}
const purpose = str2(args, "purpose").trim();
const status = str2(args, "status").trim().toLowerCase();
const q = str2(args, "q").trim().toLowerCase();
const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500);
const wantProjects = kind === "all" || kind === "projects";
const wantTasks = kind === "all" || kind === "tasks";
const fetches = [];
if (wantProjects)
fetches.push(client.get("/v1/biz-migration/projects", {}, signal));
else
fetches.push(Promise.resolve({ ok: true, data: { items: [] } }));
if (wantTasks) {
const params = {};
if (purpose)
params.purpose = purpose;
fetches.push(client.get("/v1/biz-state/tasks", params, signal));
} else {
fetches.push(Promise.resolve({ ok: true, data: { items: [] } }));
}
const [projRes, taskRes] = await Promise.all(fetches);
if (wantProjects && projRes.ok === false)
return projRes;
if (wantTasks && taskRes.ok === false)
return taskRes;
const projItems = Array.isArray(asRecord(projRes.data).items) ? asRecord(projRes.data).items : [];
const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : [];
let projects = projItems.map((row) => slimMigrationProject(asRecord(row))).filter((row) => {
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return matchesBizMonitorQ(row, q);
});
let tasks = taskItems.map((row) => asRecord(row)).filter((row) => {
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return matchesBizMonitorQ(row, q);
});
const projectsTotal = projects.length;
const tasksTotal = tasks.length;
projects = projects.slice(0, limit);
tasks = tasks.slice(0, limit);
return {
ok: true,
data: omitUndefined({
kind,
projects: wantProjects ? projects : undefined,
tasks: wantTasks ? tasks : undefined,
counts: {
projects: wantProjects ? projectsTotal : 0,
tasks: wantTasks ? tasksTotal : 0,
projects_returned: wantProjects ? projects.length : 0,
tasks_returned: wantTasks ? tasks.length : 0
},
next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id."
})
};
}
function slimCutoverBatch(row) {
return omitUndefined({
id: row.id,
project_id: row.project_id,
batch_label: row.batch_label,
status: row.status,
accept_status: row.accept_status,
accept_run_id: row.accept_run_id,
started_at: row.started_at,
ended_at: row.ended_at,
note: row.note,
created_at: row.created_at,
updated_at: row.updated_at
});
}
function slimCollectBatch(row, taskId = "") {
return omitUndefined({
id: row.id,
task_id: row.task_id || taskId || undefined,
status: row.status,
ne_name: row.ne_name,
command_count: row.command_count,
row_count: row.row_count,
is_baseline: row.is_baseline,
protected: row.protected,
message: row.message,
started_at: row.started_at,
ended_at: row.ended_at
});
}
function slimEvalRun(row) {
const summary = asRecord(row.summary);
const progress = asRecord(summary.progress);
return omitUndefined({
id: row.id,
batch_id: row.batch_id,
purpose: row.purpose,
old_batch_id: row.old_batch_id,
new_batch_id: row.new_batch_id,
created_at: row.created_at,
anomaly: summary.anomaly,
progress_ok: progress.ok ?? summary.progress_ok,
progress_total: progress.total ?? summary.progress_total
});
}
async function listBizMonitorBatches(client, args, signal) {
const projectId = str2(args, "project_id").trim();
const taskId = str2(args, "task_id").trim();
const batchId = str2(args, "batch_id").trim();
const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto";
const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1;
const status = str2(args, "status").trim().toLowerCase();
const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200);
const runsLimit = clampInt(num2(args, "runs_limit") ?? 10, 10, 1, 50);
let kind = kindRaw;
if (kind === "auto") {
if (batchId && !projectId && !taskId)
kind = "runs";
else if (projectId && !taskId)
kind = "cutover";
else if (taskId && !projectId)
kind = "collect";
else if (projectId && taskId)
kind = "all";
else if (batchId)
kind = "runs";
else {
return {
ok: false,
error: "project_id_or_task_id_or_batch_id_required",
detail: "Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs)."
};
}
}
if (!["auto", "all", "cutover", "collect", "runs"].includes(kind)) {
return { ok: false, error: "kind_invalid", detail: "kind must be auto|all|cutover|collect|runs" };
}
const wantCutover = kind === "all" || kind === "cutover";
const wantCollect = kind === "all" || kind === "collect";
const wantRuns = kind === "runs" || includeRuns && (wantCutover || Boolean(batchId));
if (wantCutover && !projectId) {
return { ok: false, error: "project_id_required", detail: "kind=cutover|all needs project_id" };
}
if (wantCollect && !taskId) {
return { ok: false, error: "task_id_required", detail: "kind=collect|all needs task_id" };
}
if (kind === "runs" && !batchId) {
return { ok: false, error: "batch_id_required", detail: "kind=runs needs cutover batch_id" };
}
let cutoverBatches = [];
let collectBatches = [];
let runs = [];
if (wantCutover) {
const res = await client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, {}, signal);
if (res.ok === false)
return res;
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
cutoverBatches = items.map((row) => slimCutoverBatch(asRecord(row))).filter((row) => {
if (batchId && String(row.id || "") !== batchId)
return false;
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return true;
}).slice(0, limit);
}
if (wantCollect) {
const res = await client.get(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`, { limit }, signal);
if (res.ok === false)
return res;
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
collectBatches = items.map((row) => slimCollectBatch(asRecord(row), taskId)).filter((row) => {
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return true;
}).slice(0, limit);
}
if (wantRuns) {
const runBatchIds = kind === "runs" ? [batchId] : cutoverBatches.map((b) => String(b.id || "").trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0);
if (kind === "runs" && batchId) {
const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`, { limit: runsLimit }, signal);
if (res.ok === false)
return res;
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit);
} else if (includeRuns && runBatchIds.length > 0) {
const settled = await Promise.all(runBatchIds.map(async (id) => {
const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`, { limit: runsLimit }, signal);
if (res.ok === false)
return { batch_id: id, error: res, items: [] };
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
return {
batch_id: id,
items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit)
};
}));
for (const block of settled) {
if ("error" in block && block.error) {
const failed = cutoverBatches.find((b) => b.id === block.batch_id);
if (failed)
failed.runs_error = asRecord(block.error).error || "runs_fetch_failed";
continue;
}
const target = cutoverBatches.find((b) => b.id === block.batch_id);
if (target)
target.runs = block.items;
for (const r of block.items)
runs.push(r);
}
}
}
return {
ok: true,
data: omitUndefined({
kind,
project_id: projectId || undefined,
task_id: taskId || undefined,
batch_id: batchId || undefined,
cutover_batches: wantCutover ? cutoverBatches : undefined,
collect_batches: wantCollect ? collectBatches : undefined,
runs: kind === "runs" ? runs : undefined,
counts: {
cutover_batches: cutoverBatches.length,
collect_batches: collectBatches.length,
runs: runs.length
},
next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)."
})
};
}
async function getBizMonitorContext(client, args, signal) {
const projectId = str2(args, "project_id").trim();
const taskId = str2(args, "task_id").trim();
if (!projectId && !taskId) {
return { ok: false, error: "project_id_or_task_id_required" };
}
const params = {};
if (projectId)
params.project_id = projectId;
if (taskId)
params.task_id = taskId;
return client.get("/v1/biz-migration/monitor-context", params, signal);
}
async function getBizMonitorBoard(client, args, signal) {
const batchId = str2(args, "batch_id").trim();
if (!batchId)
return { ok: false, error: "batch_id_required" };
const params = {};
const runId = str2(args, "run_id").trim();
if (runId)
params.run_id = runId;
return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal);
}
async function listBizMonitorReds(client, args, signal) {
const projectId = str2(args, "project_id").trim();
if (!projectId)
return { ok: false, error: "project_id_required" };
const params = {};
putStr(params, args, ["status"]);
const limit = num2(args, "limit");
if (limit !== undefined)
params.limit = clampInt(limit, 200, 1, 500);
return client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`, params, signal);
}
async function getBizMonitorDiffs(client, args, signal) {
const runId = str2(args, "run_id").trim();
if (!runId)
return { ok: false, error: "run_id_required" };
const params = {};
putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]);
const limit = num2(args, "limit");
if (limit !== undefined)
params.limit = clampInt(limit, 200, 1, 500);
const offset = num2(args, "offset");
if (offset !== undefined)
params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER);
return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal);
}
async function getBizCollectBatch(client, args, signal) {
const batchId = str2(args, "batch_id").trim();
if (!batchId)
return { ok: false, error: "batch_id_required" };
return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal);
}
async function getBizCollectCommandRaw(client, args, signal) {
const batchId = str2(args, "batch_id").trim();
const commandId = str2(args, "command_id").trim();
if (!batchId || !commandId) {
return { ok: false, error: "batch_id_and_command_id_required" };
}
return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, {}, signal);
}
// src/netx/topology-handlers.ts
function asRecord2(value) {
@ -2517,11 +2085,10 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
isConcurrencySafe: () => true,
async execute(args, exec) {
const result = await handler(getClient(), args, exec.signal);
const safe = toLosslessJson(result);
if (result.ok === false || safe.ok === false) {
throw new Error(JSON.stringify(safe));
if (result.ok === false) {
throw new Error(JSON.stringify(result));
}
return safe;
return result;
}
});
}
@ -2663,53 +2230,6 @@ function registerNetxTools(ctx, connection, options) {
layer: str4(),
detail: str4("summary | full")
}, findTopologyPaths, getClient, t),
tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.", {
kind: str4("all | projects | tasks (default all)"),
purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"),
status: str4("Filter by status (e.g. active / paused)"),
q: str4("Keyword on name / ne_name / note / id"),
limit: num4("Max rows per list (default 100, max 500)")
}, listBizMonitors, getClient, t),
tool2("netx__listBizMonitorBatches", "List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.", {
kind: str4("auto | all | cutover | collect | runs (default auto)"),
project_id: str4("Cutover project id → cutover_batches"),
task_id: str4("biz_state task id → collect_batches"),
batch_id: str4("Cutover batch id (filter or kind=runs)"),
include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"),
status: str4("Filter batch status"),
limit: num4("Max batches (default 50)"),
runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)")
}, listBizMonitorBatches, getClient, t),
tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", {
project_id: str4("Cutover / biz-migration project id"),
task_id: str4("biz_state task id (optional if project_id set)")
}, getBizMonitorContext, getClient, t),
tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", {
batch_id: { type: "string", required: true },
run_id: str4("Optional evaluate run id")
}, getBizMonitorBoard, getClient, t),
tool2("netx__listBizMonitorReds", "Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).", {
project_id: { type: "string", required: true },
status: str4("open|carried|resolved or empty=all"),
limit: num4()
}, listBizMonitorReds, getClient, t),
tool2("netx__getBizMonitorDiffs", "Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.", {
run_id: { type: "string", required: true },
metric_id: str4(),
sheet_id: str4(),
verdict: str4(),
color: str4("e.g. red"),
q: str4(),
limit: num4(),
offset: num4()
}, getBizMonitorDiffs, getClient, t),
tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", {
batch_id: { type: "string", required: true }
}, getBizCollectBatch, getClient, t),
tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", {
batch_id: { type: "string", required: true },
command_id: { type: "string", required: true }
}, getBizCollectCommandRaw, getClient, t),
tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t),
tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", {
view_id: { type: "string", required: true },

View file

@ -1,8 +1,7 @@
// src/netx/capability-groups.ts
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
ops: Object.freeze({ inPreset: true, public: false }),
topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false })
topology: Object.freeze({ inPreset: false, public: false })
});
var TOOLS_BY_GROUP = Object.freeze({
ops: Object.freeze([
@ -38,27 +37,15 @@ var TOOLS_BY_GROUP = Object.freeze({
"netx__suggestSinkHubs",
"netx__analyzeTopologyViewLayout",
"netx__sinkTopologyDualUnits"
]),
bizMonitor: Object.freeze([
"netx__listBizMonitors",
"netx__listBizMonitorBatches",
"netx__getBizMonitorContext",
"netx__getBizMonitorBoard",
"netx__listBizMonitorReds",
"netx__getBizMonitorDiffs",
"netx__getBizCollectBatch",
"netx__getBizCollectCommandRaw"
])
});
var SKILL_DIR_BY_GROUP = Object.freeze({
ops: "ops",
topology: "topology",
bizMonitor: "biz-monitor"
topology: "topology"
});
var CAPABILITY_GROUP_IDS = Object.freeze([
"ops",
"topology",
"bizMonitor"
"topology"
]);
function groupsForPlane(groups, plane, only) {
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS;
@ -73,12 +60,12 @@ function groupsForced(only) {
return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id));
}
function toolNamesForGroups(groupIds) {
const out = new Set;
const names = new Set;
for (const id of groupIds) {
for (const name of TOOLS_BY_GROUP[id] ?? [])
out.add(name);
for (const tool of TOOLS_BY_GROUP[id])
names.add(tool);
}
return out;
return names;
}
// src/netx/group-skills.ts
@ -234,16 +221,7 @@ import { join as join2, relative, resolve, sep } from "node:path";
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
"memories",
"drafts",
"suggestions",
"refs"
]);
var REF_AREAS = Object.freeze([
"inventory",
"devices",
"topology",
"business",
"commands",
"handbooks"
"suggestions"
]);
var MEMORY_BUCKET_DIR = Object.freeze({
note: "日常笔记",
@ -303,10 +281,6 @@ function suggestionFileName(opts) {
const date = normalizeIsoDate(opts.date, opts.now);
return `${date}-${sanitizeSlug(opts.slug)}.md`;
}
function refFileName(slug) {
const base = sanitizeSlug(slug, "note");
return base.toLowerCase().endsWith(".md") ? base : `${base}.md`;
}
function resolveWritableLocalPath(localRoot, ...segments) {
if (segments.some((s) => s.includes("\x00"))) {
throw new Error("path segment contains NUL");
@ -380,19 +354,6 @@ function draftDir(domain) {
return "drafts";
return join2("drafts", sanitizeSlug(d, "misc"));
}
function refDir(area, device) {
if (!REF_AREAS.includes(area)) {
throw new Error(`invalid refs area: ${area}`);
}
if (area === "devices") {
const host = sanitizeSlug(device?.trim() || "", "");
if (!host) {
throw new Error("refs/devices requires device (host_name)");
}
return join2("refs", "devices", host);
}
return join2("refs", area);
}
// src/netx/kb-context-skill.ts
var SKILL_NAME = "kb-context";
@ -403,29 +364,18 @@ function skillBody(snapshot) {
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
const localGuide = localRoot ? [
"",
"### Dual plane (mandatory)",
"",
"- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
`- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
"- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
" `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /",
" `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.",
"",
"### refs — site product knowledge (evolve every task)",
"- On any NE: **read** `refs/devices/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
"- `area=devices` requires `device=<host_name>` (never UUID); primary file slug=`PROFILE`.",
"- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.",
"- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).",
"",
"### memories — write immediately when valuable",
"- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.",
"- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`."
"**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,",
"`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.",
"They jail writes to `memories/` / `drafts/` / `suggestions/` only.",
"Do **not** use workspace Write/bash for KB paths (sandbox).",
"`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;",
"file boundary issues as `suggestions/` instead."
] : [
"",
"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: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).",
content: [
"## Knowledge base (configured)",
"",
@ -593,25 +543,6 @@ function createSuggestion(snapshot, args) {
relativePath: target.relativePath
};
}
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) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
}
mkdirSync(dirname2(target.absolutePath), { recursive: true });
const existed = existsSync3(target.absolutePath);
writeFileSync(target.absolutePath, args.body.endsWith(`
`) ? args.body : `${args.body}
`, "utf8");
return {
ok: true,
action: existed ? "updated" : "created",
absolutePath: target.absolutePath,
relativePath: target.relativePath
};
}
function updateLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path);
@ -737,41 +668,10 @@ function watchKbContext(listener) {
};
}
// src/netx/json-safe.ts
function toLosslessJson(value) {
try {
const text = JSON.stringify(value);
if (text === undefined) {
return { ok: false, error: "tool_result_not_json", detail: "undefined_root" };
}
const parsed = JSON.parse(text);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
return { ok: true, data: { value: parsed } };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) };
}
}
function omitUndefined(record) {
const out = {};
for (const [key, value] of Object.entries(record)) {
if (value !== undefined)
out[key] = value;
}
return out;
}
// src/netx/kb-local-tools.ts
var str = (description) => ({ type: "string", ...description ? { description } : {} });
var bool = (description) => ({ type: "boolean", ...description ? { description } : {} });
var num = (description) => ({ type: "number", ...description ? { description } : {} });
var reqStr = (description) => ({
type: "string",
required: true,
...description ? { description } : {}
});
function renderJson(_args, value) {
return [{ type: "text", text: JSON.stringify(value, null, 0) }];
}
@ -792,7 +692,7 @@ function tool(name, description, parameters, execute) {
isConcurrencySafe: () => false,
async execute(args) {
try {
return toLosslessJson(await execute(args));
return await execute(args);
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error));
}
@ -808,16 +708,20 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
return () => {};
const entries = [
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",
required: true,
enum: ["note", "rca_review", "ai_trace"],
description: "Memory subdirectory"
},
slug: reqStr("Short filename stem (no path separators)"),
body: reqStr("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
type: "object",
additionalProperties: false,
required: ["bucket", "slug", "body"],
properties: {
bucket: {
type: "string",
enum: ["note", "rca_review", "ai_trace"],
description: "Memory subdirectory"
},
slug: str("Short filename stem (no path separators)"),
body: str("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
}
}, (args) => createMemory(liveSnapshot(), {
bucket: args.bucket,
slug: String(args.slug ?? ""),
@ -825,12 +729,17 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", {
slug: reqStr("Case stem (DRAFT- prefix added if missing)"),
body: reqStr("Markdown body (RCA-ish draft)"),
domain: str("Optional fault-domain subfolder under drafts/"),
date: str("Optional event date YYYY-MM-DD or YYYYMMDD"),
overwrite: bool("Replace if the target file already exists")
tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", {
type: "object",
additionalProperties: false,
required: ["slug", "body"],
properties: {
slug: str("Case stem (DRAFT- prefix added if missing)"),
body: str("Markdown body (RCA-ish draft)"),
domain: str("Optional fault-domain subfolder under drafts/"),
date: str("Optional event date YYYY-MM-DD or YYYYMMDD"),
overwrite: bool("Replace if the target file already exists")
}
}, (args) => createDraft(liveSnapshot(), {
slug: String(args.slug ?? ""),
body: String(args.body ?? ""),
@ -838,17 +747,21 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", {
kind: {
type: "string",
required: true,
enum: ["theory", "improvement"],
description: "theory = knowledge fix; improvement = tools/skills/process"
},
slug: reqStr("Short filename stem"),
body: reqStr("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", {
type: "object",
additionalProperties: false,
required: ["kind", "slug", "body"],
properties: {
kind: {
type: "string",
enum: ["theory", "improvement"],
description: "theory = knowledge fix; improvement = tools/skills/process"
},
slug: str("Short filename stem"),
body: str("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
}
}, (args) => createSuggestion(liveSnapshot(), {
kind: args.kind,
slug: String(args.slug ?? ""),
@ -856,43 +769,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", {
area: {
type: "string",
required: true,
enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"],
description: "refs/ subtree"
},
slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"),
body: reqStr("Full markdown body"),
device: str("Required when area=devices — host_name (never UUID)"),
overwrite: bool("Replace if the target file already exists")
}, (args) => createRef(liveSnapshot(), {
area: args.area,
slug: String(args.slug ?? ""),
body: String(args.body ?? ""),
device: args.device != null ? String(args.device) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", {
path: reqStr("Absolute path or path relative to KB local root"),
body: reqStr("New full markdown body")
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", {
type: "object",
additionalProperties: false,
required: ["path", "body"],
properties: {
path: str("Absolute path or path relative to KB local root"),
body: str("New full markdown body")
}
}, (args) => updateLocalFile(liveSnapshot(), {
path: String(args.path ?? ""),
body: String(args.body ?? "")
})),
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, {
path: reqStr("Absolute path or path relative to KB local root")
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, {
type: "object",
additionalProperties: false,
required: ["path"],
properties: {
path: str("Absolute path or path relative to KB local root")
}
}, (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.", {
root: {
type: "string",
enum: ["memories", "drafts", "suggestions", "refs", "all"],
description: "Subtree to list (default all writable)"
},
limit: num("Max entries 1–200 (default 50)")
tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", {
type: "object",
additionalProperties: false,
properties: {
root: {
type: "string",
enum: ["memories", "drafts", "suggestions", "all"],
description: "Subtree to list (default all writable)"
},
limit: num("Max entries 1–200 (default 50)")
}
}, (args) => listLocalFiles(liveSnapshot(), {
root: args.root ?? "all",
limit: typeof args.limit === "number" ? args.limit : undefined
@ -1497,347 +1406,6 @@ async function findTopologyPaths(client, args, signal) {
body.to_managed_ne_id = toMid;
return client.post("/v1/topology/fabric/paths", body, signal, 30000);
}
function taskBriefNe(brief) {
const rec = asRecord(brief);
const name = typeof rec.ne_name === "string" ? rec.ne_name.trim() : "";
const ip = typeof rec.ne_ip === "string" ? rec.ne_ip.trim() : "";
if (name && ip)
return `${name} (${ip})`;
return name || ip;
}
function slimMigrationProject(row) {
const mt = asRecord(row.monitor_template);
return omitUndefined({
id: row.id,
name: row.name,
status: row.status,
note: row.note,
monitor_template_id: row.monitor_template_id,
monitor_template_name: typeof mt.name === "string" ? mt.name : "",
old_task_id: row.old_task_id,
new_task_id: row.new_task_id,
old_hf_task_id: row.old_hf_task_id,
new_hf_task_id: row.new_hf_task_id,
old_ne: taskBriefNe(row.old_task),
new_ne: taskBriefNe(row.new_task),
hf_interval_sec: row.hf_interval_sec,
hf_start_at: row.hf_start_at,
hf_end_at: row.hf_end_at,
updated_at: row.updated_at
});
}
function matchesBizMonitorQ(row, q) {
if (!q)
return true;
const hay = [
row.id,
row.name,
row.note,
row.status,
row.ne_name,
row.ne_ip,
row.ne_id,
row.old_ne,
row.new_ne,
row.monitor_template_name,
row.purpose,
row.vendor
].map((v) => typeof v === "string" || typeof v === "number" ? String(v).toLowerCase() : "").join(" ");
return hay.includes(q);
}
async function listBizMonitors(client, args, signal) {
const kindRaw = str2(args, "kind", "all").trim().toLowerCase() || "all";
const kind = kindRaw === "project" ? "projects" : kindRaw === "task" ? "tasks" : kindRaw;
if (kind !== "all" && kind !== "projects" && kind !== "tasks") {
return { ok: false, error: "kind_invalid", detail: "kind must be all|projects|tasks" };
}
const purpose = str2(args, "purpose").trim();
const status = str2(args, "status").trim().toLowerCase();
const q = str2(args, "q").trim().toLowerCase();
const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500);
const wantProjects = kind === "all" || kind === "projects";
const wantTasks = kind === "all" || kind === "tasks";
const fetches = [];
if (wantProjects)
fetches.push(client.get("/v1/biz-migration/projects", {}, signal));
else
fetches.push(Promise.resolve({ ok: true, data: { items: [] } }));
if (wantTasks) {
const params = {};
if (purpose)
params.purpose = purpose;
fetches.push(client.get("/v1/biz-state/tasks", params, signal));
} else {
fetches.push(Promise.resolve({ ok: true, data: { items: [] } }));
}
const [projRes, taskRes] = await Promise.all(fetches);
if (wantProjects && projRes.ok === false)
return projRes;
if (wantTasks && taskRes.ok === false)
return taskRes;
const projItems = Array.isArray(asRecord(projRes.data).items) ? asRecord(projRes.data).items : [];
const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : [];
let projects = projItems.map((row) => slimMigrationProject(asRecord(row))).filter((row) => {
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return matchesBizMonitorQ(row, q);
});
let tasks = taskItems.map((row) => asRecord(row)).filter((row) => {
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return matchesBizMonitorQ(row, q);
});
const projectsTotal = projects.length;
const tasksTotal = tasks.length;
projects = projects.slice(0, limit);
tasks = tasks.slice(0, limit);
return {
ok: true,
data: omitUndefined({
kind,
projects: wantProjects ? projects : undefined,
tasks: wantTasks ? tasks : undefined,
counts: {
projects: wantProjects ? projectsTotal : 0,
tasks: wantTasks ? tasksTotal : 0,
projects_returned: wantProjects ? projects.length : 0,
tasks_returned: wantTasks ? tasks.length : 0
},
next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id."
})
};
}
function slimCutoverBatch(row) {
return omitUndefined({
id: row.id,
project_id: row.project_id,
batch_label: row.batch_label,
status: row.status,
accept_status: row.accept_status,
accept_run_id: row.accept_run_id,
started_at: row.started_at,
ended_at: row.ended_at,
note: row.note,
created_at: row.created_at,
updated_at: row.updated_at
});
}
function slimCollectBatch(row, taskId = "") {
return omitUndefined({
id: row.id,
task_id: row.task_id || taskId || undefined,
status: row.status,
ne_name: row.ne_name,
command_count: row.command_count,
row_count: row.row_count,
is_baseline: row.is_baseline,
protected: row.protected,
message: row.message,
started_at: row.started_at,
ended_at: row.ended_at
});
}
function slimEvalRun(row) {
const summary = asRecord(row.summary);
const progress = asRecord(summary.progress);
return omitUndefined({
id: row.id,
batch_id: row.batch_id,
purpose: row.purpose,
old_batch_id: row.old_batch_id,
new_batch_id: row.new_batch_id,
created_at: row.created_at,
anomaly: summary.anomaly,
progress_ok: progress.ok ?? summary.progress_ok,
progress_total: progress.total ?? summary.progress_total
});
}
async function listBizMonitorBatches(client, args, signal) {
const projectId = str2(args, "project_id").trim();
const taskId = str2(args, "task_id").trim();
const batchId = str2(args, "batch_id").trim();
const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto";
const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1;
const status = str2(args, "status").trim().toLowerCase();
const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200);
const runsLimit = clampInt(num2(args, "runs_limit") ?? 10, 10, 1, 50);
let kind = kindRaw;
if (kind === "auto") {
if (batchId && !projectId && !taskId)
kind = "runs";
else if (projectId && !taskId)
kind = "cutover";
else if (taskId && !projectId)
kind = "collect";
else if (projectId && taskId)
kind = "all";
else if (batchId)
kind = "runs";
else {
return {
ok: false,
error: "project_id_or_task_id_or_batch_id_required",
detail: "Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs)."
};
}
}
if (!["auto", "all", "cutover", "collect", "runs"].includes(kind)) {
return { ok: false, error: "kind_invalid", detail: "kind must be auto|all|cutover|collect|runs" };
}
const wantCutover = kind === "all" || kind === "cutover";
const wantCollect = kind === "all" || kind === "collect";
const wantRuns = kind === "runs" || includeRuns && (wantCutover || Boolean(batchId));
if (wantCutover && !projectId) {
return { ok: false, error: "project_id_required", detail: "kind=cutover|all needs project_id" };
}
if (wantCollect && !taskId) {
return { ok: false, error: "task_id_required", detail: "kind=collect|all needs task_id" };
}
if (kind === "runs" && !batchId) {
return { ok: false, error: "batch_id_required", detail: "kind=runs needs cutover batch_id" };
}
let cutoverBatches = [];
let collectBatches = [];
let runs = [];
if (wantCutover) {
const res = await client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, {}, signal);
if (res.ok === false)
return res;
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
cutoverBatches = items.map((row) => slimCutoverBatch(asRecord(row))).filter((row) => {
if (batchId && String(row.id || "") !== batchId)
return false;
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return true;
}).slice(0, limit);
}
if (wantCollect) {
const res = await client.get(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`, { limit }, signal);
if (res.ok === false)
return res;
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
collectBatches = items.map((row) => slimCollectBatch(asRecord(row), taskId)).filter((row) => {
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return true;
}).slice(0, limit);
}
if (wantRuns) {
const runBatchIds = kind === "runs" ? [batchId] : cutoverBatches.map((b) => String(b.id || "").trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0);
if (kind === "runs" && batchId) {
const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`, { limit: runsLimit }, signal);
if (res.ok === false)
return res;
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit);
} else if (includeRuns && runBatchIds.length > 0) {
const settled = await Promise.all(runBatchIds.map(async (id) => {
const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`, { limit: runsLimit }, signal);
if (res.ok === false)
return { batch_id: id, error: res, items: [] };
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
return {
batch_id: id,
items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit)
};
}));
for (const block of settled) {
if ("error" in block && block.error) {
const failed = cutoverBatches.find((b) => b.id === block.batch_id);
if (failed)
failed.runs_error = asRecord(block.error).error || "runs_fetch_failed";
continue;
}
const target = cutoverBatches.find((b) => b.id === block.batch_id);
if (target)
target.runs = block.items;
for (const r of block.items)
runs.push(r);
}
}
}
return {
ok: true,
data: omitUndefined({
kind,
project_id: projectId || undefined,
task_id: taskId || undefined,
batch_id: batchId || undefined,
cutover_batches: wantCutover ? cutoverBatches : undefined,
collect_batches: wantCollect ? collectBatches : undefined,
runs: kind === "runs" ? runs : undefined,
counts: {
cutover_batches: cutoverBatches.length,
collect_batches: collectBatches.length,
runs: runs.length
},
next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)."
})
};
}
async function getBizMonitorContext(client, args, signal) {
const projectId = str2(args, "project_id").trim();
const taskId = str2(args, "task_id").trim();
if (!projectId && !taskId) {
return { ok: false, error: "project_id_or_task_id_required" };
}
const params = {};
if (projectId)
params.project_id = projectId;
if (taskId)
params.task_id = taskId;
return client.get("/v1/biz-migration/monitor-context", params, signal);
}
async function getBizMonitorBoard(client, args, signal) {
const batchId = str2(args, "batch_id").trim();
if (!batchId)
return { ok: false, error: "batch_id_required" };
const params = {};
const runId = str2(args, "run_id").trim();
if (runId)
params.run_id = runId;
return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal);
}
async function listBizMonitorReds(client, args, signal) {
const projectId = str2(args, "project_id").trim();
if (!projectId)
return { ok: false, error: "project_id_required" };
const params = {};
putStr(params, args, ["status"]);
const limit = num2(args, "limit");
if (limit !== undefined)
params.limit = clampInt(limit, 200, 1, 500);
return client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`, params, signal);
}
async function getBizMonitorDiffs(client, args, signal) {
const runId = str2(args, "run_id").trim();
if (!runId)
return { ok: false, error: "run_id_required" };
const params = {};
putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]);
const limit = num2(args, "limit");
if (limit !== undefined)
params.limit = clampInt(limit, 200, 1, 500);
const offset = num2(args, "offset");
if (offset !== undefined)
params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER);
return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal);
}
async function getBizCollectBatch(client, args, signal) {
const batchId = str2(args, "batch_id").trim();
if (!batchId)
return { ok: false, error: "batch_id_required" };
return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal);
}
async function getBizCollectCommandRaw(client, args, signal) {
const batchId = str2(args, "batch_id").trim();
const commandId = str2(args, "command_id").trim();
if (!batchId || !commandId) {
return { ok: false, error: "batch_id_and_command_id_required" };
}
return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, {}, signal);
}
// src/netx/topology-handlers.ts
function asRecord2(value) {
@ -2517,11 +2085,10 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
isConcurrencySafe: () => true,
async execute(args, exec) {
const result = await handler(getClient(), args, exec.signal);
const safe = toLosslessJson(result);
if (result.ok === false || safe.ok === false) {
throw new Error(JSON.stringify(safe));
if (result.ok === false) {
throw new Error(JSON.stringify(result));
}
return safe;
return result;
}
});
}
@ -2663,53 +2230,6 @@ function registerNetxTools(ctx, connection, options) {
layer: str4(),
detail: str4("summary | full")
}, findTopologyPaths, getClient, t),
tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.", {
kind: str4("all | projects | tasks (default all)"),
purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"),
status: str4("Filter by status (e.g. active / paused)"),
q: str4("Keyword on name / ne_name / note / id"),
limit: num4("Max rows per list (default 100, max 500)")
}, listBizMonitors, getClient, t),
tool2("netx__listBizMonitorBatches", "List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.", {
kind: str4("auto | all | cutover | collect | runs (default auto)"),
project_id: str4("Cutover project id → cutover_batches"),
task_id: str4("biz_state task id → collect_batches"),
batch_id: str4("Cutover batch id (filter or kind=runs)"),
include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"),
status: str4("Filter batch status"),
limit: num4("Max batches (default 50)"),
runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)")
}, listBizMonitorBatches, getClient, t),
tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", {
project_id: str4("Cutover / biz-migration project id"),
task_id: str4("biz_state task id (optional if project_id set)")
}, getBizMonitorContext, getClient, t),
tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", {
batch_id: { type: "string", required: true },
run_id: str4("Optional evaluate run id")
}, getBizMonitorBoard, getClient, t),
tool2("netx__listBizMonitorReds", "Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).", {
project_id: { type: "string", required: true },
status: str4("open|carried|resolved or empty=all"),
limit: num4()
}, listBizMonitorReds, getClient, t),
tool2("netx__getBizMonitorDiffs", "Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.", {
run_id: { type: "string", required: true },
metric_id: str4(),
sheet_id: str4(),
verdict: str4(),
color: str4("e.g. red"),
q: str4(),
limit: num4(),
offset: num4()
}, getBizMonitorDiffs, getClient, t),
tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", {
batch_id: { type: "string", required: true }
}, getBizCollectBatch, getClient, t),
tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", {
batch_id: { type: "string", required: true },
command_id: { type: "string", required: true }
}, getBizCollectCommandRaw, getClient, t),
tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t),
tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", {
view_id: { type: "string", required: true },

View file

@ -1,8 +1,7 @@
// src/netx/capability-groups.ts
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
ops: Object.freeze({ inPreset: true, public: false }),
topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false })
topology: Object.freeze({ inPreset: false, public: false })
});
var TOOLS_BY_GROUP = Object.freeze({
ops: Object.freeze([
@ -38,27 +37,15 @@ var TOOLS_BY_GROUP = Object.freeze({
"netx__suggestSinkHubs",
"netx__analyzeTopologyViewLayout",
"netx__sinkTopologyDualUnits"
]),
bizMonitor: Object.freeze([
"netx__listBizMonitors",
"netx__listBizMonitorBatches",
"netx__getBizMonitorContext",
"netx__getBizMonitorBoard",
"netx__listBizMonitorReds",
"netx__getBizMonitorDiffs",
"netx__getBizCollectBatch",
"netx__getBizCollectCommandRaw"
])
});
var SKILL_DIR_BY_GROUP = Object.freeze({
ops: "ops",
topology: "topology",
bizMonitor: "biz-monitor"
topology: "topology"
});
var CAPABILITY_GROUP_IDS = Object.freeze([
"ops",
"topology",
"bizMonitor"
"topology"
]);
function groupsForPlane(groups, plane, only) {
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS;
@ -73,12 +60,12 @@ function groupsForced(only) {
return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id));
}
function toolNamesForGroups(groupIds) {
const out = new Set;
const names = new Set;
for (const id of groupIds) {
for (const name of TOOLS_BY_GROUP[id] ?? [])
out.add(name);
for (const tool of TOOLS_BY_GROUP[id])
names.add(tool);
}
return out;
return names;
}
// src/netx/group-skills.ts
@ -234,16 +221,7 @@ import { join as join2, relative, resolve, sep } from "node:path";
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
"memories",
"drafts",
"suggestions",
"refs"
]);
var REF_AREAS = Object.freeze([
"inventory",
"devices",
"topology",
"business",
"commands",
"handbooks"
"suggestions"
]);
var MEMORY_BUCKET_DIR = Object.freeze({
note: "日常笔记",
@ -303,10 +281,6 @@ function suggestionFileName(opts) {
const date = normalizeIsoDate(opts.date, opts.now);
return `${date}-${sanitizeSlug(opts.slug)}.md`;
}
function refFileName(slug) {
const base = sanitizeSlug(slug, "note");
return base.toLowerCase().endsWith(".md") ? base : `${base}.md`;
}
function resolveWritableLocalPath(localRoot, ...segments) {
if (segments.some((s) => s.includes("\x00"))) {
throw new Error("path segment contains NUL");
@ -380,19 +354,6 @@ function draftDir(domain) {
return "drafts";
return join2("drafts", sanitizeSlug(d, "misc"));
}
function refDir(area, device) {
if (!REF_AREAS.includes(area)) {
throw new Error(`invalid refs area: ${area}`);
}
if (area === "devices") {
const host = sanitizeSlug(device?.trim() || "", "");
if (!host) {
throw new Error("refs/devices requires device (host_name)");
}
return join2("refs", "devices", host);
}
return join2("refs", area);
}
// src/netx/kb-context-skill.ts
var SKILL_NAME = "kb-context";
@ -403,29 +364,18 @@ function skillBody(snapshot) {
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
const localGuide = localRoot ? [
"",
"### Dual plane (mandatory)",
"",
"- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
`- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
"- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
" `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /",
" `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.",
"",
"### refs — site product knowledge (evolve every task)",
"- On any NE: **read** `refs/devices/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
"- `area=devices` requires `device=<host_name>` (never UUID); primary file slug=`PROFILE`.",
"- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.",
"- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).",
"",
"### memories — write immediately when valuable",
"- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.",
"- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`."
"**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,",
"`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.",
"They jail writes to `memories/` / `drafts/` / `suggestions/` only.",
"Do **not** use workspace Write/bash for KB paths (sandbox).",
"`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;",
"file boundary issues as `suggestions/` instead."
] : [
"",
"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: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).",
content: [
"## Knowledge base (configured)",
"",
@ -593,25 +543,6 @@ function createSuggestion(snapshot, args) {
relativePath: target.relativePath
};
}
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) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
}
mkdirSync(dirname2(target.absolutePath), { recursive: true });
const existed = existsSync3(target.absolutePath);
writeFileSync(target.absolutePath, args.body.endsWith(`
`) ? args.body : `${args.body}
`, "utf8");
return {
ok: true,
action: existed ? "updated" : "created",
absolutePath: target.absolutePath,
relativePath: target.relativePath
};
}
function updateLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path);
@ -737,41 +668,10 @@ function watchKbContext(listener) {
};
}
// src/netx/json-safe.ts
function toLosslessJson(value) {
try {
const text = JSON.stringify(value);
if (text === undefined) {
return { ok: false, error: "tool_result_not_json", detail: "undefined_root" };
}
const parsed = JSON.parse(text);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
return { ok: true, data: { value: parsed } };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) };
}
}
function omitUndefined(record) {
const out = {};
for (const [key, value] of Object.entries(record)) {
if (value !== undefined)
out[key] = value;
}
return out;
}
// src/netx/kb-local-tools.ts
var str = (description) => ({ type: "string", ...description ? { description } : {} });
var bool = (description) => ({ type: "boolean", ...description ? { description } : {} });
var num = (description) => ({ type: "number", ...description ? { description } : {} });
var reqStr = (description) => ({
type: "string",
required: true,
...description ? { description } : {}
});
function renderJson(_args, value) {
return [{ type: "text", text: JSON.stringify(value, null, 0) }];
}
@ -792,7 +692,7 @@ function tool(name, description, parameters, execute) {
isConcurrencySafe: () => false,
async execute(args) {
try {
return toLosslessJson(await execute(args));
return await execute(args);
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error));
}
@ -808,16 +708,20 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
return () => {};
const entries = [
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",
required: true,
enum: ["note", "rca_review", "ai_trace"],
description: "Memory subdirectory"
},
slug: reqStr("Short filename stem (no path separators)"),
body: reqStr("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
type: "object",
additionalProperties: false,
required: ["bucket", "slug", "body"],
properties: {
bucket: {
type: "string",
enum: ["note", "rca_review", "ai_trace"],
description: "Memory subdirectory"
},
slug: str("Short filename stem (no path separators)"),
body: str("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
}
}, (args) => createMemory(liveSnapshot(), {
bucket: args.bucket,
slug: String(args.slug ?? ""),
@ -825,12 +729,17 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", {
slug: reqStr("Case stem (DRAFT- prefix added if missing)"),
body: reqStr("Markdown body (RCA-ish draft)"),
domain: str("Optional fault-domain subfolder under drafts/"),
date: str("Optional event date YYYY-MM-DD or YYYYMMDD"),
overwrite: bool("Replace if the target file already exists")
tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", {
type: "object",
additionalProperties: false,
required: ["slug", "body"],
properties: {
slug: str("Case stem (DRAFT- prefix added if missing)"),
body: str("Markdown body (RCA-ish draft)"),
domain: str("Optional fault-domain subfolder under drafts/"),
date: str("Optional event date YYYY-MM-DD or YYYYMMDD"),
overwrite: bool("Replace if the target file already exists")
}
}, (args) => createDraft(liveSnapshot(), {
slug: String(args.slug ?? ""),
body: String(args.body ?? ""),
@ -838,17 +747,21 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", {
kind: {
type: "string",
required: true,
enum: ["theory", "improvement"],
description: "theory = knowledge fix; improvement = tools/skills/process"
},
slug: reqStr("Short filename stem"),
body: reqStr("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", {
type: "object",
additionalProperties: false,
required: ["kind", "slug", "body"],
properties: {
kind: {
type: "string",
enum: ["theory", "improvement"],
description: "theory = knowledge fix; improvement = tools/skills/process"
},
slug: str("Short filename stem"),
body: str("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
}
}, (args) => createSuggestion(liveSnapshot(), {
kind: args.kind,
slug: String(args.slug ?? ""),
@ -856,43 +769,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", {
area: {
type: "string",
required: true,
enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"],
description: "refs/ subtree"
},
slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"),
body: reqStr("Full markdown body"),
device: str("Required when area=devices — host_name (never UUID)"),
overwrite: bool("Replace if the target file already exists")
}, (args) => createRef(liveSnapshot(), {
area: args.area,
slug: String(args.slug ?? ""),
body: String(args.body ?? ""),
device: args.device != null ? String(args.device) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", {
path: reqStr("Absolute path or path relative to KB local root"),
body: reqStr("New full markdown body")
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", {
type: "object",
additionalProperties: false,
required: ["path", "body"],
properties: {
path: str("Absolute path or path relative to KB local root"),
body: str("New full markdown body")
}
}, (args) => updateLocalFile(liveSnapshot(), {
path: String(args.path ?? ""),
body: String(args.body ?? "")
})),
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, {
path: reqStr("Absolute path or path relative to KB local root")
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, {
type: "object",
additionalProperties: false,
required: ["path"],
properties: {
path: str("Absolute path or path relative to KB local root")
}
}, (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.", {
root: {
type: "string",
enum: ["memories", "drafts", "suggestions", "refs", "all"],
description: "Subtree to list (default all writable)"
},
limit: num("Max entries 1–200 (default 50)")
tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", {
type: "object",
additionalProperties: false,
properties: {
root: {
type: "string",
enum: ["memories", "drafts", "suggestions", "all"],
description: "Subtree to list (default all writable)"
},
limit: num("Max entries 1–200 (default 50)")
}
}, (args) => listLocalFiles(liveSnapshot(), {
root: args.root ?? "all",
limit: typeof args.limit === "number" ? args.limit : undefined
@ -1497,347 +1406,6 @@ async function findTopologyPaths(client, args, signal) {
body.to_managed_ne_id = toMid;
return client.post("/v1/topology/fabric/paths", body, signal, 30000);
}
function taskBriefNe(brief) {
const rec = asRecord(brief);
const name = typeof rec.ne_name === "string" ? rec.ne_name.trim() : "";
const ip = typeof rec.ne_ip === "string" ? rec.ne_ip.trim() : "";
if (name && ip)
return `${name} (${ip})`;
return name || ip;
}
function slimMigrationProject(row) {
const mt = asRecord(row.monitor_template);
return omitUndefined({
id: row.id,
name: row.name,
status: row.status,
note: row.note,
monitor_template_id: row.monitor_template_id,
monitor_template_name: typeof mt.name === "string" ? mt.name : "",
old_task_id: row.old_task_id,
new_task_id: row.new_task_id,
old_hf_task_id: row.old_hf_task_id,
new_hf_task_id: row.new_hf_task_id,
old_ne: taskBriefNe(row.old_task),
new_ne: taskBriefNe(row.new_task),
hf_interval_sec: row.hf_interval_sec,
hf_start_at: row.hf_start_at,
hf_end_at: row.hf_end_at,
updated_at: row.updated_at
});
}
function matchesBizMonitorQ(row, q) {
if (!q)
return true;
const hay = [
row.id,
row.name,
row.note,
row.status,
row.ne_name,
row.ne_ip,
row.ne_id,
row.old_ne,
row.new_ne,
row.monitor_template_name,
row.purpose,
row.vendor
].map((v) => typeof v === "string" || typeof v === "number" ? String(v).toLowerCase() : "").join(" ");
return hay.includes(q);
}
async function listBizMonitors(client, args, signal) {
const kindRaw = str2(args, "kind", "all").trim().toLowerCase() || "all";
const kind = kindRaw === "project" ? "projects" : kindRaw === "task" ? "tasks" : kindRaw;
if (kind !== "all" && kind !== "projects" && kind !== "tasks") {
return { ok: false, error: "kind_invalid", detail: "kind must be all|projects|tasks" };
}
const purpose = str2(args, "purpose").trim();
const status = str2(args, "status").trim().toLowerCase();
const q = str2(args, "q").trim().toLowerCase();
const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500);
const wantProjects = kind === "all" || kind === "projects";
const wantTasks = kind === "all" || kind === "tasks";
const fetches = [];
if (wantProjects)
fetches.push(client.get("/v1/biz-migration/projects", {}, signal));
else
fetches.push(Promise.resolve({ ok: true, data: { items: [] } }));
if (wantTasks) {
const params = {};
if (purpose)
params.purpose = purpose;
fetches.push(client.get("/v1/biz-state/tasks", params, signal));
} else {
fetches.push(Promise.resolve({ ok: true, data: { items: [] } }));
}
const [projRes, taskRes] = await Promise.all(fetches);
if (wantProjects && projRes.ok === false)
return projRes;
if (wantTasks && taskRes.ok === false)
return taskRes;
const projItems = Array.isArray(asRecord(projRes.data).items) ? asRecord(projRes.data).items : [];
const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : [];
let projects = projItems.map((row) => slimMigrationProject(asRecord(row))).filter((row) => {
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return matchesBizMonitorQ(row, q);
});
let tasks = taskItems.map((row) => asRecord(row)).filter((row) => {
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return matchesBizMonitorQ(row, q);
});
const projectsTotal = projects.length;
const tasksTotal = tasks.length;
projects = projects.slice(0, limit);
tasks = tasks.slice(0, limit);
return {
ok: true,
data: omitUndefined({
kind,
projects: wantProjects ? projects : undefined,
tasks: wantTasks ? tasks : undefined,
counts: {
projects: wantProjects ? projectsTotal : 0,
tasks: wantTasks ? tasksTotal : 0,
projects_returned: wantProjects ? projects.length : 0,
tasks_returned: wantTasks ? tasks.length : 0
},
next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id."
})
};
}
function slimCutoverBatch(row) {
return omitUndefined({
id: row.id,
project_id: row.project_id,
batch_label: row.batch_label,
status: row.status,
accept_status: row.accept_status,
accept_run_id: row.accept_run_id,
started_at: row.started_at,
ended_at: row.ended_at,
note: row.note,
created_at: row.created_at,
updated_at: row.updated_at
});
}
function slimCollectBatch(row, taskId = "") {
return omitUndefined({
id: row.id,
task_id: row.task_id || taskId || undefined,
status: row.status,
ne_name: row.ne_name,
command_count: row.command_count,
row_count: row.row_count,
is_baseline: row.is_baseline,
protected: row.protected,
message: row.message,
started_at: row.started_at,
ended_at: row.ended_at
});
}
function slimEvalRun(row) {
const summary = asRecord(row.summary);
const progress = asRecord(summary.progress);
return omitUndefined({
id: row.id,
batch_id: row.batch_id,
purpose: row.purpose,
old_batch_id: row.old_batch_id,
new_batch_id: row.new_batch_id,
created_at: row.created_at,
anomaly: summary.anomaly,
progress_ok: progress.ok ?? summary.progress_ok,
progress_total: progress.total ?? summary.progress_total
});
}
async function listBizMonitorBatches(client, args, signal) {
const projectId = str2(args, "project_id").trim();
const taskId = str2(args, "task_id").trim();
const batchId = str2(args, "batch_id").trim();
const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto";
const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1;
const status = str2(args, "status").trim().toLowerCase();
const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200);
const runsLimit = clampInt(num2(args, "runs_limit") ?? 10, 10, 1, 50);
let kind = kindRaw;
if (kind === "auto") {
if (batchId && !projectId && !taskId)
kind = "runs";
else if (projectId && !taskId)
kind = "cutover";
else if (taskId && !projectId)
kind = "collect";
else if (projectId && taskId)
kind = "all";
else if (batchId)
kind = "runs";
else {
return {
ok: false,
error: "project_id_or_task_id_or_batch_id_required",
detail: "Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs)."
};
}
}
if (!["auto", "all", "cutover", "collect", "runs"].includes(kind)) {
return { ok: false, error: "kind_invalid", detail: "kind must be auto|all|cutover|collect|runs" };
}
const wantCutover = kind === "all" || kind === "cutover";
const wantCollect = kind === "all" || kind === "collect";
const wantRuns = kind === "runs" || includeRuns && (wantCutover || Boolean(batchId));
if (wantCutover && !projectId) {
return { ok: false, error: "project_id_required", detail: "kind=cutover|all needs project_id" };
}
if (wantCollect && !taskId) {
return { ok: false, error: "task_id_required", detail: "kind=collect|all needs task_id" };
}
if (kind === "runs" && !batchId) {
return { ok: false, error: "batch_id_required", detail: "kind=runs needs cutover batch_id" };
}
let cutoverBatches = [];
let collectBatches = [];
let runs = [];
if (wantCutover) {
const res = await client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, {}, signal);
if (res.ok === false)
return res;
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
cutoverBatches = items.map((row) => slimCutoverBatch(asRecord(row))).filter((row) => {
if (batchId && String(row.id || "") !== batchId)
return false;
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return true;
}).slice(0, limit);
}
if (wantCollect) {
const res = await client.get(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`, { limit }, signal);
if (res.ok === false)
return res;
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
collectBatches = items.map((row) => slimCollectBatch(asRecord(row), taskId)).filter((row) => {
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return true;
}).slice(0, limit);
}
if (wantRuns) {
const runBatchIds = kind === "runs" ? [batchId] : cutoverBatches.map((b) => String(b.id || "").trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0);
if (kind === "runs" && batchId) {
const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`, { limit: runsLimit }, signal);
if (res.ok === false)
return res;
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit);
} else if (includeRuns && runBatchIds.length > 0) {
const settled = await Promise.all(runBatchIds.map(async (id) => {
const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`, { limit: runsLimit }, signal);
if (res.ok === false)
return { batch_id: id, error: res, items: [] };
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
return {
batch_id: id,
items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit)
};
}));
for (const block of settled) {
if ("error" in block && block.error) {
const failed = cutoverBatches.find((b) => b.id === block.batch_id);
if (failed)
failed.runs_error = asRecord(block.error).error || "runs_fetch_failed";
continue;
}
const target = cutoverBatches.find((b) => b.id === block.batch_id);
if (target)
target.runs = block.items;
for (const r of block.items)
runs.push(r);
}
}
}
return {
ok: true,
data: omitUndefined({
kind,
project_id: projectId || undefined,
task_id: taskId || undefined,
batch_id: batchId || undefined,
cutover_batches: wantCutover ? cutoverBatches : undefined,
collect_batches: wantCollect ? collectBatches : undefined,
runs: kind === "runs" ? runs : undefined,
counts: {
cutover_batches: cutoverBatches.length,
collect_batches: collectBatches.length,
runs: runs.length
},
next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)."
})
};
}
async function getBizMonitorContext(client, args, signal) {
const projectId = str2(args, "project_id").trim();
const taskId = str2(args, "task_id").trim();
if (!projectId && !taskId) {
return { ok: false, error: "project_id_or_task_id_required" };
}
const params = {};
if (projectId)
params.project_id = projectId;
if (taskId)
params.task_id = taskId;
return client.get("/v1/biz-migration/monitor-context", params, signal);
}
async function getBizMonitorBoard(client, args, signal) {
const batchId = str2(args, "batch_id").trim();
if (!batchId)
return { ok: false, error: "batch_id_required" };
const params = {};
const runId = str2(args, "run_id").trim();
if (runId)
params.run_id = runId;
return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal);
}
async function listBizMonitorReds(client, args, signal) {
const projectId = str2(args, "project_id").trim();
if (!projectId)
return { ok: false, error: "project_id_required" };
const params = {};
putStr(params, args, ["status"]);
const limit = num2(args, "limit");
if (limit !== undefined)
params.limit = clampInt(limit, 200, 1, 500);
return client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`, params, signal);
}
async function getBizMonitorDiffs(client, args, signal) {
const runId = str2(args, "run_id").trim();
if (!runId)
return { ok: false, error: "run_id_required" };
const params = {};
putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]);
const limit = num2(args, "limit");
if (limit !== undefined)
params.limit = clampInt(limit, 200, 1, 500);
const offset = num2(args, "offset");
if (offset !== undefined)
params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER);
return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal);
}
async function getBizCollectBatch(client, args, signal) {
const batchId = str2(args, "batch_id").trim();
if (!batchId)
return { ok: false, error: "batch_id_required" };
return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal);
}
async function getBizCollectCommandRaw(client, args, signal) {
const batchId = str2(args, "batch_id").trim();
const commandId = str2(args, "command_id").trim();
if (!batchId || !commandId) {
return { ok: false, error: "batch_id_and_command_id_required" };
}
return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, {}, signal);
}
// src/netx/topology-handlers.ts
function asRecord2(value) {
@ -2517,11 +2085,10 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
isConcurrencySafe: () => true,
async execute(args, exec) {
const result = await handler(getClient(), args, exec.signal);
const safe = toLosslessJson(result);
if (result.ok === false || safe.ok === false) {
throw new Error(JSON.stringify(safe));
if (result.ok === false) {
throw new Error(JSON.stringify(result));
}
return safe;
return result;
}
});
}
@ -2663,53 +2230,6 @@ function registerNetxTools(ctx, connection, options) {
layer: str4(),
detail: str4("summary | full")
}, findTopologyPaths, getClient, t),
tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.", {
kind: str4("all | projects | tasks (default all)"),
purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"),
status: str4("Filter by status (e.g. active / paused)"),
q: str4("Keyword on name / ne_name / note / id"),
limit: num4("Max rows per list (default 100, max 500)")
}, listBizMonitors, getClient, t),
tool2("netx__listBizMonitorBatches", "List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.", {
kind: str4("auto | all | cutover | collect | runs (default auto)"),
project_id: str4("Cutover project id → cutover_batches"),
task_id: str4("biz_state task id → collect_batches"),
batch_id: str4("Cutover batch id (filter or kind=runs)"),
include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"),
status: str4("Filter batch status"),
limit: num4("Max batches (default 50)"),
runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)")
}, listBizMonitorBatches, getClient, t),
tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", {
project_id: str4("Cutover / biz-migration project id"),
task_id: str4("biz_state task id (optional if project_id set)")
}, getBizMonitorContext, getClient, t),
tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", {
batch_id: { type: "string", required: true },
run_id: str4("Optional evaluate run id")
}, getBizMonitorBoard, getClient, t),
tool2("netx__listBizMonitorReds", "Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).", {
project_id: { type: "string", required: true },
status: str4("open|carried|resolved or empty=all"),
limit: num4()
}, listBizMonitorReds, getClient, t),
tool2("netx__getBizMonitorDiffs", "Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.", {
run_id: { type: "string", required: true },
metric_id: str4(),
sheet_id: str4(),
verdict: str4(),
color: str4("e.g. red"),
q: str4(),
limit: num4(),
offset: num4()
}, getBizMonitorDiffs, getClient, t),
tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", {
batch_id: { type: "string", required: true }
}, getBizCollectBatch, getClient, t),
tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", {
batch_id: { type: "string", required: true },
command_id: { type: "string", required: true }
}, getBizCollectCommandRaw, getClient, t),
tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t),
tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", {
view_id: { type: "string", required: true },

View file

@ -1,8 +1,7 @@
// src/netx/capability-groups.ts
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
ops: Object.freeze({ inPreset: true, public: false }),
topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false })
topology: Object.freeze({ inPreset: false, public: false })
});
var TOOLS_BY_GROUP = Object.freeze({
ops: Object.freeze([
@ -38,27 +37,15 @@ var TOOLS_BY_GROUP = Object.freeze({
"netx__suggestSinkHubs",
"netx__analyzeTopologyViewLayout",
"netx__sinkTopologyDualUnits"
]),
bizMonitor: Object.freeze([
"netx__listBizMonitors",
"netx__listBizMonitorBatches",
"netx__getBizMonitorContext",
"netx__getBizMonitorBoard",
"netx__listBizMonitorReds",
"netx__getBizMonitorDiffs",
"netx__getBizCollectBatch",
"netx__getBizCollectCommandRaw"
])
});
var SKILL_DIR_BY_GROUP = Object.freeze({
ops: "ops",
topology: "topology",
bizMonitor: "biz-monitor"
topology: "topology"
});
var CAPABILITY_GROUP_IDS = Object.freeze([
"ops",
"topology",
"bizMonitor"
"topology"
]);
function groupsForPlane(groups, plane, only) {
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS;
@ -73,12 +60,12 @@ function groupsForced(only) {
return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id));
}
function toolNamesForGroups(groupIds) {
const out = new Set;
const names = new Set;
for (const id of groupIds) {
for (const name of TOOLS_BY_GROUP[id] ?? [])
out.add(name);
for (const tool of TOOLS_BY_GROUP[id])
names.add(tool);
}
return out;
return names;
}
// src/netx/group-skills.ts
@ -234,16 +221,7 @@ import { join as join2, relative, resolve, sep } from "node:path";
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
"memories",
"drafts",
"suggestions",
"refs"
]);
var REF_AREAS = Object.freeze([
"inventory",
"devices",
"topology",
"business",
"commands",
"handbooks"
"suggestions"
]);
var MEMORY_BUCKET_DIR = Object.freeze({
note: "日常笔记",
@ -303,10 +281,6 @@ function suggestionFileName(opts) {
const date = normalizeIsoDate(opts.date, opts.now);
return `${date}-${sanitizeSlug(opts.slug)}.md`;
}
function refFileName(slug) {
const base = sanitizeSlug(slug, "note");
return base.toLowerCase().endsWith(".md") ? base : `${base}.md`;
}
function resolveWritableLocalPath(localRoot, ...segments) {
if (segments.some((s) => s.includes("\x00"))) {
throw new Error("path segment contains NUL");
@ -380,19 +354,6 @@ function draftDir(domain) {
return "drafts";
return join2("drafts", sanitizeSlug(d, "misc"));
}
function refDir(area, device) {
if (!REF_AREAS.includes(area)) {
throw new Error(`invalid refs area: ${area}`);
}
if (area === "devices") {
const host = sanitizeSlug(device?.trim() || "", "");
if (!host) {
throw new Error("refs/devices requires device (host_name)");
}
return join2("refs", "devices", host);
}
return join2("refs", area);
}
// src/netx/kb-context-skill.ts
var SKILL_NAME = "kb-context";
@ -403,29 +364,18 @@ function skillBody(snapshot) {
const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |";
const localGuide = localRoot ? [
"",
"### Dual plane (mandatory)",
"",
"- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).",
`- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
"- **All KB writes via host tools** (workspace Write/bash cannot reach KB):",
" `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /",
" `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.",
"",
"### refs — site product knowledge (evolve every task)",
"- On any NE: **read** `refs/devices/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.",
"- `area=devices` requires `device=<host_name>` (never UUID); primary file slug=`PROFILE`.",
"- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.",
"- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).",
"",
"### memories — write immediately when valuable",
"- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.",
"- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`."
"**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,",
"`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.",
"They jail writes to `memories/` / `drafts/` / `suggestions/` only.",
"Do **not** use workspace Write/bash for KB paths (sandbox).",
"`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;",
"file boundary issues as `suggestions/` instead."
] : [
"",
"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: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).",
content: [
"## Knowledge base (configured)",
"",
@ -593,25 +543,6 @@ function createSuggestion(snapshot, args) {
relativePath: target.relativePath
};
}
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) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
}
mkdirSync(dirname2(target.absolutePath), { recursive: true });
const existed = existsSync3(target.absolutePath);
writeFileSync(target.absolutePath, args.body.endsWith(`
`) ? args.body : `${args.body}
`, "utf8");
return {
ok: true,
action: existed ? "updated" : "created",
absolutePath: target.absolutePath,
relativePath: target.relativePath
};
}
function updateLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path);
@ -737,41 +668,10 @@ function watchKbContext(listener) {
};
}
// src/netx/json-safe.ts
function toLosslessJson(value) {
try {
const text = JSON.stringify(value);
if (text === undefined) {
return { ok: false, error: "tool_result_not_json", detail: "undefined_root" };
}
const parsed = JSON.parse(text);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
return { ok: true, data: { value: parsed } };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) };
}
}
function omitUndefined(record) {
const out = {};
for (const [key, value] of Object.entries(record)) {
if (value !== undefined)
out[key] = value;
}
return out;
}
// src/netx/kb-local-tools.ts
var str = (description) => ({ type: "string", ...description ? { description } : {} });
var bool = (description) => ({ type: "boolean", ...description ? { description } : {} });
var num = (description) => ({ type: "number", ...description ? { description } : {} });
var reqStr = (description) => ({
type: "string",
required: true,
...description ? { description } : {}
});
function renderJson(_args, value) {
return [{ type: "text", text: JSON.stringify(value, null, 0) }];
}
@ -792,7 +692,7 @@ function tool(name, description, parameters, execute) {
isConcurrencySafe: () => false,
async execute(args) {
try {
return toLosslessJson(await execute(args));
return await execute(args);
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error));
}
@ -808,16 +708,20 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
return () => {};
const entries = [
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",
required: true,
enum: ["note", "rca_review", "ai_trace"],
description: "Memory subdirectory"
},
slug: reqStr("Short filename stem (no path separators)"),
body: reqStr("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
type: "object",
additionalProperties: false,
required: ["bucket", "slug", "body"],
properties: {
bucket: {
type: "string",
enum: ["note", "rca_review", "ai_trace"],
description: "Memory subdirectory"
},
slug: str("Short filename stem (no path separators)"),
body: str("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
}
}, (args) => createMemory(liveSnapshot(), {
bucket: args.bucket,
slug: String(args.slug ?? ""),
@ -825,12 +729,17 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for formal RCA.", {
slug: reqStr("Case stem (DRAFT- prefix added if missing)"),
body: reqStr("Markdown body (RCA-ish draft)"),
domain: str("Optional fault-domain subfolder under drafts/"),
date: str("Optional event date YYYY-MM-DD or YYYYMMDD"),
overwrite: bool("Replace if the target file already exists")
tool("netx__kbWriteDraft", `Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. ` + "Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. " + "Set overwrite=true to replace. Not for identity/ or formal RCA.", {
type: "object",
additionalProperties: false,
required: ["slug", "body"],
properties: {
slug: str("Case stem (DRAFT- prefix added if missing)"),
body: str("Markdown body (RCA-ish draft)"),
domain: str("Optional fault-domain subfolder under drafts/"),
date: str("Optional event date YYYY-MM-DD or YYYYMMDD"),
overwrite: bool("Replace if the target file already exists")
}
}, (args) => createDraft(liveSnapshot(), {
slug: String(args.slug ?? ""),
body: String(args.body ?? ""),
@ -838,17 +747,21 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for HQ pack/theory corrections or tool/process improvements.", {
kind: {
type: "string",
required: true,
enum: ["theory", "improvement"],
description: "theory = knowledge fix; improvement = tools/skills/process"
},
slug: reqStr("Short filename stem"),
body: reqStr("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
tool("netx__kbWriteSuggestion", `Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. ` + "Use for theory corrections or tool/process improvements — never rewrite identity/.", {
type: "object",
additionalProperties: false,
required: ["kind", "slug", "body"],
properties: {
kind: {
type: "string",
enum: ["theory", "improvement"],
description: "theory = knowledge fix; improvement = tools/skills/process"
},
slug: str("Short filename stem"),
body: str("Full markdown body"),
date: str("Optional YYYY-MM-DD (default today)"),
overwrite: bool("Replace if the target file already exists")
}
}, (args) => createSuggestion(liveSnapshot(), {
kind: args.kind,
slug: String(args.slug ?? ""),
@ -856,43 +769,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
date: args.date != null ? String(args.date) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbWriteRef", `Create (or overwrite) site product-knowledge under ${localRoot}/refs/. ` + "area=inventory|devices|topology|business|commands|handbooks. " + "For devices: require device=host_name; default slug PROFILE for the ops profile. " + "Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.", {
area: {
type: "string",
required: true,
enum: ["inventory", "devices", "topology", "business", "commands", "handbooks"],
description: "refs/ subtree"
},
slug: reqStr("Filename stem (e.g. PROFILE, neighbors, ledger)"),
body: reqStr("Full markdown body"),
device: str("Required when area=devices — host_name (never UUID)"),
overwrite: bool("Replace if the target file already exists")
}, (args) => createRef(liveSnapshot(), {
area: args.area,
slug: String(args.slug ?? ""),
body: String(args.body ?? ""),
device: args.device != null ? String(args.device) : undefined,
overwrite: args.overwrite === true
})),
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions|refs ` + `(absolute path or path relative to ${localRoot}). ` + "Drafts keep status: draft.", {
path: reqStr("Absolute path or path relative to KB local root"),
body: reqStr("New full markdown body")
tool("netx__kbUpdateLocal", `Replace the body of an existing file under memories|drafts|suggestions ` + `(absolute path or path relative to ${localRoot}). ` + "Cannot touch identity/ or outside paths.local. Drafts keep status: draft.", {
type: "object",
additionalProperties: false,
required: ["path", "body"],
properties: {
path: str("Absolute path or path relative to KB local root"),
body: str("New full markdown body")
}
}, (args) => updateLocalFile(liveSnapshot(), {
path: String(args.path ?? ""),
body: String(args.body ?? "")
})),
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions|refs ` + `(absolute or relative to ${localRoot}).`, {
path: reqStr("Absolute path or path relative to KB local root")
tool("netx__kbDeleteLocal", `Delete one existing markdown under memories|drafts|suggestions ` + `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`, {
type: "object",
additionalProperties: false,
required: ["path"],
properties: {
path: str("Absolute path or path relative to KB local root")
}
}, (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.", {
root: {
type: "string",
enum: ["memories", "drafts", "suggestions", "refs", "all"],
description: "Subtree to list (default all writable)"
},
limit: num("Max entries 1–200 (default 50)")
tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). ` + "Does not include identity/. Read contents via absolute paths from the listing.", {
type: "object",
additionalProperties: false,
properties: {
root: {
type: "string",
enum: ["memories", "drafts", "suggestions", "all"],
description: "Subtree to list (default all writable)"
},
limit: num("Max entries 1–200 (default 50)")
}
}, (args) => listLocalFiles(liveSnapshot(), {
root: args.root ?? "all",
limit: typeof args.limit === "number" ? args.limit : undefined
@ -1497,347 +1406,6 @@ async function findTopologyPaths(client, args, signal) {
body.to_managed_ne_id = toMid;
return client.post("/v1/topology/fabric/paths", body, signal, 30000);
}
function taskBriefNe(brief) {
const rec = asRecord(brief);
const name = typeof rec.ne_name === "string" ? rec.ne_name.trim() : "";
const ip = typeof rec.ne_ip === "string" ? rec.ne_ip.trim() : "";
if (name && ip)
return `${name} (${ip})`;
return name || ip;
}
function slimMigrationProject(row) {
const mt = asRecord(row.monitor_template);
return omitUndefined({
id: row.id,
name: row.name,
status: row.status,
note: row.note,
monitor_template_id: row.monitor_template_id,
monitor_template_name: typeof mt.name === "string" ? mt.name : "",
old_task_id: row.old_task_id,
new_task_id: row.new_task_id,
old_hf_task_id: row.old_hf_task_id,
new_hf_task_id: row.new_hf_task_id,
old_ne: taskBriefNe(row.old_task),
new_ne: taskBriefNe(row.new_task),
hf_interval_sec: row.hf_interval_sec,
hf_start_at: row.hf_start_at,
hf_end_at: row.hf_end_at,
updated_at: row.updated_at
});
}
function matchesBizMonitorQ(row, q) {
if (!q)
return true;
const hay = [
row.id,
row.name,
row.note,
row.status,
row.ne_name,
row.ne_ip,
row.ne_id,
row.old_ne,
row.new_ne,
row.monitor_template_name,
row.purpose,
row.vendor
].map((v) => typeof v === "string" || typeof v === "number" ? String(v).toLowerCase() : "").join(" ");
return hay.includes(q);
}
async function listBizMonitors(client, args, signal) {
const kindRaw = str2(args, "kind", "all").trim().toLowerCase() || "all";
const kind = kindRaw === "project" ? "projects" : kindRaw === "task" ? "tasks" : kindRaw;
if (kind !== "all" && kind !== "projects" && kind !== "tasks") {
return { ok: false, error: "kind_invalid", detail: "kind must be all|projects|tasks" };
}
const purpose = str2(args, "purpose").trim();
const status = str2(args, "status").trim().toLowerCase();
const q = str2(args, "q").trim().toLowerCase();
const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500);
const wantProjects = kind === "all" || kind === "projects";
const wantTasks = kind === "all" || kind === "tasks";
const fetches = [];
if (wantProjects)
fetches.push(client.get("/v1/biz-migration/projects", {}, signal));
else
fetches.push(Promise.resolve({ ok: true, data: { items: [] } }));
if (wantTasks) {
const params = {};
if (purpose)
params.purpose = purpose;
fetches.push(client.get("/v1/biz-state/tasks", params, signal));
} else {
fetches.push(Promise.resolve({ ok: true, data: { items: [] } }));
}
const [projRes, taskRes] = await Promise.all(fetches);
if (wantProjects && projRes.ok === false)
return projRes;
if (wantTasks && taskRes.ok === false)
return taskRes;
const projItems = Array.isArray(asRecord(projRes.data).items) ? asRecord(projRes.data).items : [];
const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : [];
let projects = projItems.map((row) => slimMigrationProject(asRecord(row))).filter((row) => {
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return matchesBizMonitorQ(row, q);
});
let tasks = taskItems.map((row) => asRecord(row)).filter((row) => {
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return matchesBizMonitorQ(row, q);
});
const projectsTotal = projects.length;
const tasksTotal = tasks.length;
projects = projects.slice(0, limit);
tasks = tasks.slice(0, limit);
return {
ok: true,
data: omitUndefined({
kind,
projects: wantProjects ? projects : undefined,
tasks: wantTasks ? tasks : undefined,
counts: {
projects: wantProjects ? projectsTotal : 0,
tasks: wantTasks ? tasksTotal : 0,
projects_returned: wantProjects ? projects.length : 0,
tasks_returned: wantTasks ? tasks.length : 0
},
next: "Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id."
})
};
}
function slimCutoverBatch(row) {
return omitUndefined({
id: row.id,
project_id: row.project_id,
batch_label: row.batch_label,
status: row.status,
accept_status: row.accept_status,
accept_run_id: row.accept_run_id,
started_at: row.started_at,
ended_at: row.ended_at,
note: row.note,
created_at: row.created_at,
updated_at: row.updated_at
});
}
function slimCollectBatch(row, taskId = "") {
return omitUndefined({
id: row.id,
task_id: row.task_id || taskId || undefined,
status: row.status,
ne_name: row.ne_name,
command_count: row.command_count,
row_count: row.row_count,
is_baseline: row.is_baseline,
protected: row.protected,
message: row.message,
started_at: row.started_at,
ended_at: row.ended_at
});
}
function slimEvalRun(row) {
const summary = asRecord(row.summary);
const progress = asRecord(summary.progress);
return omitUndefined({
id: row.id,
batch_id: row.batch_id,
purpose: row.purpose,
old_batch_id: row.old_batch_id,
new_batch_id: row.new_batch_id,
created_at: row.created_at,
anomaly: summary.anomaly,
progress_ok: progress.ok ?? summary.progress_ok,
progress_total: progress.total ?? summary.progress_total
});
}
async function listBizMonitorBatches(client, args, signal) {
const projectId = str2(args, "project_id").trim();
const taskId = str2(args, "task_id").trim();
const batchId = str2(args, "batch_id").trim();
const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto";
const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1;
const status = str2(args, "status").trim().toLowerCase();
const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200);
const runsLimit = clampInt(num2(args, "runs_limit") ?? 10, 10, 1, 50);
let kind = kindRaw;
if (kind === "auto") {
if (batchId && !projectId && !taskId)
kind = "runs";
else if (projectId && !taskId)
kind = "cutover";
else if (taskId && !projectId)
kind = "collect";
else if (projectId && taskId)
kind = "all";
else if (batchId)
kind = "runs";
else {
return {
ok: false,
error: "project_id_or_task_id_or_batch_id_required",
detail: "Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs)."
};
}
}
if (!["auto", "all", "cutover", "collect", "runs"].includes(kind)) {
return { ok: false, error: "kind_invalid", detail: "kind must be auto|all|cutover|collect|runs" };
}
const wantCutover = kind === "all" || kind === "cutover";
const wantCollect = kind === "all" || kind === "collect";
const wantRuns = kind === "runs" || includeRuns && (wantCutover || Boolean(batchId));
if (wantCutover && !projectId) {
return { ok: false, error: "project_id_required", detail: "kind=cutover|all needs project_id" };
}
if (wantCollect && !taskId) {
return { ok: false, error: "task_id_required", detail: "kind=collect|all needs task_id" };
}
if (kind === "runs" && !batchId) {
return { ok: false, error: "batch_id_required", detail: "kind=runs needs cutover batch_id" };
}
let cutoverBatches = [];
let collectBatches = [];
let runs = [];
if (wantCutover) {
const res = await client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, {}, signal);
if (res.ok === false)
return res;
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
cutoverBatches = items.map((row) => slimCutoverBatch(asRecord(row))).filter((row) => {
if (batchId && String(row.id || "") !== batchId)
return false;
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return true;
}).slice(0, limit);
}
if (wantCollect) {
const res = await client.get(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`, { limit }, signal);
if (res.ok === false)
return res;
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
collectBatches = items.map((row) => slimCollectBatch(asRecord(row), taskId)).filter((row) => {
if (status && String(row.status || "").toLowerCase() !== status)
return false;
return true;
}).slice(0, limit);
}
if (wantRuns) {
const runBatchIds = kind === "runs" ? [batchId] : cutoverBatches.map((b) => String(b.id || "").trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0);
if (kind === "runs" && batchId) {
const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`, { limit: runsLimit }, signal);
if (res.ok === false)
return res;
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit);
} else if (includeRuns && runBatchIds.length > 0) {
const settled = await Promise.all(runBatchIds.map(async (id) => {
const res = await client.get(`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`, { limit: runsLimit }, signal);
if (res.ok === false)
return { batch_id: id, error: res, items: [] };
const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : [];
return {
batch_id: id,
items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit)
};
}));
for (const block of settled) {
if ("error" in block && block.error) {
const failed = cutoverBatches.find((b) => b.id === block.batch_id);
if (failed)
failed.runs_error = asRecord(block.error).error || "runs_fetch_failed";
continue;
}
const target = cutoverBatches.find((b) => b.id === block.batch_id);
if (target)
target.runs = block.items;
for (const r of block.items)
runs.push(r);
}
}
}
return {
ok: true,
data: omitUndefined({
kind,
project_id: projectId || undefined,
task_id: taskId || undefined,
batch_id: batchId || undefined,
cutover_batches: wantCutover ? cutoverBatches : undefined,
collect_batches: wantCollect ? collectBatches : undefined,
runs: kind === "runs" ? runs : undefined,
counts: {
cutover_batches: cutoverBatches.length,
collect_batches: collectBatches.length,
runs: runs.length
},
next: wantCutover ? "Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch." : wantCollect ? "Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw." : "Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id)."
})
};
}
async function getBizMonitorContext(client, args, signal) {
const projectId = str2(args, "project_id").trim();
const taskId = str2(args, "task_id").trim();
if (!projectId && !taskId) {
return { ok: false, error: "project_id_or_task_id_required" };
}
const params = {};
if (projectId)
params.project_id = projectId;
if (taskId)
params.task_id = taskId;
return client.get("/v1/biz-migration/monitor-context", params, signal);
}
async function getBizMonitorBoard(client, args, signal) {
const batchId = str2(args, "batch_id").trim();
if (!batchId)
return { ok: false, error: "batch_id_required" };
const params = {};
const runId = str2(args, "run_id").trim();
if (runId)
params.run_id = runId;
return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal);
}
async function listBizMonitorReds(client, args, signal) {
const projectId = str2(args, "project_id").trim();
if (!projectId)
return { ok: false, error: "project_id_required" };
const params = {};
putStr(params, args, ["status"]);
const limit = num2(args, "limit");
if (limit !== undefined)
params.limit = clampInt(limit, 200, 1, 500);
return client.get(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`, params, signal);
}
async function getBizMonitorDiffs(client, args, signal) {
const runId = str2(args, "run_id").trim();
if (!runId)
return { ok: false, error: "run_id_required" };
const params = {};
putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]);
const limit = num2(args, "limit");
if (limit !== undefined)
params.limit = clampInt(limit, 200, 1, 500);
const offset = num2(args, "offset");
if (offset !== undefined)
params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER);
return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal);
}
async function getBizCollectBatch(client, args, signal) {
const batchId = str2(args, "batch_id").trim();
if (!batchId)
return { ok: false, error: "batch_id_required" };
return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal);
}
async function getBizCollectCommandRaw(client, args, signal) {
const batchId = str2(args, "batch_id").trim();
const commandId = str2(args, "command_id").trim();
if (!batchId || !commandId) {
return { ok: false, error: "batch_id_and_command_id_required" };
}
return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, {}, signal);
}
// src/netx/topology-handlers.ts
function asRecord2(value) {
@ -2517,11 +2085,10 @@ function tool2(name, description, parameters, handler, getClient, timeoutMs) {
isConcurrencySafe: () => true,
async execute(args, exec) {
const result = await handler(getClient(), args, exec.signal);
const safe = toLosslessJson(result);
if (result.ok === false || safe.ok === false) {
throw new Error(JSON.stringify(safe));
if (result.ok === false) {
throw new Error(JSON.stringify(result));
}
return safe;
return result;
}
});
}
@ -2663,53 +2230,6 @@ function registerNetxTools(ctx, connection, options) {
layer: str4(),
detail: str4("summary | full")
}, findTopologyPaths, getClient, t),
tool2("netx__listBizMonitors", "Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.", {
kind: str4("all | projects | tasks (default all)"),
purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"),
status: str4("Filter by status (e.g. active / paused)"),
q: str4("Keyword on name / ne_name / note / id"),
limit: num4("Max rows per list (default 100, max 500)")
}, listBizMonitors, getClient, t),
tool2("netx__listBizMonitorBatches", "List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.", {
kind: str4("auto | all | cutover | collect | runs (default auto)"),
project_id: str4("Cutover project id → cutover_batches"),
task_id: str4("biz_state task id → collect_batches"),
batch_id: str4("Cutover batch id (filter or kind=runs)"),
include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"),
status: str4("Filter batch status"),
limit: num4("Max batches (default 50)"),
runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)")
}, listBizMonitorBatches, getClient, t),
tool2("netx__getBizMonitorContext", "Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.", {
project_id: str4("Cutover / biz-migration project id"),
task_id: str4("biz_state task id (optional if project_id set)")
}, getBizMonitorContext, getClient, t),
tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", {
batch_id: { type: "string", required: true },
run_id: str4("Optional evaluate run id")
}, getBizMonitorBoard, getClient, t),
tool2("netx__listBizMonitorReds", "Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).", {
project_id: { type: "string", required: true },
status: str4("open|carried|resolved or empty=all"),
limit: num4()
}, listBizMonitorReds, getClient, t),
tool2("netx__getBizMonitorDiffs", "Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.", {
run_id: { type: "string", required: true },
metric_id: str4(),
sheet_id: str4(),
verdict: str4(),
color: str4("e.g. red"),
q: str4(),
limit: num4(),
offset: num4()
}, getBizMonitorDiffs, getClient, t),
tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", {
batch_id: { type: "string", required: true }
}, getBizCollectBatch, getClient, t),
tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", {
batch_id: { type: "string", required: true },
command_id: { type: "string", required: true }
}, getBizCollectCommandRaw, getClient, t),
tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t),
tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", {
view_id: { type: "string", required: true },

View file

@ -591,7 +591,6 @@ function unconfiguredKbSnapshot() {
// src/client/kb-status-view.ts
var NETXOPS_RPC_CHANNEL2 = "/netxops";
var KB_STATUS_ENDPOINT = "kb.status";
var KB_RELOAD_ENDPOINT = "kb.reload";
var KB_RESOLVE_ENDPOINT = "kb.resolve";
function asKbSnapshot(value) {
const empty = unconfiguredKbSnapshot();
@ -625,44 +624,22 @@ function asKbSnapshot(value) {
errorMessage: typeof row.errorMessage === "string" ? row.errorMessage : ""
};
}
function asRpcOk(result) {
return result !== null && typeof result === "object" && result.ok === true;
}
async function fetchKbStatus(call, signal) {
const result = await call(NETXOPS_RPC_CHANNEL2, KB_STATUS_ENDPOINT, {}, signal);
if (asRpcOk(result)) {
if (result !== null && typeof result === "object" && result.ok === true) {
return asKbSnapshot(result.value);
}
if (result !== null && typeof result === "object" && result.ok === false) {
const message = String(result.error?.message ?? "kb.status rpc failed");
return { ...unconfiguredKbSnapshot(), status: "error", errorMessage: message };
}
return {
...unconfiguredKbSnapshot(),
status: "error",
errorMessage: "kb.status unavailable"
};
}
async function reloadKbStatus(call, signal) {
const result = await call(NETXOPS_RPC_CHANNEL2, KB_RELOAD_ENDPOINT, {}, signal);
if (asRpcOk(result)) {
return asKbSnapshot(result.value);
}
return fetchKbStatus(call, signal);
return unconfiguredKbSnapshot();
}
async function resolveKbPath(call, path, signal) {
let result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { path }, signal);
if (!asRpcOk(result)) {
if (!(result !== null && typeof result === "object" && result.ok === true)) {
result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { args: { path } }, signal);
}
if (asRpcOk(result)) {
if (result !== null && typeof result === "object" && result.ok === true) {
return asKbSnapshot(result.value);
}
return {
...unconfiguredKbSnapshot(),
status: "error",
errorMessage: "kb.resolve unavailable"
};
return unconfiguredKbSnapshot();
}
function kbStatusTone(status) {
if (status === "configured")
@ -1451,20 +1428,6 @@ function NetxopsCard(props) {
onEditPublic: (checked) => {
props.edit("groupTopologyPublic", checked ? "true" : "false");
}
}),
/* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, {
title: t("groupBizMonitor"),
inPresetLabel: t("groupInPreset"),
publicLabel: t("groupPublic"),
inPreset: state.groupBizMonitorInPreset,
published: state.groupBizMonitorPublic,
disabled,
onEditInPreset: (checked) => {
props.edit("groupBizMonitorInPreset", checked ? "true" : "false");
},
onEditPublic: (checked) => {
props.edit("groupBizMonitorPublic", checked ? "true" : "false");
}
})
]
}),
@ -1923,7 +1886,7 @@ class CardForm {
}
async store(field, value) {
await this.scope.set(field, value);
return this.sectionValue(field) === value || this.userLayer()?.[field] === value;
return this.userLayer()?.[field] === value;
}
stage(field, edit) {
this.staged.set(field, edit);
@ -1982,9 +1945,7 @@ class NetxopsCardController {
kbDirectoryPicker;
kbBrowseInFlight = false;
kbStatusInFlight = false;
kbReloadInFlight = false;
kbUiError = null;
lastSyncedKbRoot = undefined;
imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
sessionsExportStatus = null;
sessionsExportBusy = false;
@ -2009,8 +1970,6 @@ class NetxopsCardController {
booleanField("groupOpsPublic"),
booleanField("groupTopologyInPreset"),
booleanField("groupTopologyPublic"),
booleanFieldPersistFalse("groupBizMonitorInPreset"),
booleanField("groupBizMonitorPublic"),
booleanFieldPersistFalse("groupKbInPreset"),
booleanField("groupKbPublic"),
booleanField("alarmPushEnabled"),
@ -2024,7 +1983,6 @@ class NetxopsCardController {
scope.subscribe(() => {
this.readCredential();
this.refreshKbPreview();
this.syncKbRootFromSettings();
});
this.readCredential();
}
@ -2068,7 +2026,7 @@ class NetxopsCardController {
}
this.startStatusPoll();
this.refreshAlarmPushStatus();
this.reloadKbFromHost();
this.refreshKbStatus();
this.refreshKbPreview();
this.refreshImDeliveryCatalog();
this.refreshSessionsExportStatus();
@ -2156,42 +2114,15 @@ class NetxopsCardController {
this.kbStatusInFlight = true;
try {
const next = await fetchKbStatus(call);
this.applyKbSnapshot(next);
const prev = this.kbStatus;
if (prev && prev.status === next.status && prev.realRoot === next.realRoot && prev.operatorName === next.operatorName && prev.country === next.country && prev.version === next.version && prev.errorMessage === next.errorMessage)
return;
this.kbStatus = next;
this.store.set(this.projection());
} catch {} finally {
this.kbStatusInFlight = false;
}
}
async reloadKbFromHost() {
const call = this.rpcCall;
if (call === undefined || this.kbReloadInFlight)
return;
this.kbReloadInFlight = true;
try {
const next = await reloadKbStatus(call);
this.applyKbSnapshot(next);
this.lastSyncedKbRoot = (this.scope.getSnapshot().value?.kbRoot ?? "").trim();
} catch {
this.refreshKbStatus();
} finally {
this.kbReloadInFlight = false;
}
}
applyKbSnapshot(next) {
const prev = this.kbStatus;
if (prev && prev.status === next.status && prev.realRoot === next.realRoot && prev.operatorName === next.operatorName && prev.country === next.country && prev.version === next.version && prev.errorMessage === next.errorMessage)
return;
this.kbStatus = next;
this.store.set(this.projection());
}
async syncKbRootFromSettings() {
if (this.rpcCall === undefined)
return;
const saved = (this.scope.getSnapshot().value?.kbRoot ?? "").trim();
if (saved === this.lastSyncedKbRoot)
return;
this.lastSyncedKbRoot = saved;
await this.reloadKbFromHost();
}
async refreshKbPreview() {
const call = this.rpcCall;
if (call === undefined)
@ -2214,30 +2145,6 @@ class NetxopsCardController {
this.store.set(this.projection());
} catch {}
}
async commitKbRoot(path) {
const trimmed = path.trim();
this.kbUiError = null;
this.store.set(this.projection());
await this.form.save();
const shell = this.form.shell();
const saved = (this.scope.getSnapshot().value?.kbRoot ?? "").trim();
if (shell.failed || saved !== trimmed) {
this.kbUiError = saved === trimmed ? "kbRoot save failed" : `kbRoot save did not land (saved="${saved || "(empty)"}")`;
this.store.set(this.projection());
const call = this.rpcCall;
if (call !== undefined && trimmed !== "") {
try {
this.kbPreview = await resolveKbPath(call, trimmed);
this.store.set(this.projection());
} catch {}
}
return;
}
this.kbPreview = null;
this.lastSyncedKbRoot = undefined;
await this.reloadKbFromHost();
this.refreshKbPreview();
}
browseKbRoot() {
if (this.kbBrowseInFlight)
return;
@ -2246,7 +2153,11 @@ class NetxopsCardController {
this.store.set(this.projection());
const applyPath = (path) => {
this.form.actions().edit("kbRoot", path);
this.commitKbRoot(path);
this.refreshKbPreview();
this.form.save().then(() => {
this.refreshKbStatus();
this.refreshKbPreview();
});
};
const fail = (error) => {
this.kbUiError = error instanceof Error ? error.message : String(error);
@ -2328,8 +2239,6 @@ class NetxopsCardController {
groupOpsPublic: this.form.field("groupOpsPublic"),
groupTopologyInPreset: this.form.field("groupTopologyInPreset"),
groupTopologyPublic: this.form.field("groupTopologyPublic"),
groupBizMonitorInPreset: this.form.field("groupBizMonitorInPreset"),
groupBizMonitorPublic: this.form.field("groupBizMonitorPublic"),
groupKbInPreset: this.form.field("groupKbInPreset"),
groupKbPublic: this.form.field("groupKbPublic"),
alarmPushEnabled: this.form.field("alarmPushEnabled"),
@ -2489,7 +2398,6 @@ var en = {
nmsProviderHint: "zte-ume",
groupOps: "ops",
groupTopology: "topology",
groupBizMonitor: "bizMonitor",
groupKb: "Knowledge skills",
groupInPreset: "In Netx Ops preset",
groupPublic: "Publish to other presets",
@ -2498,7 +2406,7 @@ var en = {
kbBrowse: "Browse…",
kbBrowseUnavailable: "Directory picker unavailable — paste an absolute folder path, then Save.",
kbBrowseFailed: "Browse failed: {detail}",
kbSaveHint: "Browse writes and saves automatically, then reloads Host KB status. Manual paste still needs Save.",
kbSaveHint: "Browse writes and saves automatically. Manual paste still needs Save.",
kbStatusConfigured: "Knowledge base: {operator} ({country}) v{version}",
kbStatusUnconfigured: "Knowledge base: not configured (pure netx)",
kbStatusError: "Knowledge base: error — {detail}",
@ -2567,7 +2475,6 @@ var zh = {
nmsProviderHint: "zte-ume",
groupOps: "ops",
groupTopology: "topology",
groupBizMonitor: "bizMonitor",
groupKb: "知识库技能",
groupInPreset: "在 Netx Ops 预设中启用",
groupPublic: "对其他预设公开",
@ -2576,7 +2483,7 @@ var zh = {
kbBrowse: "浏览…",
kbBrowseUnavailable: "目录选择器不可用 — 请粘贴绝对目录路径后点保存。",
kbBrowseFailed: "浏览失败:{detail}",
kbSaveHint: "点「浏览」选目录后会自动写入、保存,并刷新宿主知识库状态;若是手粘路径仍需点保存。",
kbSaveHint: "点「浏览」选目录后会自动写入并保存;若是手粘路径仍需点保存。",
kbStatusConfigured: "知识库: {operator}({country}) v{version}",
kbStatusUnconfigured: "知识库: 未配置(纯 netx)",
kbStatusError: "知识库: 错误 — {detail}",

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "dsh-netxops",
"version": "0.1.40",
"version": "0.1.33",
"description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills",
"license": "MIT",
"type": "module",
@ -13,7 +13,6 @@
"./tools-nms": "./lib/agent-tools-nms.js",
"./tools-common": "./lib/agent-tools-common.js",
"./tools-topology": "./lib/agent-tools-topology.js",
"./tools-biz-monitor": "./lib/agent-tools-biz-monitor.js",
"./client": "./lib/client.js",
"./package.json": "./package.json"
},
@ -25,7 +24,6 @@
"lib/agent-tools-nms.js",
"lib/agent-tools-common.js",
"lib/agent-tools-topology.js",
"lib/agent-tools-biz-monitor.js",
"lib/client.js",
"presets/",
"cordis.patch.yml",

View file

@ -33,25 +33,7 @@
## 工具
- netx 调用名为 `netx__*`(驼峰)。决策树见技能正文。
- 多台 CLI:**一次** `execManagedNe`(`ne_ids` / `nms_ne_ids` / `targets`)。
- 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等)。
## 知识库双平面(KB 已配置时 — 铁律)
- **总部包只读**:正式 RCA、theory、packet、`_common`、`_skills`、总部命令库 — 检索用,不改。
- **唯一可写区** `{kbLocal}`:`refs/`(本网产品知识)/ `memories/`(过程记忆)/ `drafts/`(案例草稿)/ `suggestions/`(反哺总部)。
- **写文件只走宿主工具**:`netx__kbWriteRef` / `kbWriteMemory` / `kbWriteDraft` / `kbWriteSuggestion` / `kbUpdateLocal` / `kbDeleteLocal`。**禁止**对 KB 用 workspace `Write` / bash。
### refs — 本网产品知识(可进化核心)
- 排障涉及某网元时:**先读** `refs/devices/<host_name>/`;没有则 netx 取证后立刻 `kbWriteRef` 建档/补全。
- `area=devices` 必须带 `device=host_name`(禁 UUID);画像主文件 slug=`PROFILE`。
- 台账/汇总 → `inventory`;跨机拓扑 → `topology`;业务域 → `business`;本网命令集 → `commands`;速查 → `handbooks`。
- **设备事实写 refs,过程经验写 memories**,不要把台账塞进日记。
### memories — 随手记
任务中一旦出现可复用价值(经验、踩坑、口诀、误判纠正),立刻 `kbWriteMemory`;宁短勿丢。
- `note` / `rca_review` / `ai_trace`
### drafts / suggestions
未闭环案例 → `kbWriteDraft`;总部通用层有误或工具/流程改进 → `kbWriteSuggestion`。
- 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等);KB 现场可写区用 `netx__kbWrite*` / `UpdateLocal` / `DeleteLocal`(勿对 KB 用沙箱 Write)。
## 委派(默认 subagent,活多再拆)

View file

@ -46,25 +46,7 @@
## 工具
- netx 调用名为 `netx__*`(驼峰)。决策树见技能正文。
- 多台 CLI:**一次** `execManagedNe`(`ne_ids` / `nms_ne_ids` / `targets`)。
- 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等)。
## 知识库双平面(KB 已配置时 — 铁律)
- **总部包只读**:正式 RCA、theory、packet、`_common`、`_skills`、总部命令库 — 检索用,不改。
- **唯一可写区** `{kbLocal}`:`refs/`(本网产品知识)/ `memories/`(过程记忆)/ `drafts/`(案例草稿)/ `suggestions/`(反哺总部)。
- **写文件只走宿主工具**:`netx__kbWriteRef` / `kbWriteMemory` / `kbWriteDraft` / `kbWriteSuggestion` / `kbUpdateLocal` / `kbDeleteLocal`。**禁止**对 KB 用 workspace `Write` / bash。
### refs — 本网产品知识(可进化核心)
- 排障涉及某网元时:**先读** `refs/devices/<host_name>/`;没有则 netx 取证后立刻 `kbWriteRef` 建档/补全。
- `area=devices` 必须带 `device=host_name`(禁 UUID);画像主文件 slug=`PROFILE`。
- 台账/汇总 → `inventory`;跨机拓扑 → `topology`;业务域 → `business`;本网命令集 → `commands`;速查 → `handbooks`。
- **设备事实写 refs,过程经验写 memories**,不要把台账塞进日记。
### memories — 随手记
任务中一旦出现可复用价值(经验、踩坑、口诀、误判纠正),立刻 `kbWriteMemory`;宁短勿丢。
- `note` / `rca_review` / `ai_trace`
### drafts / suggestions
未闭环案例 → `kbWriteDraft`;总部通用层有误或工具/流程改进 → `kbWriteSuggestion`。
- 工作区:与标准模式相同(shell、搜索、计划、todo、web、委派等);KB 现场可写区用 `netx__kbWrite*` / `UpdateLocal` / `DeleteLocal`(勿对 KB 用沙箱 Write)。
## 委派(默认 subagent,活多再拆)
你本人逻辑上全能。默认自己干;只有活明显偏多、且可并行时,才用 `subagent`(必要时 `subagent_fork`)拆开干。

View file

@ -1,14 +1,14 @@
# Mirrored from netx/skills 鈥?do not long-edit here
# Mirrored from netx/skills — do not long-edit here
Canonical: `D:\project\chatgpt\netx\skills`
`powershell
```powershell
powershell -File .\scripts\sync-skills-from-netx.ps1
`
```
Runtime prefers `NETX_SKILLS_ROOT` or sibling `../netx/skills`.
Groups: **ops** (`netx-ops`) + **topology** (`netx-topology`). One group 鈫?one skill.
Groups: **ops** (`netx-ops`) + **topology** (`netx-topology`). One group ↔ one skill.
**Keep netx-ops pure netx** (alarms / inventory / CLI). Knowledge-base triage belongs in
operator-subset `_skills/kb-*` packs registered by the plugin 鈥?do **not** write KB linkage
operator-subset `_skills/kb-*` packs registered by the plugin — do **not** write KB linkage
back into `netx-ops`.

View file

@ -1,66 +0,0 @@
---
name: netx-biz-monitor
description: >-
Overnight cutover / biz_state monitor analysis via netxops host tools
(netx__listBizMonitors / listBizMonitorBatches / getBizMonitor*). List
projects/tasks then batches when no id is given; read definitions, board,
reds/diffs with evidence (device-raw A/B, show commands), and judge tool
false-positive vs real business fault. Does not create templates/tasks.
---
# netx-biz-monitor(割接 / 业务监控只读分析)
人配好监控与任务;AI 当晚做归因。
工具在 **netxops**(`netx__*`),直连 netx REST,**不是** netx-mcp。
## 工具(能力组 `bizMonitor`)
| 工具 | 用途 |
|------|------|
| `netx__listBizMonitors` | **入口目录**:列出割接/监控对比项目 + 业务监控任务(**无需** project_id) |
| `netx__listBizMonitorBatches` | **批次目录**:`project_id`→割接批次;`task_id`→采集批次;`kind=runs`→evaluate run |
| `netx__getBizMonitorContext` | 任务定义:项目、监控/对比模板、归一化规则、端口映射、关联任务与命令项 |
| `netx__getBizMonitorBoard` | 批次进度、evaluate run、sheet 卡、verdict 统计 |
| `netx__listBizMonitorReds` | 红单 + **evidence** |
| `netx__getBizMonitorDiffs` | run 明细(可滤 color=red) |
| `netx__getBizCollectBatch` | 某次采集:commands / metrics |
| `netx__getBizCollectCommandRaw` | 单命令全量 show 输出 |
交叉验证真障时再用 ops:`netx__execManagedNe` / 拓扑路径。
## 字段约定(必守)
- **`old_key` / `new_key`**:设备采集原文 **A/B**(大屏与结论用这个)
- **`match_*` / evidence.iface.normalized|map_***:内部配对,**不要**把映射后的 BB 说成两侧端口
- **evidence.*.command.raw_command**:实际 show 命令
- **evidence.*.device / collect**:哪台设备、何时采的
## 推荐顺序
1. 无 id → `listBizMonitors`(`kind=projects|tasks|all`,可加 `q` / `purpose`)
2. `listBizMonitorBatches(project_id)` 或 `(task_id)` — 拿到 **完整 batch_id**;需要 run 时再 `kind=runs` / `include_runs`
3. `getBizMonitorContext(project_id)` — 弄清盯什么
4. `getBizMonitorBoard(batch_id)` — 进度 / missing / skipped
5. `listBizMonitorReds` 或 `getBizMonitorDiffs(color=red)`
6. 看 evidence:parse 失败?current_missing?映射 miss?归一化误伤?
7. 需要原文 → `getBizCollectCommandRaw`
8. 像真障 → `execManagedNe` 现场核对
## Verdict 速查(skill 解释,工具不硬编码)
| verdict | 含义(窗口内) |
|---------|----------------|
| migrated | expect 项已迁到新侧 |
| anomaly | 异常(多为红) |
| unfinished | expect 未完成(验收常红) |
| migrating / expected / ok | 过程态 / 正常 |
| lost / not_involved | 丢失或无关 |
## 误报优先查
1. `parse_status` 非 ok / 行数为 0
2. board:`current_missing` / `collect_skipped`
3. 端口映射漏配、归一化前后不一致导致假 anomaly
4. 再谈真实业务问题
配置整改与关红单由人做;本 skill **只读分析**。

View file

@ -34,7 +34,6 @@ const entries = [
{ entry: join(root, 'src/agent-tools-nms.ts'), out: join(outDir, 'agent-tools-nms.js') },
{ entry: join(root, 'src/agent-tools-common.ts'), out: join(outDir, 'agent-tools-common.js') },
{ entry: join(root, 'src/agent-tools-topology.ts'), out: join(outDir, 'agent-tools-topology.js') },
{ entry: join(root, 'src/agent-tools-biz-monitor.ts'), out: join(outDir, 'agent-tools-biz-monitor.js') },
]
mkdirSync(outDir, { recursive: true })

View file

@ -17,7 +17,7 @@ $src = Join-Path $NetxRoot "skills"
$dst = Join-Path $opsRoot "presets\netxops\skills"
if (-not (Test-Path $src)) { throw "missing $src" }
foreach ($group in @("ops", "topology", "biz-monitor")) {
foreach ($group in @("ops", "topology")) {
$from = Join-Path $src $group
$to = Join-Path $dst $group
if (-not (Test-Path $from)) { throw "missing group $from" }

View file

@ -1,15 +0,0 @@
/**
* Forced bizMonitor-group tools + skill (cutover / biz_state read analysis).
*
* @module dsh-netxops/tools-biz-monitor
*/
import type { Context } from '@deepseek-ai/cordis'
import { applyGroupToolsPlugin } from './netx/group-tools-plugin.ts'
export const name = 'netxops-tools-biz-monitor'
export const inject = ['tools']
export function apply(ctx: Context): void {
applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['bizMonitor'] })
}

View file

@ -564,20 +564,6 @@ export function NetxopsCard(props: NetxopsCardProps) {
props.edit('groupTopologyPublic', checked ? 'true' : 'false')
}}
/>
<CapabilityGroupBlock
title={t('groupBizMonitor')}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupBizMonitorInPreset}
published={state.groupBizMonitorPublic}
disabled={disabled}
onEditInPreset={(checked) => {
props.edit('groupBizMonitorInPreset', checked ? 'true' : 'false')
}}
onEditPublic={(checked) => {
props.edit('groupBizMonitorPublic', checked ? 'true' : 'false')
}}
/>
</div>
<div className="dsh-nx-settings-card">

View file

@ -218,9 +218,7 @@ export class CardForm<T> {
private async store(field: string, value: unknown): Promise<boolean> {
await this.scope.set(field, value)
// `settings.mutate` can settle without throwing on a refused write; confirm
// via the resolved section value (user layer alone can lag or omit keys).
return this.sectionValue(field) === value || this.userLayer()?.[field] === value
return this.userLayer()?.[field] === value
}
private stage(field: string, edit: StagedEdit): void {

View file

@ -17,7 +17,6 @@ import {
} from './alarm-push-status-view.ts'
import {
fetchKbStatus,
reloadKbStatus,
resolveKbPath,
type KbSnapshot,
} from './kb-status-view.ts'
@ -57,8 +56,6 @@ export interface NetxopsSettings {
groupOpsPublic?: boolean
groupTopologyInPreset?: boolean
groupTopologyPublic?: boolean
groupBizMonitorInPreset?: boolean
groupBizMonitorPublic?: boolean
nmsProvider?: string
/** Operator-subset knowledge package root (Host-local path). */
kbRoot?: string
@ -84,8 +81,6 @@ export interface NetxopsCardState extends CardShell {
groupOpsPublic: CardFieldState
groupTopologyInPreset: CardFieldState
groupTopologyPublic: CardFieldState
groupBizMonitorInPreset: CardFieldState
groupBizMonitorPublic: CardFieldState
nmsProvider: CardFieldState
kbRoot: CardFieldState
groupKbInPreset: CardFieldState
@ -160,9 +155,7 @@ export class NetxopsCardController {
private kbDirectoryPicker: DirectoryPickerRemote | undefined
private kbBrowseInFlight = false
private kbStatusInFlight = false
private kbReloadInFlight = false
private kbUiError: string | null = null
private lastSyncedKbRoot: string | undefined = undefined
private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
private sessionsExportStatus: SessionsExportStatus | null = null
private sessionsExportBusy = false
@ -191,8 +184,6 @@ export class NetxopsCardController {
booleanField('groupOpsPublic'),
booleanField('groupTopologyInPreset'),
booleanField('groupTopologyPublic'),
booleanFieldPersistFalse('groupBizMonitorInPreset'),
booleanField('groupBizMonitorPublic'),
booleanFieldPersistFalse('groupKbInPreset'),
booleanField('groupKbPublic'),
booleanField('alarmPushEnabled'),
@ -208,7 +199,6 @@ export class NetxopsCardController {
scope.subscribe(() => {
void this.readCredential()
void this.refreshKbPreview()
void this.syncKbRootFromSettings()
})
void this.readCredential()
}
@ -256,7 +246,7 @@ export class NetxopsCardController {
}
this.startStatusPoll()
void this.refreshAlarmPushStatus()
void this.reloadKbFromHost()
void this.refreshKbStatus()
void this.refreshKbPreview()
void this.refreshImDeliveryCatalog()
void this.refreshSessionsExportStatus()
@ -374,7 +364,18 @@ export class NetxopsCardController {
this.kbStatusInFlight = true
try {
const next = await fetchKbStatus(call)
this.applyKbSnapshot(next)
const prev = this.kbStatus
if (
prev
&& prev.status === next.status
&& prev.realRoot === next.realRoot
&& prev.operatorName === next.operatorName
&& prev.country === next.country
&& prev.version === next.version
&& prev.errorMessage === next.errorMessage
) return
this.kbStatus = next
this.store.set(this.projection())
} catch {
// Keep last good snapshot; next poll retries.
} finally {
@ -382,50 +383,6 @@ export class NetxopsCardController {
}
}
/**
* Ask Host to re-resolve kbRoot from live settings and republish.
* Prefer this after browse/save so the badge cannot stick on a stale snapshot.
*/
private async reloadKbFromHost(): Promise<void> {
const call = this.rpcCall
if (call === undefined || this.kbReloadInFlight) return
this.kbReloadInFlight = true
try {
const next = await reloadKbStatus(call)
this.applyKbSnapshot(next)
this.lastSyncedKbRoot = (this.scope.getSnapshot().value?.kbRoot ?? '').trim()
} catch {
// Fall back to published status poll.
void this.refreshKbStatus()
} finally {
this.kbReloadInFlight = false
}
}
private applyKbSnapshot(next: KbSnapshot): void {
const prev = this.kbStatus
if (
prev
&& prev.status === next.status
&& prev.realRoot === next.realRoot
&& prev.operatorName === next.operatorName
&& prev.country === next.country
&& prev.version === next.version
&& prev.errorMessage === next.errorMessage
) return
this.kbStatus = next
this.store.set(this.projection())
}
/** When settings document kbRoot changes, force Host reload (covers footer Save / Reset). */
private async syncKbRootFromSettings(): Promise<void> {
if (this.rpcCall === undefined) return
const saved = (this.scope.getSnapshot().value?.kbRoot ?? '').trim()
if (saved === this.lastSyncedKbRoot) return
this.lastSyncedKbRoot = saved
await this.reloadKbFromHost()
}
private async refreshKbPreview(): Promise<void> {
const call = this.rpcCall
if (call === undefined) return
@ -459,38 +416,6 @@ export class NetxopsCardController {
}
}
/**
* Persist kbRoot then force Host reload so the badge matches the bound package.
*/
private async commitKbRoot(path: string): Promise<void> {
const trimmed = path.trim()
this.kbUiError = null
this.store.set(this.projection())
await this.form.save()
const shell = this.form.shell()
const saved = (this.scope.getSnapshot().value?.kbRoot ?? '').trim()
if (shell.failed || saved !== trimmed) {
this.kbUiError = saved === trimmed
? 'kbRoot save failed'
: `kbRoot save did not land (saved="${saved || '(empty)'}")`
this.store.set(this.projection())
const call = this.rpcCall
if (call !== undefined && trimmed !== '') {
try {
this.kbPreview = await resolveKbPath(call, trimmed)
this.store.set(this.projection())
} catch {
// keep error banner
}
}
return
}
this.kbPreview = null
this.lastSyncedKbRoot = undefined
await this.reloadKbFromHost()
void this.refreshKbPreview()
}
/**
* Open the Host OS directory chooser (or /api directoryPicker/pick fallback)
* and write the path into kbRoot.
@ -503,7 +428,12 @@ export class NetxopsCardController {
const applyPath = (path: string): void => {
this.form.actions().edit('kbRoot', path)
void this.commitKbRoot(path)
void this.refreshKbPreview()
// Selecting a folder should land immediately — don't require a separate Save.
void this.form.save().then(() => {
void this.refreshKbStatus()
void this.refreshKbPreview()
})
}
const fail = (error: unknown): void => {
@ -605,8 +535,6 @@ export class NetxopsCardController {
groupOpsPublic: this.form.field('groupOpsPublic'),
groupTopologyInPreset: this.form.field('groupTopologyInPreset'),
groupTopologyPublic: this.form.field('groupTopologyPublic'),
groupBizMonitorInPreset: this.form.field('groupBizMonitorInPreset'),
groupBizMonitorPublic: this.form.field('groupBizMonitorPublic'),
groupKbInPreset: this.form.field('groupKbInPreset'),
groupKbPublic: this.form.field('groupKbPublic'),
alarmPushEnabled: this.form.field('alarmPushEnabled'),

View file

@ -10,7 +10,6 @@ import type { AlarmPushRpcCall } from './alarm-push-status-view.ts'
export const NETXOPS_RPC_CHANNEL = '/netxops'
export const KB_STATUS_ENDPOINT = 'kb.status'
export const KB_RELOAD_ENDPOINT = 'kb.reload'
export const KB_RESOLVE_ENDPOINT = 'kb.resolve'
export type { KbSnapshot, KbStatus }
@ -51,10 +50,6 @@ export function asKbSnapshot(value: unknown): KbSnapshot {
}
}
function asRpcOk(result: unknown): result is { ok: true; value?: unknown } {
return result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true
}
/**
* Fetch the published KB snapshot from the Host.
*/
@ -63,37 +58,10 @@ export async function fetchKbStatus(
signal?: AbortSignal,
): Promise<KbSnapshot> {
const result = await call(NETXOPS_RPC_CHANNEL, KB_STATUS_ENDPOINT, {}, signal)
if (asRpcOk(result)) {
return asKbSnapshot(result.value)
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
return asKbSnapshot((result as { value?: unknown }).value)
}
// Do not pretend "unconfigured" on transport/shape miss — keep empty identity
// but mark error so the badge is not stuck on the soft "pure netx" copy.
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === false) {
const message = String(
(result as { error?: { message?: string } }).error?.message ?? 'kb.status rpc failed',
)
return { ...unconfiguredKbSnapshot(), status: 'error', errorMessage: message }
}
return {
...unconfiguredKbSnapshot(),
status: 'error',
errorMessage: 'kb.status unavailable',
}
}
/**
* Force Host to re-resolve `kbRoot` from live settings and republish.
*/
export async function reloadKbStatus(
call: AlarmPushRpcCall,
signal?: AbortSignal,
): Promise<KbSnapshot> {
const result = await call(NETXOPS_RPC_CHANNEL, KB_RELOAD_ENDPOINT, {}, signal)
if (asRpcOk(result)) {
return asKbSnapshot(result.value)
}
// Older hosts without kb.reload — fall back to status / resolve.
return fetchKbStatus(call, signal)
return unconfiguredKbSnapshot()
}
/**
@ -106,17 +74,13 @@ export async function resolveKbPath(
): Promise<KbSnapshot> {
// Prefer flat `{ path }` (netxops channel); also try `{ args: { path } }`.
let result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { path }, signal)
if (!asRpcOk(result)) {
if (!(result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true)) {
result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { args: { path } }, signal)
}
if (asRpcOk(result)) {
return asKbSnapshot(result.value)
}
return {
...unconfiguredKbSnapshot(),
status: 'error',
errorMessage: 'kb.resolve unavailable',
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
return asKbSnapshot((result as { value?: unknown }).value)
}
return unconfiguredKbSnapshot()
}
/** Badge tone for the settings card. */

View file

@ -21,7 +21,6 @@ export type NetxopsLocaleKey =
| 'nmsProviderHint'
| 'groupOps'
| 'groupTopology'
| 'groupBizMonitor'
| 'groupKb'
| 'groupInPreset'
| 'groupPublic'
@ -99,7 +98,6 @@ export const en: Record<NetxopsLocaleKey, string> = {
nmsProviderHint: 'zte-ume',
groupOps: 'ops',
groupTopology: 'topology',
groupBizMonitor: 'bizMonitor',
groupKb: 'Knowledge skills',
groupInPreset: 'In Netx Ops preset',
groupPublic: 'Publish to other presets',
@ -108,7 +106,7 @@ export const en: Record<NetxopsLocaleKey, string> = {
kbBrowse: 'Browse…',
kbBrowseUnavailable: 'Directory picker unavailable — paste an absolute folder path, then Save.',
kbBrowseFailed: 'Browse failed: {detail}',
kbSaveHint: 'Browse writes and saves automatically, then reloads Host KB status. Manual paste still needs Save.',
kbSaveHint: 'Browse writes and saves automatically. Manual paste still needs Save.',
kbStatusConfigured: 'Knowledge base: {operator} ({country}) v{version}',
kbStatusUnconfigured: 'Knowledge base: not configured (pure netx)',
kbStatusError: 'Knowledge base: error — {detail}',
@ -178,7 +176,6 @@ export const zh: Record<NetxopsLocaleKey, string> = {
nmsProviderHint: 'zte-ume',
groupOps: 'ops',
groupTopology: 'topology',
groupBizMonitor: 'bizMonitor',
groupKb: '知识库技能',
groupInPreset: '在 Netx Ops 预设中启用',
groupPublic: '对其他预设公开',
@ -187,7 +184,7 @@ export const zh: Record<NetxopsLocaleKey, string> = {
kbBrowse: '浏览…',
kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对目录路径后点保存。',
kbBrowseFailed: '浏览失败:{detail}',
kbSaveHint: '点「浏览」选目录后会自动写入、保存,并刷新宿主知识库状态;若是手粘路径仍需点保存。',
kbSaveHint: '点「浏览」选目录后会自动写入并保存;若是手粘路径仍需点保存。',
kbStatusConfigured: '知识库: {operator}({country}) v{version}',
kbStatusUnconfigured: '知识库: 未配置(纯 netx)',
kbStatusError: '知识库: 错误 — {detail}',

View file

@ -35,8 +35,8 @@ import {
publishAlarmPushStatus,
resetAlarmPushStatus,
} from './netx/alarm-push-status.ts'
import { dispatchAlarmToSinks } from './netx/alarm-dispatch.ts'
import { resetAlarmSession } from './netx/alarm-session.ts'
import { deliverAlarmToIm } from './netx/alarm-im.ts'
import { deliverAlarmToSession, resetAlarmSession } from './netx/alarm-session.ts'
import {
capabilityGroupsFromSettings,
groupsForPlane,
@ -72,7 +72,6 @@ import {
sessionsExportHeadResponse,
sessionsExportResponse,
} from './netx/session-export.ts'
import { mountNetxopsWebRoute } from './netx/netxops-web-rpc.ts'
import { registerNetxTools } from './netx/tools.ts'
/** Cordis plugin name. */
@ -152,10 +151,6 @@ export interface Config {
groupTopologyInPreset: boolean
/** Publish topology tools/skills to other presets (default off). */
groupTopologyPublic: boolean
/** biz-monitor (cutover / biz_state read) tools in the Netx Ops preset (default on). */
groupBizMonitorInPreset: boolean
/** Publish biz-monitor tools/skills to other presets (default off). */
groupBizMonitorPublic: boolean
/**
* Absolute (or Host-local) path to an operator-subset knowledge package.
* Empty = unconfigured. Plugin locates `MANIFEST.json` (≤3 levels) and
@ -187,8 +182,6 @@ export const Config: z<Config> = z.object({
groupOpsPublic: z.boolean().default(false),
groupTopologyInPreset: z.boolean().default(false),
groupTopologyPublic: z.boolean().default(false),
groupBizMonitorInPreset: z.boolean().default(true),
groupBizMonitorPublic: z.boolean().default(false),
kbRoot: z.string().default(''),
groupKbInPreset: z.boolean().default(true),
groupKbPublic: z.boolean().default(false),
@ -314,18 +307,18 @@ export function apply(ctx: Context, config: Config = Config({})): void {
imBotId: current.imBotId ?? '',
imTargetId: current.imTargetId ?? '',
})
// Prefer target list presence; UI also mirrors this into alarmDeliverIm.
const deliverIm = imTargets.length > 0
stopAlarmPush = startAlarmPushClient({
apiUrl,
token,
logger: ctx.logger,
onAlarm: async (payload) => {
// Parallel sinks: a stuck/failing DSH sticky session must not block WhatsApp/IM.
await dispatchAlarmToSinks(ctx, payload, {
deliverDsh,
deliverIm,
imTargets,
if (deliverDsh) {
await deliverAlarmToSession(ctx, payload, lang)
}
await deliverAlarmToIm(ctx, payload, {
enabled: deliverIm,
targets: imTargets,
lang,
})
},
@ -602,112 +595,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}, 'netxops: dispose public skills')
})
const handleNetxopsRpc = async (endpoint: string, payload?: unknown): Promise<unknown> => {
if (endpoint === 'alarm-push.status') {
return { ok: true, value: getAlarmPushStatus() }
}
if (endpoint === 'kb.status') {
return { ok: true, value: getKbContext() }
}
if (endpoint === 'kb.reload') {
const current = source()
const kb = resolveKbRoot(current.kbRoot ?? '')
publishKbContext(kb)
applyKbEnv(kb)
if (kb.status === 'configured') {
ctx.logger.info(
'netxops: kb.reload → %s (%s / %s v%s)',
kb.realRoot,
kb.operatorName,
kb.country,
kb.version,
)
} else if (kb.status === 'error') {
ctx.logger.warn('netxops: kb.reload error — %s', kb.errorMessage)
} else {
ctx.logger.info('netxops: kb.reload → unconfigured (kbRoot empty)')
}
return { ok: true, value: kb }
}
if (endpoint === 'kb.resolve') {
const path = extractKbResolvePath(payload)
return { ok: true, value: resolveKbRoot(path) }
}
if (endpoint === 'sessions.export.status') {
const value = await getSessionsExportStatus(ctx)
return { ok: true, value }
}
if (endpoint === 'im-delivery.catalog') {
type DshImCatalog = { listDeliveryCatalog?: () => Promise<unknown> }
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
? (ctx as { get: (name: string) => unknown }).get('dshIm') as DshImCatalog | undefined
: undefined
const im = fromGet ?? (ctx as { dshIm?: DshImCatalog }).dshIm
if (!im || typeof im.listDeliveryCatalog !== 'function') {
return {
ok: true,
value: {
available: false,
options: [],
reasonCode: 'im_catalog_unavailable',
hint: 'dsh-im-ops missing or outdated — install ≥ops.24 for delivery picker',
},
}
}
try {
const options = await im.listDeliveryCatalog()
return {
ok: true,
value: {
available: true,
options: Array.isArray(options) ? options : [],
},
}
} catch (error) {
return {
ok: true,
value: {
available: false,
options: [],
hint: error instanceof Error ? error.message : String(error),
},
}
}
}
return { ok: false, error: { code: 'bad-request', message: 'Unknown endpoint.' } }
}
// DSH ≥0.1.5: mount /netxops on this plugin's webServer inject (connection.rpc.handle no longer registers).
ctx.inject(['webServer', 'connection'], (mountCtx) => {
mountCtx.effect(() => {
const direct = mountNetxopsWebRoute(
mountCtx,
NETXOPS_RPC_CHANNEL,
(endpoint, payload) => handleNetxopsRpc(endpoint, payload),
)
if (direct !== null) {
mountCtx.logger.info('netxops: /netxops rpc mounted on webServer')
return direct
}
type RpcHandle = (
channel: string,
handler: (endpoint: string, payload?: unknown) => Promise<unknown>,
) => (() => void) | Promise<void>
const rpc = (mountCtx.connection as { rpc?: { handle?: RpcHandle } } | undefined)?.rpc
if (rpc && typeof rpc.handle === 'function') {
mountCtx.logger.info('netxops: /netxops rpc mounted via connection.rpc.handle (legacy)')
const legacy = rpc.handle(
NETXOPS_RPC_CHANNEL,
(endpoint, payload) => handleNetxopsRpc(endpoint, payload),
)
return () => { void legacy() }
}
mountCtx.logger.warn('netxops: /netxops rpc unavailable — settings card status disabled')
return () => {}
}, 'netxops: /netxops web rpc')
})
// Browser card: sessions export download via shared /api Fetch route.
// Browser card: alarm-push status + IM catalog (RPC) and all-sessions ZIP (Fetch).
ctx.inject(['connection'], (connCtx) => {
const connection = connCtx.connection as {
rpc?: { handle?: (
@ -722,6 +610,72 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}) => () => Promise<void>
}
} | undefined
const rpc = connection?.rpc
if (!rpc || typeof rpc.handle !== 'function') {
connCtx.logger.warn('netxops: connection.rpc.handle unavailable — alarm status UI disabled')
} else {
connCtx.effect(() => {
const dispose = rpc.handle(
NETXOPS_RPC_CHANNEL,
async (endpoint: string, payload?: unknown) => {
if (endpoint === 'alarm-push.status') {
return { ok: true, value: getAlarmPushStatus() }
}
if (endpoint === 'kb.status') {
return { ok: true, value: getKbContext() }
}
if (endpoint === 'kb.resolve') {
const path = extractKbResolvePath(payload)
return { ok: true, value: resolveKbRoot(path) }
}
if (endpoint === 'sessions.export.status') {
const value = await getSessionsExportStatus(ctx)
return { ok: true, value }
}
if (endpoint === 'im-delivery.catalog') {
type DshImCatalog = { listDeliveryCatalog?: () => Promise<unknown> }
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
? (ctx as { get: (name: string) => unknown }).get('dshIm') as DshImCatalog | undefined
: undefined
const im = fromGet ?? (ctx as { dshIm?: DshImCatalog }).dshIm
if (!im || typeof im.listDeliveryCatalog !== 'function') {
return {
ok: true,
value: {
available: false,
options: [],
reasonCode: 'im_catalog_unavailable',
hint: 'dsh-im-ops missing or outdated — install ≥ops.24 for delivery picker',
},
}
}
try {
const options = await im.listDeliveryCatalog()
return {
ok: true,
value: {
available: true,
options: Array.isArray(options) ? options : [],
},
}
} catch (error) {
return {
ok: true,
value: {
available: false,
options: [],
hint: error instanceof Error ? error.message : String(error),
},
}
}
}
return { ok: false, error: { code: 'bad-request', message: 'Unknown endpoint.' } }
},
)
return () => { void dispose() }
}, 'netxops: alarm-push status rpc')
}
const fetchApi = connection?.fetch
if (!fetchApi || typeof fetchApi.register !== 'function') {
connCtx.logger.warn('netxops: connection.fetch.register unavailable — sessions export download disabled')

View file

@ -1,54 +0,0 @@
/**
* Fan-out one key-alarm to optional DSH session + IM sinks.
* Sinks run in parallel so a failing/slow session path cannot block WhatsApp/IM.
*/
import type { Context } from '@deepseek-ai/cordis'
import { deliverAlarmToIm, type AlarmImDeliveryOptions } from './alarm-im.ts'
import { deliverAlarmToSession } from './alarm-session.ts'
import type { KeyAlarmPayload } from './alarm-push.ts'
import type { ImDeliveryTarget } from './im-targets.ts'
export interface AlarmSinkDispatchOptions {
deliverDsh: boolean
deliverIm: boolean
imTargets: readonly ImDeliveryTarget[]
lang: string
}
export interface AlarmSinkFns {
toSession?: typeof deliverAlarmToSession
toIm?: typeof deliverAlarmToIm
}
/**
* Deliver to every enabled sink; never let one sink's rejection cancel the others.
* @returns settled results in order: [session?, im] (session omitted when deliverDsh is false).
*/
export async function dispatchAlarmToSinks(
ctx: Context,
payload: KeyAlarmPayload,
options: AlarmSinkDispatchOptions,
sinks: AlarmSinkFns = {},
): Promise<PromiseSettledResult<void>[]> {
const toSession = sinks.toSession ?? deliverAlarmToSession
const toIm = sinks.toIm ?? deliverAlarmToIm
const jobs: Array<Promise<void>> = []
if (options.deliverDsh) {
jobs.push(toSession(ctx, payload, options.lang))
}
const imOptions: AlarmImDeliveryOptions = {
enabled: options.deliverIm,
targets: options.imTargets,
lang: options.lang,
}
jobs.push(toIm(ctx, payload, imOptions))
const results = await Promise.allSettled(jobs)
for (const result of results) {
if (result.status === 'rejected') {
ctx.logger.warn('netxops alarm-push: sink failed: %s', result.reason)
}
}
return results
}

View file

@ -5,11 +5,10 @@
*
* - `ops` → skill `netx-ops` — NMS alarms/inventory/SQL + managed CLI + findTopologyPaths
* - `topology` → skill `netx-topology` — canvas / fabric / dual_unit / layout
* - `bizMonitor` → skill `netx-biz-monitor` — cutover / biz_state read + overnight analysis
*/
/** Stable group ids used in settings, skill dirs, and registration filters. */
export type NetxCapabilityGroupId = 'ops' | 'topology' | 'bizMonitor'
export type NetxCapabilityGroupId = 'ops' | 'topology'
/** Per-group exposure knobs. */
export interface NetxGroupExposure {
@ -28,17 +27,14 @@ export interface NetxCapabilityGroupSettingsFields {
groupOpsPublic: boolean
groupTopologyInPreset: boolean
groupTopologyPublic: boolean
groupBizMonitorInPreset: boolean
groupBizMonitorPublic: boolean
}
/**
* Default: ops in Ops preset; topology / bizMonitor and all public off.
* Default: ops in Ops preset; topology and all public off.
*/
export const DEFAULT_CAPABILITY_GROUPS: NetxCapabilityGroups = Object.freeze({
ops: Object.freeze({ inPreset: true, public: false }),
topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false }),
})
/**
@ -80,29 +76,17 @@ export const TOOLS_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, readonly str
'netx__analyzeTopologyViewLayout',
'netx__sinkTopologyDualUnits',
]),
bizMonitor: Object.freeze([
'netx__listBizMonitors',
'netx__listBizMonitorBatches',
'netx__getBizMonitorContext',
'netx__getBizMonitorBoard',
'netx__listBizMonitorReds',
'netx__getBizMonitorDiffs',
'netx__getBizCollectBatch',
'netx__getBizCollectCommandRaw',
]),
})
/** Skill directory name under skills root / `presets/netxops/skills/<group>/`. */
export const SKILL_DIR_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, string>> = Object.freeze({
ops: 'ops',
topology: 'topology',
bizMonitor: 'biz-monitor',
})
export const CAPABILITY_GROUP_IDS: readonly NetxCapabilityGroupId[] = Object.freeze([
'ops',
'topology',
'bizMonitor',
])
/**
@ -142,11 +126,6 @@ export function capabilityGroupsFromSettings(
}
}
let bizMonitorInPreset = true
if (src.groupBizMonitorInPreset !== undefined) {
bizMonitorInPreset = src.groupBizMonitorInPreset !== false
}
return {
ops: {
inPreset: opsInPreset,
@ -161,10 +140,6 @@ export function capabilityGroupsFromSettings(
public: src.groupTopologyPublic === true
|| src.groupTopologyLayoutPublic === true,
},
bizMonitor: {
inPreset: bizMonitorInPreset,
public: src.groupBizMonitorPublic === true,
},
}
}
@ -202,9 +177,9 @@ export function groupsForced(
* Tool name allow-list for the given groups.
*/
export function toolNamesForGroups(groupIds: readonly NetxCapabilityGroupId[]): Set<string> {
const out = new Set<string>()
const names = new Set<string>()
for (const id of groupIds) {
for (const name of TOOLS_BY_GROUP[id] ?? []) out.add(name)
for (const tool of TOOLS_BY_GROUP[id]) names.add(tool)
}
return out
return names
}

View file

@ -3,7 +3,6 @@
*/
import { type NetxClient, quoteNeId, type NetxJson } from './http.ts'
import { omitUndefined } from './json-safe.ts'
const EXEC_MAX_COMMANDS = 5
@ -331,436 +330,3 @@ export async function findTopologyPaths(client: NetxClient, args: NetxJson, sign
else body.to_managed_ne_id = toMid
return client.post('/v1/topology/fabric/paths', body, signal, 30_000)
}
// ── bizMonitor group (cutover / biz_state read) ─────────────────────────────
function taskBriefNe(brief: unknown): string {
const rec = asRecord(brief)
const name = typeof rec.ne_name === 'string' ? rec.ne_name.trim() : ''
const ip = typeof rec.ne_ip === 'string' ? rec.ne_ip.trim() : ''
if (name && ip) return `${name} (${ip})`
return name || ip
}
function slimMigrationProject(row: NetxJson): NetxJson {
const mt = asRecord(row.monitor_template)
return omitUndefined({
id: row.id,
name: row.name,
status: row.status,
note: row.note,
monitor_template_id: row.monitor_template_id,
monitor_template_name: typeof mt.name === 'string' ? mt.name : '',
old_task_id: row.old_task_id,
new_task_id: row.new_task_id,
old_hf_task_id: row.old_hf_task_id,
new_hf_task_id: row.new_hf_task_id,
old_ne: taskBriefNe(row.old_task),
new_ne: taskBriefNe(row.new_task),
hf_interval_sec: row.hf_interval_sec,
hf_start_at: row.hf_start_at,
hf_end_at: row.hf_end_at,
updated_at: row.updated_at,
})
}
function matchesBizMonitorQ(row: NetxJson, q: string): boolean {
if (!q) return true
const hay = [
row.id, row.name, row.note, row.status, row.ne_name, row.ne_ip, row.ne_id,
row.old_ne, row.new_ne, row.monitor_template_name, row.purpose, row.vendor,
]
.map((v) => (typeof v === 'string' || typeof v === 'number' ? String(v).toLowerCase() : ''))
.join(' ')
return hay.includes(q)
}
/**
* Catalog entry: list cutover projects and/or biz_state tasks (no project_id required).
* Use this first when the user did not give an id; then call getBizMonitorContext.
*/
export async function listBizMonitors(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const kindRaw = str(args, 'kind', 'all').trim().toLowerCase() || 'all'
const kind = kindRaw === 'project' ? 'projects'
: kindRaw === 'task' ? 'tasks'
: kindRaw
if (kind !== 'all' && kind !== 'projects' && kind !== 'tasks') {
return { ok: false, error: 'kind_invalid', detail: 'kind must be all|projects|tasks' }
}
const purpose = str(args, 'purpose').trim()
const status = str(args, 'status').trim().toLowerCase()
const q = str(args, 'q').trim().toLowerCase()
const limit = clampInt(num(args, 'limit') ?? 100, 100, 1, 500)
const wantProjects = kind === 'all' || kind === 'projects'
const wantTasks = kind === 'all' || kind === 'tasks'
const fetches: Array<Promise<NetxJson>> = []
if (wantProjects) fetches.push(client.get('/v1/biz-migration/projects', {}, signal))
else fetches.push(Promise.resolve({ ok: true, data: { items: [] } }))
if (wantTasks) {
const params: Record<string, string | number | boolean> = {}
if (purpose) params.purpose = purpose
fetches.push(client.get('/v1/biz-state/tasks', params, signal))
} else {
fetches.push(Promise.resolve({ ok: true, data: { items: [] } }))
}
const [projRes, taskRes] = await Promise.all(fetches)
if (wantProjects && projRes.ok === false) return projRes
if (wantTasks && taskRes.ok === false) return taskRes
const projItems = Array.isArray(asRecord(projRes.data).items)
? (asRecord(projRes.data).items as unknown[])
: []
const taskItems = Array.isArray(asRecord(taskRes.data).items)
? (asRecord(taskRes.data).items as unknown[])
: []
let projects = projItems
.map((row) => slimMigrationProject(asRecord(row)))
.filter((row) => {
if (status && String(row.status || '').toLowerCase() !== status) return false
return matchesBizMonitorQ(row, q)
})
let tasks = taskItems
.map((row) => asRecord(row))
.filter((row) => {
if (status && String(row.status || '').toLowerCase() !== status) return false
return matchesBizMonitorQ(row, q)
})
const projectsTotal = projects.length
const tasksTotal = tasks.length
projects = projects.slice(0, limit)
tasks = tasks.slice(0, limit)
return {
ok: true,
data: omitUndefined({
kind,
projects: wantProjects ? projects : undefined,
tasks: wantTasks ? tasks : undefined,
counts: {
projects: wantProjects ? projectsTotal : 0,
tasks: wantTasks ? tasksTotal : 0,
projects_returned: wantProjects ? projects.length : 0,
tasks_returned: wantTasks ? tasks.length : 0,
},
next: 'Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id.',
}),
}
}
function slimCutoverBatch(row: NetxJson): NetxJson {
return omitUndefined({
id: row.id,
project_id: row.project_id,
batch_label: row.batch_label,
status: row.status,
accept_status: row.accept_status,
accept_run_id: row.accept_run_id,
started_at: row.started_at,
ended_at: row.ended_at,
note: row.note,
created_at: row.created_at,
updated_at: row.updated_at,
})
}
function slimCollectBatch(row: NetxJson, taskId = ''): NetxJson {
return omitUndefined({
id: row.id,
task_id: row.task_id || taskId || undefined,
status: row.status,
ne_name: row.ne_name,
command_count: row.command_count,
row_count: row.row_count,
is_baseline: row.is_baseline,
protected: row.protected,
message: row.message,
started_at: row.started_at,
ended_at: row.ended_at,
})
}
function slimEvalRun(row: NetxJson): NetxJson {
const summary = asRecord(row.summary)
const progress = asRecord(summary.progress)
return omitUndefined({
id: row.id,
batch_id: row.batch_id,
purpose: row.purpose,
old_batch_id: row.old_batch_id,
new_batch_id: row.new_batch_id,
created_at: row.created_at,
anomaly: summary.anomaly,
progress_ok: progress.ok ?? summary.progress_ok,
progress_total: progress.total ?? summary.progress_total,
})
}
/**
* List cutover batches, biz_state collect batches, and/or evaluate runs.
* Needs project_id and/or task_id and/or cutover batch_id (for runs).
*/
export async function listBizMonitorBatches(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const projectId = str(args, 'project_id').trim()
const taskId = str(args, 'task_id').trim()
const batchId = str(args, 'batch_id').trim()
const kindRaw = str(args, 'kind', 'auto').trim().toLowerCase() || 'auto'
const includeRuns = args.include_runs === true || args.include_runs === 'true' || args.include_runs === 1
const status = str(args, 'status').trim().toLowerCase()
const limit = clampInt(num(args, 'limit') ?? 50, 50, 1, 200)
const runsLimit = clampInt(num(args, 'runs_limit') ?? 10, 10, 1, 50)
let kind = kindRaw
if (kind === 'auto') {
if (batchId && !projectId && !taskId) kind = 'runs'
else if (projectId && !taskId) kind = 'cutover'
else if (taskId && !projectId) kind = 'collect'
else if (projectId && taskId) kind = 'all'
else if (batchId) kind = 'runs'
else {
return {
ok: false,
error: 'project_id_or_task_id_or_batch_id_required',
detail: 'Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs).',
}
}
}
if (!['auto', 'all', 'cutover', 'collect', 'runs'].includes(kind)) {
return { ok: false, error: 'kind_invalid', detail: 'kind must be auto|all|cutover|collect|runs' }
}
const wantCutover = kind === 'all' || kind === 'cutover'
const wantCollect = kind === 'all' || kind === 'collect'
const wantRuns = kind === 'runs' || (includeRuns && (wantCutover || Boolean(batchId)))
if (wantCutover && !projectId) {
return { ok: false, error: 'project_id_required', detail: 'kind=cutover|all needs project_id' }
}
if (wantCollect && !taskId) {
return { ok: false, error: 'task_id_required', detail: 'kind=collect|all needs task_id' }
}
if (kind === 'runs' && !batchId) {
return { ok: false, error: 'batch_id_required', detail: 'kind=runs needs cutover batch_id' }
}
let cutoverBatches: NetxJson[] = []
let collectBatches: NetxJson[] = []
let runs: NetxJson[] = []
if (wantCutover) {
const res = await client.get(
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`,
{},
signal,
)
if (res.ok === false) return res
const items = Array.isArray(asRecord(res.data).items)
? (asRecord(res.data).items as unknown[])
: []
cutoverBatches = items
.map((row) => slimCutoverBatch(asRecord(row)))
.filter((row) => {
if (batchId && String(row.id || '') !== batchId) return false
if (status && String(row.status || '').toLowerCase() !== status) return false
return true
})
.slice(0, limit)
}
if (wantCollect) {
const res = await client.get(
`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`,
{ limit },
signal,
)
if (res.ok === false) return res
const items = Array.isArray(asRecord(res.data).items)
? (asRecord(res.data).items as unknown[])
: []
collectBatches = items
.map((row) => slimCollectBatch(asRecord(row), taskId))
.filter((row) => {
if (status && String(row.status || '').toLowerCase() !== status) return false
return true
})
.slice(0, limit)
}
if (wantRuns) {
const runBatchIds = kind === 'runs'
? [batchId]
: cutoverBatches.map((b) => String(b.id || '').trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0)
if (kind === 'runs' && batchId) {
const res = await client.get(
`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`,
{ limit: runsLimit },
signal,
)
if (res.ok === false) return res
const items = Array.isArray(asRecord(res.data).items)
? (asRecord(res.data).items as unknown[])
: []
runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit)
} else if (includeRuns && runBatchIds.length > 0) {
const settled = await Promise.all(
runBatchIds.map(async (id) => {
const res = await client.get(
`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`,
{ limit: runsLimit },
signal,
)
if (res.ok === false) return { batch_id: id, error: res, items: [] as NetxJson[] }
const items = Array.isArray(asRecord(res.data).items)
? (asRecord(res.data).items as unknown[])
: []
return {
batch_id: id,
items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit),
}
}),
)
for (const block of settled) {
if ('error' in block && block.error) {
// Keep listing batches even if one runs fetch fails; attach error note.
const failed = cutoverBatches.find((b) => b.id === block.batch_id)
if (failed) failed.runs_error = asRecord(block.error).error || 'runs_fetch_failed'
continue
}
const target = cutoverBatches.find((b) => b.id === block.batch_id)
if (target) target.runs = block.items
for (const r of block.items) runs.push(r)
}
}
}
return {
ok: true,
data: omitUndefined({
kind,
project_id: projectId || undefined,
task_id: taskId || undefined,
batch_id: batchId || undefined,
cutover_batches: wantCutover ? cutoverBatches : undefined,
collect_batches: wantCollect ? collectBatches : undefined,
runs: kind === 'runs' ? runs : undefined,
counts: {
cutover_batches: cutoverBatches.length,
collect_batches: collectBatches.length,
runs: runs.length,
},
next: wantCutover
? 'Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch.'
: wantCollect
? 'Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw.'
: 'Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id).',
}),
}
}
/** Fat cutover / biz_state definition bundle (templates, mapping, tasks). */
export async function getBizMonitorContext(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const projectId = str(args, 'project_id').trim()
const taskId = str(args, 'task_id').trim()
if (!projectId && !taskId) {
return { ok: false, error: 'project_id_or_task_id_required' }
}
const params: Record<string, string | number | boolean> = {}
if (projectId) params.project_id = projectId
if (taskId) params.task_id = taskId
return client.get('/v1/biz-migration/monitor-context', params, signal)
}
/** Cutover batch board + latest evaluate run summary. */
export async function getBizMonitorBoard(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const batchId = str(args, 'batch_id').trim()
if (!batchId) return { ok: false, error: 'batch_id_required' }
const params: Record<string, string | number | boolean> = {}
const runId = str(args, 'run_id').trim()
if (runId) params.run_id = runId
return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal)
}
/** Red tickets with evidence (raw A/B + show command). */
export async function listBizMonitorReds(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const projectId = str(args, 'project_id').trim()
if (!projectId) return { ok: false, error: 'project_id_required' }
const params: Record<string, string | number | boolean> = {}
putStr(params, args, ['status'])
const limit = num(args, 'limit')
if (limit !== undefined) params.limit = clampInt(limit, 200, 1, 500)
return client.get(
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`,
params,
signal,
)
}
/** Paged evaluate diffs (old_key/new_key = device-raw A/B). */
export async function getBizMonitorDiffs(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const runId = str(args, 'run_id').trim()
if (!runId) return { ok: false, error: 'run_id_required' }
const params: Record<string, string | number | boolean> = {}
putStr(params, args, ['metric_id', 'sheet_id', 'verdict', 'color', 'q'])
const limit = num(args, 'limit')
if (limit !== undefined) params.limit = clampInt(limit, 200, 1, 500)
const offset = num(args, 'offset')
if (offset !== undefined) params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER)
return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal)
}
/** One biz_state collect batch (commands + metrics). */
export async function getBizCollectBatch(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const batchId = str(args, 'batch_id').trim()
if (!batchId) return { ok: false, error: 'batch_id_required' }
return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal)
}
/** Full CLI raw_text for one collect command. */
export async function getBizCollectCommandRaw(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const batchId = str(args, 'batch_id').trim()
const commandId = str(args, 'command_id').trim()
if (!batchId || !commandId) {
return { ok: false, error: 'batch_id_and_command_id_required' }
}
return client.get(
`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`,
{},
signal,
)
}

View file

@ -1,41 +0,0 @@
/**
* DSH tool outputs must be lossless JSON: no `undefined` property values,
* no NaN/Infinity, no class instances. Build plain objects carefully, or
* round-trip through JSON before returning from a tool.
*/
import type { NetxJson } from './http.ts'
/**
* Detach a plain JSON-compatible value for DSH tool results.
* Drops object keys whose value is `undefined` (via JSON.stringify).
* @param value - handler result (may contain undefined holes).
* @returns parsed plain JSON object, or a safe error envelope.
*/
export function toLosslessJson(value: unknown): NetxJson {
try {
const text = JSON.stringify(value)
if (text === undefined) {
return { ok: false, error: 'tool_result_not_json', detail: 'undefined_root' }
}
const parsed: unknown = JSON.parse(text)
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return parsed as NetxJson
}
return { ok: true, data: { value: parsed } }
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
return { ok: false, error: 'tool_result_not_json', detail: detail.slice(0, 400) }
}
}
/**
* Build a record omitting keys whose value is `undefined` (keeps null).
*/
export function omitUndefined(record: Record<string, unknown>): NetxJson {
const out: NetxJson = {}
for (const [key, value] of Object.entries(record)) {
if (value !== undefined) out[key] = value as NetxJson[string]
}
return out
}

View file

@ -22,23 +22,12 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string
const localGuide = localRoot
? [
'',
'### Dual plane (mandatory)',
'',
'- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).',
`- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`,
'- **All KB writes via host tools** (workspace Write/bash cannot reach KB):',
' `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /',
' `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.',
'',
'### refs — site product knowledge (evolve every task)',
'- On any NE: **read** `refs/devices/<host_name>/` first; if missing, prove with netx then `kbWriteRef` to create/update.',
'- `area=devices` requires `device=<host_name>` (never UUID); primary file slug=`PROFILE`.',
'- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.',
'- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).',
'',
'### memories — write immediately when valuable',
'- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.',
'- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`.',
'**Site-writable (`kbLocal`)**: use host tools `netx__kbWriteMemory`, `netx__kbWriteDraft`,',
'`netx__kbWriteSuggestion`, `netx__kbUpdateLocal`, `netx__kbDeleteLocal`, `netx__kbListLocal`.',
'They jail writes to `memories/` / `drafts/` / `suggestions/` only.',
'Do **not** use workspace Write/bash for KB paths (sandbox).',
'`identity/` is persona/policy for the host — **read-only for the agent**; never rewrite it;',
'file boundary issues as `suggestions/` instead.',
]
: [
'',
@ -46,7 +35,7 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string
]
return {
description:
'Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).',
'Operator knowledge-base context for this Host (paths + identity from MANIFEST).',
content: [
'## Knowledge base (configured)',
'',

View file

@ -18,15 +18,12 @@ import {
draftFileName,
memoryDir,
memoryFileName,
refDir,
refFileName,
resolveExistingWritableFile,
resolveKbLocalRoot,
resolveWritableLocalPath,
suggestionDir,
suggestionFileName,
type MemoryBucket,
type RefArea,
type SuggestionKind,
KB_LOCAL_WRITABLE_ROOTS,
type KbLocalWritableRoot,
@ -171,49 +168,7 @@ export function createSuggestion(
}
/**
* Create/overwrite site product-knowledge under `refs/`
* (inventory / devices / topology / business / commands / handbooks).
*/
export function createRef(
snapshot: KbSnapshot,
args: {
area: RefArea
slug: string
body: string
/** Required when area=devices — use host_name. */
device?: string
overwrite?: boolean
},
): KbLocalWriteResult {
const localRoot = requireLocalRoot(snapshot)
const name = refFileName(args.slug)
const target = resolveWritableLocalPath(
localRoot,
refDir(args.area, args.device),
name,
)
if (existsSync(target.absolutePath) && !args.overwrite) {
throw new Error(
`file already exists (pass overwrite=true to replace): ${target.relativePath}`,
)
}
mkdirSync(dirname(target.absolutePath), { recursive: true })
const existed = existsSync(target.absolutePath)
writeFileSync(
target.absolutePath,
args.body.endsWith('\n') ? args.body : `${args.body}\n`,
'utf8',
)
return {
ok: true,
action: existed ? 'updated' : 'created',
absolutePath: target.absolutePath,
relativePath: target.relativePath,
}
}
/**
* Replace body of an existing file under memories|drafts|suggestions|refs.
* Replace body of an existing file under memories|drafts|suggestions.
* Drafts keep/force `status: draft` in frontmatter.
*/
export function updateLocalFile(

View file

@ -1,6 +1,6 @@
/**
* Path jail for operator-subset `_local` agent writes.
* Writable: memories / drafts / suggestions / refs (not local_skills).
* Writable trees: memories / drafts / suggestions only (not identity / local_skills).
*/
import { existsSync, realpathSync, statSync } from 'node:fs'
@ -12,7 +12,6 @@ export const KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
'memories',
'drafts',
'suggestions',
'refs',
] as const)
export type KbLocalWritableRoot = (typeof KB_LOCAL_WRITABLE_ROOTS)[number]
@ -20,18 +19,6 @@ export type KbLocalWritableRoot = (typeof KB_LOCAL_WRITABLE_ROOTS)[number]
export type MemoryBucket = 'note' | 'rca_review' | 'ai_trace'
export type SuggestionKind = 'theory' | 'improvement'
/** Site product-knowledge areas under `refs/`. */
export const REF_AREAS = Object.freeze([
'inventory',
'devices',
'topology',
'business',
'commands',
'handbooks',
] as const)
export type RefArea = (typeof REF_AREAS)[number]
export const MEMORY_BUCKET_DIR: Readonly<Record<MemoryBucket, string>> = Object.freeze({
note: '日常笔记',
rca_review: '排障复盘',
@ -123,11 +110,6 @@ export function suggestionFileName(opts: {
return `${date}-${sanitizeSlug(opts.slug)}.md`
}
export function refFileName(slug: string): string {
const base = sanitizeSlug(slug, 'note')
return base.toLowerCase().endsWith('.md') ? base : `${base}.md`
}
/**
* Resolve a candidate path and assert it lies under a writable local subtree.
* Parent dirs need not exist yet (create path); uses resolve + prefix check.
@ -225,20 +207,3 @@ export function draftDir(domain?: string): string {
if (!d) return 'drafts'
return join('drafts', sanitizeSlug(d, 'misc'))
}
/**
* `refs/<area>/…` — for `devices`, require `device` → `refs/devices/<device>/<file>`.
*/
export function refDir(area: RefArea, device?: string): string {
if (!(REF_AREAS as readonly string[]).includes(area)) {
throw new Error(`invalid refs area: ${area}`)
}
if (area === 'devices') {
const host = sanitizeSlug(device?.trim() || '', '')
if (!host) {
throw new Error('refs/devices requires device (host_name)')
}
return join('refs', 'devices', host)
}
return join('refs', area)
}

View file

@ -1,6 +1,6 @@
/**
* Host-side tools for writing under MANIFEST `paths.local`
* (memories / drafts / suggestions / refs). Bypasses workspace sandbox.
* (memories / drafts / suggestions). Bypasses workspace sandbox.
*/
import type { Context } from '@deepseek-ai/cordis'
@ -9,24 +9,17 @@ import type { KbSnapshot } from './kb-manifest.ts'
import {
createDraft,
createMemory,
createRef,
createSuggestion,
deleteLocalFile,
listLocalFiles,
updateLocalFile,
} from './kb-local-ops.ts'
import { kbLocalToolsEnabled, resolveKbLocalRoot, type RefArea } from './kb-local-path.ts'
import { kbLocalToolsEnabled, resolveKbLocalRoot } from './kb-local-path.ts'
import { getKbContext } from './kb-runtime.ts'
import { toLosslessJson } from './json-safe.ts'
const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) })
const bool = (description?: string) => ({ type: 'boolean' as const, ...(description ? { description } : {}) })
const num = (description?: string) => ({ type: 'number' as const, ...(description ? { description } : {}) })
const reqStr = (description?: string) => ({
type: 'string' as const,
required: true as const,
...(description ? { description } : {}),
})
function renderJson(_args: unknown, value: unknown) {
return [{ type: 'text' as const, text: JSON.stringify(value, null, 0) }]
@ -44,7 +37,6 @@ function liveSnapshot(): KbSnapshot {
function tool(
name: string,
description: string,
/** DSH `defineTool` expects a flat parameter map — not a full JSON Schema object. */
parameters: Record<string, unknown>,
execute: (args: Record<string, unknown>) => Promise<unknown> | unknown,
) {
@ -57,7 +49,7 @@ function tool(
isConcurrencySafe: () => false,
async execute(args) {
try {
return toLosslessJson(await execute(args as Record<string, unknown>))
return await execute(args as Record<string, unknown>)
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error))
}
@ -87,16 +79,20 @@ export function registerKbLocalTools(
+ 'Default create-only; set overwrite=true to replace same filename. '
+ 'Do not use workspace Write — KB is outside the sandbox.',
{
bucket: {
type: 'string' as const,
required: true as const,
enum: ['note', 'rca_review', 'ai_trace'],
description: 'Memory subdirectory',
type: 'object',
additionalProperties: false,
required: ['bucket', 'slug', 'body'],
properties: {
bucket: {
type: 'string',
enum: ['note', 'rca_review', 'ai_trace'],
description: 'Memory subdirectory',
},
slug: str('Short filename stem (no path separators)'),
body: str('Full markdown body'),
date: str('Optional YYYY-MM-DD (default today)'),
overwrite: bool('Replace if the target file already exists'),
},
slug: reqStr('Short filename stem (no path separators)'),
body: reqStr('Full markdown body'),
date: str('Optional YYYY-MM-DD (default today)'),
overwrite: bool('Replace if the target file already exists'),
},
(args) => createMemory(liveSnapshot(), {
bucket: args.bucket as 'note' | 'rca_review' | 'ai_trace',
@ -110,13 +106,18 @@ export function registerKbLocalTools(
'netx__kbWriteDraft',
`Create (or overwrite) a DRAFT case under ${localRoot}/drafts/. `
+ 'Filename is forced to DRAFT-YYYYMMDD-…; body gets status: draft frontmatter if missing. '
+ 'Set overwrite=true to replace. Not for formal RCA.',
+ 'Set overwrite=true to replace. Not for identity/ or formal RCA.',
{
slug: reqStr('Case stem (DRAFT- prefix added if missing)'),
body: reqStr('Markdown body (RCA-ish draft)'),
domain: str('Optional fault-domain subfolder under drafts/'),
date: str('Optional event date YYYY-MM-DD or YYYYMMDD'),
overwrite: bool('Replace if the target file already exists'),
type: 'object',
additionalProperties: false,
required: ['slug', 'body'],
properties: {
slug: str('Case stem (DRAFT- prefix added if missing)'),
body: str('Markdown body (RCA-ish draft)'),
domain: str('Optional fault-domain subfolder under drafts/'),
date: str('Optional event date YYYY-MM-DD or YYYYMMDD'),
overwrite: bool('Replace if the target file already exists'),
},
},
(args) => createDraft(liveSnapshot(), {
slug: String(args.slug ?? ''),
@ -129,18 +130,22 @@ export function registerKbLocalTools(
tool(
'netx__kbWriteSuggestion',
`Create (or overwrite) a suggestion under ${localRoot}/suggestions/{theory|improvement}/. `
+ 'Use for HQ pack/theory corrections or tool/process improvements.',
+ 'Use for theory corrections or tool/process improvements — never rewrite identity/.',
{
kind: {
type: 'string' as const,
required: true as const,
enum: ['theory', 'improvement'],
description: 'theory = knowledge fix; improvement = tools/skills/process',
type: 'object',
additionalProperties: false,
required: ['kind', 'slug', 'body'],
properties: {
kind: {
type: 'string',
enum: ['theory', 'improvement'],
description: 'theory = knowledge fix; improvement = tools/skills/process',
},
slug: str('Short filename stem'),
body: str('Full markdown body'),
date: str('Optional YYYY-MM-DD (default today)'),
overwrite: bool('Replace if the target file already exists'),
},
slug: reqStr('Short filename stem'),
body: reqStr('Full markdown body'),
date: str('Optional YYYY-MM-DD (default today)'),
overwrite: bool('Replace if the target file already exists'),
},
(args) => createSuggestion(liveSnapshot(), {
kind: args.kind as 'theory' | 'improvement',
@ -150,40 +155,19 @@ export function registerKbLocalTools(
overwrite: args.overwrite === true,
}),
),
tool(
'netx__kbWriteRef',
`Create (or overwrite) site product-knowledge under ${localRoot}/refs/. `
+ 'area=inventory|devices|topology|business|commands|handbooks. '
+ 'For devices: require device=host_name; default slug PROFILE for the ops profile. '
+ 'Use for NE ledger, per-device profiles, topology/business notes, site command books — not diaries.',
{
area: {
type: 'string' as const,
required: true as const,
enum: ['inventory', 'devices', 'topology', 'business', 'commands', 'handbooks'],
description: 'refs/ subtree',
},
slug: reqStr('Filename stem (e.g. PROFILE, neighbors, ledger)'),
body: reqStr('Full markdown body'),
device: str('Required when area=devices — host_name (never UUID)'),
overwrite: bool('Replace if the target file already exists'),
},
(args) => createRef(liveSnapshot(), {
area: args.area as RefArea,
slug: String(args.slug ?? ''),
body: String(args.body ?? ''),
device: args.device != null ? String(args.device) : undefined,
overwrite: args.overwrite === true,
}),
),
tool(
'netx__kbUpdateLocal',
`Replace the body of an existing file under memories|drafts|suggestions|refs `
`Replace the body of an existing file under memories|drafts|suggestions `
+ `(absolute path or path relative to ${localRoot}). `
+ 'Drafts keep status: draft.',
+ 'Cannot touch identity/ or outside paths.local. Drafts keep status: draft.',
{
path: reqStr('Absolute path or path relative to KB local root'),
body: reqStr('New full markdown body'),
type: 'object',
additionalProperties: false,
required: ['path', 'body'],
properties: {
path: str('Absolute path or path relative to KB local root'),
body: str('New full markdown body'),
},
},
(args) => updateLocalFile(liveSnapshot(), {
path: String(args.path ?? ''),
@ -192,10 +176,15 @@ export function registerKbLocalTools(
),
tool(
'netx__kbDeleteLocal',
`Delete one existing markdown under memories|drafts|suggestions|refs `
+ `(absolute or relative to ${localRoot}).`,
`Delete one existing markdown under memories|drafts|suggestions `
+ `(absolute or relative to ${localRoot}). Refuses identity/ and escapes.`,
{
path: reqStr('Absolute path or path relative to KB local root'),
type: 'object',
additionalProperties: false,
required: ['path'],
properties: {
path: str('Absolute path or path relative to KB local root'),
},
},
(args) => deleteLocalFile(liveSnapshot(), {
path: String(args.path ?? ''),
@ -203,18 +192,22 @@ export function registerKbLocalTools(
),
tool(
'netx__kbListLocal',
`List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). `
+ 'Read contents via absolute paths from the listing.',
`List recent .md files under ${localRoot} memories|drafts|suggestions (newest first). `
+ 'Does not include identity/. Read contents via absolute paths from the listing.',
{
root: {
type: 'string' as const,
enum: ['memories', 'drafts', 'suggestions', 'refs', 'all'],
description: 'Subtree to list (default all writable)',
type: 'object',
additionalProperties: false,
properties: {
root: {
type: 'string',
enum: ['memories', 'drafts', 'suggestions', 'all'],
description: 'Subtree to list (default all writable)',
},
limit: num('Max entries 1–200 (default 50)'),
},
limit: num('Max entries 1–200 (default 50)'),
},
(args) => listLocalFiles(liveSnapshot(), {
root: (args.root as 'memories' | 'drafts' | 'suggestions' | 'refs' | 'all' | undefined) ?? 'all',
root: (args.root as 'memories' | 'drafts' | 'suggestions' | 'all' | undefined) ?? 'all',
limit: typeof args.limit === 'number' ? args.limit : undefined,
}),
),

View file

@ -1,239 +0,0 @@
/**
* Mount `/netxops/*` on the plugin's own `webServer` inject (DSH ≥0.1.5).
*
* `connection.rpc.handle('/netxops')` registers via the connection plugin ctx,
* which no longer injects `webServer` — the route never mounts and the settings
* card sees HTTP 405 → "未配置". Same pattern as dsh-pocket `web-rpc.js`.
*/
import type { Context } from '@deepseek-ai/cordis'
import type { IncomingMessage, ServerResponse } from 'node:http'
/** Max buffered JSON body for control-plane RPC (status / kb.resolve paths). */
const NETXOPS_RPC_BODY_MAX = 8 * 1024 * 1024
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
const INVALID_REQUEST_RPC_ID = 'invalid-request'
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1'])
export type NetxopsRpcHandler = (
endpoint: string,
payload: unknown,
signal: AbortSignal,
) => Promise<unknown>
function endpointFromPath(channel: string, pathname: string): string | undefined {
if (!pathname.startsWith(`${channel}/`)) return undefined
const endpoint = pathname.slice(channel.length + 1)
if (endpoint.split('/').some((seg) =>
seg === '' || seg === '.' || seg === '..' || !ENDPOINT_SEGMENT_PATTERN.test(seg))) {
return undefined
}
return endpoint
}
function serverResponseJson(rpcId: string, result: unknown): string {
return JSON.stringify({ type: 'server-response', rpcId, result })
}
function isTrustedLoopbackRequest(req: IncomingMessage): boolean {
const host = req.headers?.host
if (!host) return false
const hostName = host.split(':')[0]
if (!LOOPBACK_HOSTNAMES.has(hostName ?? '')) return false
if (req.headers['sec-fetch-site'] === 'cross-site') return false
const origin = req.headers.origin
if (origin === undefined) return true
try {
return new URL(origin).host === host
} catch {
return false
}
}
function netxopsFetchHandler(
channel: string,
handler: NetxopsRpcHandler,
log: Pick<Context['logger'], 'error'>,
) {
return {
async fetch(request: Request): Promise<Response> {
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
if (request.method !== 'POST' || endpoint === undefined) {
return new Response('not found', { status: 404 })
}
const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
if (mediaType !== 'application/json') {
return new Response('content type must be application/json', { status: 415 })
}
let body: unknown
try {
body = await request.json()
} catch {
return new Response('body is not JSON', { status: 400 })
}
const row = body as { rpcId?: unknown; method?: unknown; payload?: unknown } | null
const rpcId = row && typeof row.rpcId === 'string' ? row.rpcId : INVALID_REQUEST_RPC_ID
const method = row && typeof row.method === 'string' ? row.method : null
if (rpcId === INVALID_REQUEST_RPC_ID || method === null) {
return new Response(
serverResponseJson(INVALID_REQUEST_RPC_ID, {
ok: false,
error: { code: 'bad-request', message: 'invalid client-request message', details: { issues: [] } },
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
)
}
if (method !== endpoint) {
return new Response(
serverResponseJson(rpcId, {
ok: false,
error: {
code: 'bad-request',
message: `method ${JSON.stringify(method)} does not match endpoint ${JSON.stringify(endpoint)}`,
details: { issues: [] },
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
)
}
try {
const result = await handler(endpoint, row?.payload, request.signal)
return new Response(serverResponseJson(rpcId, result), {
status: 200,
headers: { 'content-type': 'application/json' },
})
} catch (error) {
log.error?.('netxops: rpc %s failed: %s', endpoint, error instanceof Error ? error.message : String(error))
return new Response(`handler failure: ${String(error)}`, { status: 500 })
}
},
}
}
async function httpBridge(
req: IncomingMessage,
res: ServerResponse,
fetchHandler: { fetch: (request: Request) => Promise<Response> },
maxBodyBytes: number,
): Promise<void> {
const abort = new AbortController()
res.on('close', () => {
if (!res.writableEnded) abort.abort()
})
const declaredLen = req.headers['content-length']
if (declaredLen !== undefined && Number(declaredLen) > maxBodyBytes) {
res.writeHead(413, { connection: 'close' })
res.end()
req.destroy()
return
}
const chunks: Buffer[] = []
let received = 0
for await (const chunk of req) {
const buffer = chunk as Buffer
received += buffer.byteLength
if (received > maxBodyBytes) {
res.writeHead(413, { connection: 'close' })
res.end()
req.destroy()
return
}
chunks.push(buffer)
}
const url = `http://${req.headers.host ?? '127.0.0.1'}${req.url ?? '/'}`
const request = new Request(url, {
method: req.method ?? 'GET',
headers: Object.fromEntries(
Object.entries(req.headers).filter(([, value]) => typeof value === 'string') as [string, string][],
),
...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
signal: abort.signal,
})
const response = await fetchHandler.fetch(request)
const headers = Object.fromEntries(response.headers.entries())
res.writeHead(response.status, headers)
if (response.body === null) {
res.end()
return
}
for await (const chunk of response.body) {
if (!res.write(chunk)) {
await new Promise<void>((resolve) => {
const done = (): void => {
res.off('drain', done)
res.off('close', done)
resolve()
}
res.once('drain', done)
res.once('close', done)
})
}
}
res.end()
}
type ConnectionRejection = { requestRejection?: (req: IncomingMessage) => number | undefined }
/**
* Register prefix route `channel` on `ctx.webServer` when available.
* @returns disposer, or null when `webServer` is not on this inject ctx.
*/
export function mountNetxopsWebRoute(
ctx: Context & { webServer?: { register: (route: unknown) => unknown }; connection?: ConnectionRejection },
channel: string,
handler: NetxopsRpcHandler,
): (() => void) | null {
const webServer = ctx.webServer
if (!webServer || typeof webServer.register !== 'function') return null
const connection = ctx.connection
const fetchHandler = netxopsFetchHandler(channel, handler, ctx.logger)
const route = {
kind: 'prefix' as const,
path: channel,
handler: async (req: IncomingMessage, res: ServerResponse) => {
let rejection: number | undefined
if (typeof connection?.requestRejection === 'function') {
try {
rejection = connection.requestRejection(req)
} catch {
rejection = 403
}
} else if (!isTrustedLoopbackRequest(req)) {
rejection = 403
}
if (rejection !== undefined) {
res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' })
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
return
}
await httpBridge(req, res, fetchHandler, NETXOPS_RPC_BODY_MAX)
},
}
const registered = webServer.register(route)
if (typeof registered === 'function') {
return () => {
try {
registered()
} catch {
// already disposed
}
}
}
if (registered && typeof (registered as Promise<unknown>).then === 'function') {
let done = false
return () => {
if (done) return
done = true
void (registered as Promise<(() => void) | void>).then((dispose) => {
if (typeof dispose === 'function') dispose()
}).catch(() => {})
}
}
return () => {}
}

View file

@ -11,7 +11,6 @@ import {
type NetxCapabilityGroups,
} from './capability-groups.ts'
import { createNetxClient, type NetxClient, type NetxJson } from './http.ts'
import { toLosslessJson } from './json-safe.ts'
import { getNetxConnection } from './runtime.ts'
import * as H from './handlers.ts'
import * as T from './topology-handlers.ts'
@ -75,13 +74,10 @@ function tool(
isConcurrencySafe: () => true,
async execute(args, exec) {
const result = await handler(getClient(), args as NetxJson, exec.signal)
// DSH rejects tool results that contain `undefined` property holes
// ("value is not lossless JSON"). Round-trip strips them.
const safe = toLosslessJson(result)
if (result.ok === false || safe.ok === false) {
throw new Error(JSON.stringify(safe))
if (result.ok === false) {
throw new Error(JSON.stringify(result))
}
return safe
return result
},
})
}
@ -320,94 +316,6 @@ export function registerNetxTools(
},
H.findTopologyPaths, getClient, t,
),
// ── bizMonitor group (cutover / biz_state read via netx REST) ─────────
tool(
'netx__listBizMonitors',
'Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.',
{
kind: str('all | projects | tasks (default all)'),
purpose: str('Filter tasks: portrait | cutover_hf | empty=all'),
status: str('Filter by status (e.g. active / paused)'),
q: str('Keyword on name / ne_name / note / id'),
limit: num('Max rows per list (default 100, max 500)'),
},
H.listBizMonitors, getClient, t,
),
tool(
'netx__listBizMonitorBatches',
'List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.',
{
kind: str('auto | all | cutover | collect | runs (default auto)'),
project_id: str('Cutover project id → cutover_batches'),
task_id: str('biz_state task id → collect_batches'),
batch_id: str('Cutover batch id (filter or kind=runs)'),
include_runs: bool('Attach recent evaluate runs onto listed cutover batches'),
status: str('Filter batch status'),
limit: num('Max batches (default 50)'),
runs_limit: num('Max runs per batch when include_runs / kind=runs (default 10)'),
},
H.listBizMonitorBatches, getClient, t,
),
tool(
'netx__getBizMonitorContext',
'Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.',
{
project_id: str('Cutover / biz-migration project id'),
task_id: str('biz_state task id (optional if project_id set)'),
},
H.getBizMonitorContext, getClient, t,
),
tool(
'netx__getBizMonitorBoard',
'Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.',
{
batch_id: { type: 'string' as const, required: true as const },
run_id: str('Optional evaluate run id'),
},
H.getBizMonitorBoard, getClient, t,
),
tool(
'netx__listBizMonitorReds',
'Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).',
{
project_id: { type: 'string' as const, required: true as const },
status: str('open|carried|resolved or empty=all'),
limit: num(),
},
H.listBizMonitorReds, getClient, t,
),
tool(
'netx__getBizMonitorDiffs',
'Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.',
{
run_id: { type: 'string' as const, required: true as const },
metric_id: str(),
sheet_id: str(),
verdict: str(),
color: str('e.g. red'),
q: str(),
limit: num(),
offset: num(),
},
H.getBizMonitorDiffs, getClient, t,
),
tool(
'netx__getBizCollectBatch',
'One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.',
{
batch_id: { type: 'string' as const, required: true as const },
},
H.getBizCollectBatch, getClient, t,
),
tool(
'netx__getBizCollectCommandRaw',
'Full CLI raw_text for one collect command inside a batch (deep dive).',
{
batch_id: { type: 'string' as const, required: true as const },
command_id: { type: 'string' as const, required: true as const },
},
H.getBizCollectCommandRaw, getClient, t,
),
// ── topology group (netx-topology canvas / fabric) ─────────────────────
tool(
'netx__getTopologyTree',

View file

@ -1,67 +0,0 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { dispatchAlarmToSinks } from '../src/netx/alarm-dispatch.ts'
test('dispatchAlarmToSinks still delivers IM when DSH session rejects', async () => {
let imCalls = 0
const warnings = []
const ctx = {
logger: {
warn: (...args) => { warnings.push(args.map(String).join(' ')) },
info() {},
},
}
const results = await dispatchAlarmToSinks(
ctx,
{
action: 'inserted',
rule_label: 'Power Down',
alarm_key: 'k1',
ne: { host_name: 'PE1' },
},
{
deliverDsh: true,
deliverIm: true,
imTargets: [{ botId: 'bot', targetId: 'tgt' }],
lang: 'zh',
},
{
toSession: async () => {
throw new Error('sticky session exploded')
},
toIm: async () => {
imCalls += 1
},
},
)
assert.equal(imCalls, 1)
assert.equal(results.length, 2)
assert.equal(results[0].status, 'rejected')
assert.equal(results[1].status, 'fulfilled')
assert.ok(warnings.some((line) => line.includes('sink failed')))
})
test('dispatchAlarmToSinks skips session job when deliverDsh is false', async () => {
let sessionCalls = 0
let imCalls = 0
const ctx = { logger: { warn() {}, info() {} } }
const results = await dispatchAlarmToSinks(
ctx,
{ action: 'inserted', alarm_key: 'k1' },
{
deliverDsh: false,
deliverIm: true,
imTargets: [{ botId: 'bot', targetId: 'tgt' }],
lang: 'zh',
},
{
toSession: async () => { sessionCalls += 1 },
toIm: async () => { imCalls += 1 },
},
)
assert.equal(sessionCalls, 0)
assert.equal(imCalls, 1)
assert.equal(results.length, 1)
assert.equal(results[0].status, 'fulfilled')
})

View file

@ -15,21 +15,19 @@ import {
TOOLS_BY_GROUP,
} from '../src/netx/capability-groups.ts'
test('defaults: ops + bizMonitor in preset; topology and public off', () => {
test('defaults: ops in preset; topology and public off', () => {
assert.deepEqual(DEFAULT_CAPABILITY_GROUPS, {
ops: { inPreset: true, public: false },
topology: { inPreset: false, public: false },
bizMonitor: { inPreset: true, public: false },
})
assert.deepEqual(groupsForPlane(undefined, 'preset'), ['ops', 'bizMonitor'])
assert.deepEqual(groupsForPlane(undefined, 'preset'), ['ops'])
assert.deepEqual(groupsForPlane(undefined, 'public'), [])
})
test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas; bizMonitor owns cutover read', () => {
test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas', () => {
assert.deepEqual(SKILL_DIR_BY_GROUP, {
ops: 'ops',
topology: 'topology',
bizMonitor: 'biz-monitor',
})
assert.ok(TOOLS_BY_GROUP.ops.includes('netx__findTopologyPaths'))
assert.ok(TOOLS_BY_GROUP.ops.includes('netx__execManagedNe'))
@ -38,16 +36,11 @@ test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas;
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__layoutTopologyView'))
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__suggestSinkHubs'))
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__sinkTopologyDualUnits'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizMonitorContext'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitors'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitorBatches'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizCollectCommandRaw'))
})
test('capabilityGroupsFromSettings honors explicit false for inPreset defaults', () => {
const groups = capabilityGroupsFromSettings({
groupOpsInPreset: false,
groupBizMonitorInPreset: false,
groupTopologyInPreset: true,
groupTopologyPublic: true,
})
@ -56,7 +49,6 @@ test('capabilityGroupsFromSettings honors explicit false for inPreset defaults',
assert.ok(toolNamesForGroups(['topology']).has('netx__layoutTopologyView'))
assert.ok(toolNamesForGroups(['topology']).has('netx__sinkTopologyDualUnits'))
assert.equal(toolNamesForGroups(['topology']).has('netx__queryNmsAlarms'), false)
assert.equal(toolNamesForGroups(['topology']).has('netx__getBizMonitorContext'), false)
})
test('legacy groupNms/common/managedNe map to ops; legacy layout flags OR into topology', () => {
@ -69,7 +61,6 @@ test('legacy groupNms/common/managedNe map to ops; legacy layout flags OR into t
assert.equal(groups.ops.inPreset, false)
assert.equal(groups.ops.public, true)
assert.equal(groups.topology.inPreset, true)
assert.equal(groups.bizMonitor.inPreset, true)
})
test('legacy any-true turns ops inPreset on', () => {
@ -83,5 +74,4 @@ test('legacy any-true turns ops inPreset on', () => {
test('groupsForced ignores settings for per-export mounts', () => {
assert.deepEqual(groupsForced(['ops']), ['ops'])
assert.deepEqual(groupsForced(['ops', 'topology']), ['ops', 'topology'])
assert.deepEqual(groupsForced(['bizMonitor']), ['bizMonitor'])
})

View file

@ -1,40 +0,0 @@
/**
* Lossless JSON helpers for DSH tool results.
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { omitUndefined, toLosslessJson } from '../src/netx/json-safe.ts'
test('omitUndefined drops undefined keys but keeps null/false/0', () => {
assert.deepEqual(
omitUndefined({ a: 1, b: undefined, c: null, d: false, e: 0 }),
{ a: 1, c: null, d: false, e: 0 },
)
})
test('toLosslessJson strips undefined holes (listBizMonitors-style payload)', () => {
const raw = {
ok: true,
data: {
kind: 'projects',
projects: [{ id: 'p1', name: 'demo' }],
tasks: undefined,
next: 'go',
},
}
const safe = toLosslessJson(raw)
assert.equal(safe.ok, true)
const data = safe.data
assert.ok(data && typeof data === 'object' && !Array.isArray(data))
assert.equal(Object.hasOwn(data, 'tasks'), false)
assert.deepEqual(data.projects, [{ id: 'p1', name: 'demo' }])
})
test('toLosslessJson keeps failure envelopes serializable', () => {
const safe = toLosslessJson({ ok: false, error: 'x', detail: undefined })
assert.equal(safe.ok, false)
assert.equal(safe.error, 'x')
assert.equal(Object.hasOwn(safe, 'detail'), false)
})

View file

@ -3,7 +3,7 @@
*/
import assert from 'node:assert/strict'
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
@ -11,7 +11,6 @@ import test from 'node:test'
import {
createDraft,
createMemory,
createRef,
createSuggestion,
deleteLocalFile,
listLocalFiles,
@ -63,13 +62,13 @@ test('draftFileName forces DRAFT- prefix', () => {
assert.match(name, /^DRAFT-20260915-jakarta-down\.md$/)
})
test('resolveWritableLocalPath rejects non-writable roots', () => {
test('resolveWritableLocalPath rejects identity', () => {
withTemp((root) => {
const local = join(root, '_local')
mkdirSync(local, { recursive: true })
assert.throws(
() => resolveWritableLocalPath(local, 'local_skills', 'x.md'),
/allowed under/,
() => resolveWritableLocalPath(local, 'identity', '话术.md'),
/identity|allowed under/,
)
})
})
@ -130,50 +129,26 @@ test('draft injects status: draft frontmatter', () => {
})
})
test('createRef device PROFILE + list refs', () => {
test('suggestion + refuse identity path update', () => {
withTemp((root) => {
const s = snap(root)
mkdirSync(join(root, '_local'), { recursive: true })
assert.throws(
() => createRef(s, { area: 'devices', slug: 'PROFILE', body: '# x\n' }),
/requires device/,
)
const created = createRef(s, {
area: 'devices',
device: 'JKT-PE-01',
slug: 'PROFILE',
body: '# JKT-PE-01\n\nrole: PE\n',
})
assert.match(created.relativePath, /^refs\/devices\/JKT-PE-01\/PROFILE\.md$/)
assert.equal(readFileSync(created.absolutePath, 'utf8'), '# JKT-PE-01\n\nrole: PE\n')
createRef(s, {
area: 'inventory',
slug: 'ledger',
body: '# ledger\n',
})
const listed = listLocalFiles(s, { root: 'refs' })
assert.equal(listed.entries.length, 2)
updateLocalFile(s, {
path: created.relativePath,
body: '# JKT-PE-01\n\nrole: PE\nupdated: true\n',
})
assert.match(readFileSync(created.absolutePath, 'utf8'), /updated: true/)
})
})
test('suggestion create works', () => {
withTemp((root) => {
const s = snap(root)
mkdirSync(join(root, '_local'), { recursive: true })
const created = createSuggestion(s, {
const idDir = join(root, '_local', 'identity')
mkdirSync(idDir, { recursive: true })
writeFileSync(join(idDir, '话术与边界.md'), 'secret\n', 'utf8')
createSuggestion(s, {
kind: 'improvement',
slug: 'tool-x',
body: '# sug\n',
date: '2026-09-15',
})
assert.match(created.relativePath, /suggestions\/improvement\//)
assert.throws(
() => updateLocalFile(s, {
path: 'identity/话术与边界.md',
body: 'hacked\n',
}),
/identity|allowed under/,
)
assert.equal(readFileSync(join(idDir, '话术与边界.md'), 'utf8'), 'secret\n')
})
})