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

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

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

View file

@ -2,9 +2,9 @@
"format": 1, "format": 1,
"builds": { "builds": {
"lib/agent-tools-biz-monitor.js": { "lib/agent-tools-biz-monitor.js": {
"output": "2e870f4d26b8b9560ad4ebced0e8c6b02bc265bd677acae0e85e7f98de1e22ff", "output": "8e72f3ef56c2700444531f0c0af778efbb4838b1f67d42e4fc8711aa575f544a",
"inputs": { "inputs": {
"package.json": "3b8ffe892624815c6dab4313c01056fc2c2d573a3e4e6771a6ff63918d4a8dab", "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d",
"pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3",
"scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8", "scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8",
"scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9",
@ -16,13 +16,14 @@
"src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d", "src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d",
"src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d", "src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d",
"src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9", "src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9",
"src/netx/kb-context-skill.ts": "48f2b1f10c1615aa79d3965f97a4ffb27b7bb91ec9c2ec06681fbe7d9e0a70f0", "src/netx/kb-context-skill.ts": "e0375006ba1e505fa6e5fd50f1748d7b43bb8c34809d10a0549e934006eb3045",
"src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463", "src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463",
"src/netx/kb-local-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c", "src/netx/kb-local-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c",
"src/netx/kb-local-tools.ts": "c3ae8119e1a917498d945161bfe4965401fce5d167144d6500fba8205993511a", "src/netx/kb-local-tools.ts": "5dc2f5c058231b96c130b67cfcf0458bda9804d12ed1746a4b3db371bfcc8881",
"src/netx/kb-manifest.ts": "17dcf4ede421485eabcefde63232c8d79e55e76c0374eae74405fb3a35328166", "src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02",
"src/netx/kb-pack-skills.ts": "0a2e43f04b2301707439764d29a60d60aeb07946a66dfc024f1b6aed8a3556d8", "src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5",
"src/netx/kb-runtime.ts": "71ea48399260ebb228633f26d77411d808c41b7c17946901582af387070f1a3d", "src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86",
"src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b",
"src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450", "src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450",
"src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf",
"src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508", "src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508",
@ -30,9 +31,9 @@
} }
}, },
"lib/agent-tools-common.js": { "lib/agent-tools-common.js": {
"output": "87742324e6d39db0e42a2a28866a97835aacfceb24d6105ff7a76bc9ec00af2d", "output": "a4bd50da911bed64245ebee31a27f4f112729e7b35b99816671c3253e89719f0",
"inputs": { "inputs": {
"package.json": "3b8ffe892624815c6dab4313c01056fc2c2d573a3e4e6771a6ff63918d4a8dab", "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d",
"pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3",
"scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8", "scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8",
"scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9",
@ -44,13 +45,14 @@
"src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d", "src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d",
"src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d", "src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d",
"src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9", "src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9",
"src/netx/kb-context-skill.ts": "48f2b1f10c1615aa79d3965f97a4ffb27b7bb91ec9c2ec06681fbe7d9e0a70f0", "src/netx/kb-context-skill.ts": "e0375006ba1e505fa6e5fd50f1748d7b43bb8c34809d10a0549e934006eb3045",
"src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463", "src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463",
"src/netx/kb-local-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c", "src/netx/kb-local-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c",
"src/netx/kb-local-tools.ts": "c3ae8119e1a917498d945161bfe4965401fce5d167144d6500fba8205993511a", "src/netx/kb-local-tools.ts": "5dc2f5c058231b96c130b67cfcf0458bda9804d12ed1746a4b3db371bfcc8881",
"src/netx/kb-manifest.ts": "17dcf4ede421485eabcefde63232c8d79e55e76c0374eae74405fb3a35328166", "src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02",
"src/netx/kb-pack-skills.ts": "0a2e43f04b2301707439764d29a60d60aeb07946a66dfc024f1b6aed8a3556d8", "src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5",
"src/netx/kb-runtime.ts": "71ea48399260ebb228633f26d77411d808c41b7c17946901582af387070f1a3d", "src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86",
"src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b",
"src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450", "src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450",
"src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf",
"src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508", "src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508",
@ -58,9 +60,9 @@
} }
}, },
"lib/agent-tools-nms.js": { "lib/agent-tools-nms.js": {
"output": "5433fcebca463c2d3bde07f5cc948f572706153c41797e0d805c2a6c9fbff9a0", "output": "b67bb855df12beb0ee24c1c3cd3fb0d42203c060568731784e9a14b545060214",
"inputs": { "inputs": {
"package.json": "3b8ffe892624815c6dab4313c01056fc2c2d573a3e4e6771a6ff63918d4a8dab", "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d",
"pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3",
"scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8", "scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8",
"scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9",
@ -72,13 +74,14 @@
"src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d", "src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d",
"src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d", "src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d",
"src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9", "src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9",
"src/netx/kb-context-skill.ts": "48f2b1f10c1615aa79d3965f97a4ffb27b7bb91ec9c2ec06681fbe7d9e0a70f0", "src/netx/kb-context-skill.ts": "e0375006ba1e505fa6e5fd50f1748d7b43bb8c34809d10a0549e934006eb3045",
"src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463", "src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463",
"src/netx/kb-local-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c", "src/netx/kb-local-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c",
"src/netx/kb-local-tools.ts": "c3ae8119e1a917498d945161bfe4965401fce5d167144d6500fba8205993511a", "src/netx/kb-local-tools.ts": "5dc2f5c058231b96c130b67cfcf0458bda9804d12ed1746a4b3db371bfcc8881",
"src/netx/kb-manifest.ts": "17dcf4ede421485eabcefde63232c8d79e55e76c0374eae74405fb3a35328166", "src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02",
"src/netx/kb-pack-skills.ts": "0a2e43f04b2301707439764d29a60d60aeb07946a66dfc024f1b6aed8a3556d8", "src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5",
"src/netx/kb-runtime.ts": "71ea48399260ebb228633f26d77411d808c41b7c17946901582af387070f1a3d", "src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86",
"src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b",
"src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450", "src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450",
"src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf",
"src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508", "src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508",
@ -86,9 +89,9 @@
} }
}, },
"lib/agent-tools-ops.js": { "lib/agent-tools-ops.js": {
"output": "9945eb9b4e400b5cab9de885f75d29cc43da53f7e129071b22d019c10ed8e698", "output": "4515ae3096e1698f6e8b059c84eb4b91f0f3f62ca1831551484f581d29c28a80",
"inputs": { "inputs": {
"package.json": "3b8ffe892624815c6dab4313c01056fc2c2d573a3e4e6771a6ff63918d4a8dab", "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d",
"pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3",
"scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8", "scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8",
"scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9",
@ -100,13 +103,14 @@
"src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d", "src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d",
"src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d", "src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d",
"src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9", "src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9",
"src/netx/kb-context-skill.ts": "48f2b1f10c1615aa79d3965f97a4ffb27b7bb91ec9c2ec06681fbe7d9e0a70f0", "src/netx/kb-context-skill.ts": "e0375006ba1e505fa6e5fd50f1748d7b43bb8c34809d10a0549e934006eb3045",
"src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463", "src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463",
"src/netx/kb-local-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c", "src/netx/kb-local-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c",
"src/netx/kb-local-tools.ts": "c3ae8119e1a917498d945161bfe4965401fce5d167144d6500fba8205993511a", "src/netx/kb-local-tools.ts": "5dc2f5c058231b96c130b67cfcf0458bda9804d12ed1746a4b3db371bfcc8881",
"src/netx/kb-manifest.ts": "17dcf4ede421485eabcefde63232c8d79e55e76c0374eae74405fb3a35328166", "src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02",
"src/netx/kb-pack-skills.ts": "0a2e43f04b2301707439764d29a60d60aeb07946a66dfc024f1b6aed8a3556d8", "src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5",
"src/netx/kb-runtime.ts": "71ea48399260ebb228633f26d77411d808c41b7c17946901582af387070f1a3d", "src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86",
"src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b",
"src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450", "src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450",
"src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf",
"src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508", "src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508",
@ -114,9 +118,9 @@
} }
}, },
"lib/agent-tools-topology.js": { "lib/agent-tools-topology.js": {
"output": "44f4fba8d174bdcfc6b55a38d1fb1e32828f0ac8c3c87a14ff02a43693ca46ef", "output": "54f0b223025fcc1618771cb1148f41547cb66ea296e238b9ad214fef28bd73ae",
"inputs": { "inputs": {
"package.json": "3b8ffe892624815c6dab4313c01056fc2c2d573a3e4e6771a6ff63918d4a8dab", "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d",
"pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3",
"scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8", "scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8",
"scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9",
@ -128,13 +132,14 @@
"src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d", "src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d",
"src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d", "src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d",
"src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9", "src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9",
"src/netx/kb-context-skill.ts": "48f2b1f10c1615aa79d3965f97a4ffb27b7bb91ec9c2ec06681fbe7d9e0a70f0", "src/netx/kb-context-skill.ts": "e0375006ba1e505fa6e5fd50f1748d7b43bb8c34809d10a0549e934006eb3045",
"src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463", "src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463",
"src/netx/kb-local-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c", "src/netx/kb-local-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c",
"src/netx/kb-local-tools.ts": "c3ae8119e1a917498d945161bfe4965401fce5d167144d6500fba8205993511a", "src/netx/kb-local-tools.ts": "5dc2f5c058231b96c130b67cfcf0458bda9804d12ed1746a4b3db371bfcc8881",
"src/netx/kb-manifest.ts": "17dcf4ede421485eabcefde63232c8d79e55e76c0374eae74405fb3a35328166", "src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02",
"src/netx/kb-pack-skills.ts": "0a2e43f04b2301707439764d29a60d60aeb07946a66dfc024f1b6aed8a3556d8", "src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5",
"src/netx/kb-runtime.ts": "71ea48399260ebb228633f26d77411d808c41b7c17946901582af387070f1a3d", "src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86",
"src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b",
"src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450", "src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450",
"src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf",
"src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508", "src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508",
@ -142,9 +147,9 @@
} }
}, },
"lib/agent-tools.js": { "lib/agent-tools.js": {
"output": "a1978da94dae7fc721c386a147a4ddbab9ec1a055ab86ccedc3565e0f96bced5", "output": "16f2f1ec267929d2055546eebf980544db5758590a7e1b4073c2d4ae5843ddde",
"inputs": { "inputs": {
"package.json": "3b8ffe892624815c6dab4313c01056fc2c2d573a3e4e6771a6ff63918d4a8dab", "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d",
"pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3",
"scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8", "scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8",
"scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9",
@ -156,13 +161,14 @@
"src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d", "src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d",
"src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d", "src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d",
"src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9", "src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9",
"src/netx/kb-context-skill.ts": "48f2b1f10c1615aa79d3965f97a4ffb27b7bb91ec9c2ec06681fbe7d9e0a70f0", "src/netx/kb-context-skill.ts": "e0375006ba1e505fa6e5fd50f1748d7b43bb8c34809d10a0549e934006eb3045",
"src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463", "src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463",
"src/netx/kb-local-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c", "src/netx/kb-local-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c",
"src/netx/kb-local-tools.ts": "c3ae8119e1a917498d945161bfe4965401fce5d167144d6500fba8205993511a", "src/netx/kb-local-tools.ts": "5dc2f5c058231b96c130b67cfcf0458bda9804d12ed1746a4b3db371bfcc8881",
"src/netx/kb-manifest.ts": "17dcf4ede421485eabcefde63232c8d79e55e76c0374eae74405fb3a35328166", "src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02",
"src/netx/kb-pack-skills.ts": "0a2e43f04b2301707439764d29a60d60aeb07946a66dfc024f1b6aed8a3556d8", "src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5",
"src/netx/kb-runtime.ts": "71ea48399260ebb228633f26d77411d808c41b7c17946901582af387070f1a3d", "src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86",
"src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b",
"src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450", "src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450",
"src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf",
"src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508", "src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508",
@ -172,7 +178,7 @@
"lib/client.js": { "lib/client.js": {
"output": "cd0b85687902aae42158838e000f7e9a6bef488abbcbf6c7885d0a87c40e00f8", "output": "cd0b85687902aae42158838e000f7e9a6bef488abbcbf6c7885d0a87c40e00f8",
"inputs": { "inputs": {
"package.json": "3b8ffe892624815c6dab4313c01056fc2c2d573a3e4e6771a6ff63918d4a8dab", "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d",
"pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3",
"scripts/build-client.mjs": "bbb37a7d5024772f35824a4a3b5b9bc75fc670da391151e35a3811a677e23fd1", "scripts/build-client.mjs": "bbb37a7d5024772f35824a4a3b5b9bc75fc670da391151e35a3811a677e23fd1",
"scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9",
@ -193,19 +199,19 @@
"src/netx/config-values.ts": "7e8ca894d0b09f5e52686d6b293e49b46dd2cc13d714b0e0b103c3d07433e117", "src/netx/config-values.ts": "7e8ca894d0b09f5e52686d6b293e49b46dd2cc13d714b0e0b103c3d07433e117",
"src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d", "src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d",
"src/netx/im-targets.ts": "123613038dee8b14ce63ea46e88029287c1a8c527e6ba6cadab02f8c95b28917", "src/netx/im-targets.ts": "123613038dee8b14ce63ea46e88029287c1a8c527e6ba6cadab02f8c95b28917",
"src/netx/kb-manifest.ts": "17dcf4ede421485eabcefde63232c8d79e55e76c0374eae74405fb3a35328166", "src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02",
"src/session-export-shared.ts": "e30d884ec22a0ea2e016c1479f62a8bc8d7ddcce5369a36647d411d07a8620d5" "src/session-export-shared.ts": "e30d884ec22a0ea2e016c1479f62a8bc8d7ddcce5369a36647d411d07a8620d5"
} }
}, },
"lib/index.js": { "lib/index.js": {
"output": "514890b88808556213c851071faf0c73a4e5761a5eac73c9a2bd3376a08fb304", "output": "982dd1a42b990fdd17467a5963b11494ac1b5f32ba54dffdeee414ed84df5908",
"inputs": { "inputs": {
"package.json": "3b8ffe892624815c6dab4313c01056fc2c2d573a3e4e6771a6ff63918d4a8dab", "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d",
"pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3",
"scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8", "scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8",
"scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9",
"src/agent-preset-install.ts": "1f397bb8e1c9a3f13cb7e4c442c3d13c42fce94fbc4eb1d90b85a0485bda48bc", "src/agent-preset-install.ts": "1f397bb8e1c9a3f13cb7e4c442c3d13c42fce94fbc4eb1d90b85a0485bda48bc",
"src/index.ts": "a7c4a08590af7cfe2a9fa671fa5518e9b6de13d96f5360349e4d8a6ed2cb0517", "src/index.ts": "dc869281bf04502e63abf69d7502afa834cfb2f6cbafb8e1a97e009c15844d61",
"src/netx/alarm-dispatch.ts": "9f476f1c620e40d3e2017554ec2fa8a1848f75d7e77e7c9ee5ee0464e970ba62", "src/netx/alarm-dispatch.ts": "9f476f1c620e40d3e2017554ec2fa8a1848f75d7e77e7c9ee5ee0464e970ba62",
"src/netx/alarm-im.ts": "a75b37e379500cbb90f1f4b81d448d45a97fd9e744e507608910f510480cffaf", "src/netx/alarm-im.ts": "a75b37e379500cbb90f1f4b81d448d45a97fd9e744e507608910f510480cffaf",
"src/netx/alarm-push-status.ts": "21f0efa33ea6f21e3ede35b4accbc6c0c4c78fea23631f1c5b80e6e9aef8b82f", "src/netx/alarm-push-status.ts": "21f0efa33ea6f21e3ede35b4accbc6c0c4c78fea23631f1c5b80e6e9aef8b82f",
@ -221,13 +227,14 @@
"src/netx/i18n.ts": "9278e4adaa96cae413326f4dd8c796f2712d4749bcf92e5170967e38cee2d082", "src/netx/i18n.ts": "9278e4adaa96cae413326f4dd8c796f2712d4749bcf92e5170967e38cee2d082",
"src/netx/im-targets.ts": "123613038dee8b14ce63ea46e88029287c1a8c527e6ba6cadab02f8c95b28917", "src/netx/im-targets.ts": "123613038dee8b14ce63ea46e88029287c1a8c527e6ba6cadab02f8c95b28917",
"src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9", "src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9",
"src/netx/kb-context-skill.ts": "48f2b1f10c1615aa79d3965f97a4ffb27b7bb91ec9c2ec06681fbe7d9e0a70f0", "src/netx/kb-context-skill.ts": "e0375006ba1e505fa6e5fd50f1748d7b43bb8c34809d10a0549e934006eb3045",
"src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463", "src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463",
"src/netx/kb-local-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c", "src/netx/kb-local-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c",
"src/netx/kb-local-tools.ts": "c3ae8119e1a917498d945161bfe4965401fce5d167144d6500fba8205993511a", "src/netx/kb-local-tools.ts": "5dc2f5c058231b96c130b67cfcf0458bda9804d12ed1746a4b3db371bfcc8881",
"src/netx/kb-manifest.ts": "17dcf4ede421485eabcefde63232c8d79e55e76c0374eae74405fb3a35328166", "src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02",
"src/netx/kb-pack-skills.ts": "0a2e43f04b2301707439764d29a60d60aeb07946a66dfc024f1b6aed8a3556d8", "src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5",
"src/netx/kb-runtime.ts": "71ea48399260ebb228633f26d77411d808c41b7c17946901582af387070f1a3d", "src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86",
"src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b",
"src/netx/model-language.ts": "07df448b407086237c184a71bfc78e7cb27e50d7f8c499995d8b368b14780c9e", "src/netx/model-language.ts": "07df448b407086237c184a71bfc78e7cb27e50d7f8c499995d8b368b14780c9e",
"src/netx/netxops-web-rpc.ts": "c6981c91b0a300806d44f45708f07c5102ec40655e5d387394608e5472ad3214", "src/netx/netxops-web-rpc.ts": "c6981c91b0a300806d44f45708f07c5102ec40655e5d387394608e5472ad3214",
"src/netx/preset-publication.ts": "22e357cb58bf72cb0d269246f9e3e083269feb1706a9c4f152eb042330c9e967", "src/netx/preset-publication.ts": "22e357cb58bf72cb0d269246f9e3e083269feb1706a9c4f152eb042330c9e967",

View file

@ -22,7 +22,7 @@ dsh web
使用已经下载的发布包,例如: 使用已经下载的发布包,例如:
```powershell ```powershell
dsh plugin --profile web add "D:\Downloads\dsh-netxops-0.1.70.tgz" dsh plugin --profile web add "D:\Downloads\dsh-netxops-0.1.71.tgz"
dsh web dsh web
``` ```

View file

@ -37,12 +37,15 @@ Authoritative packaging prose lives in the workspace contract notes (`插件` /
| Channel | Fields | | 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` | | `process.env` | `KB_ROOT`, `KB_LOCAL` (when `paths.local` set), `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT` (JSON of `has*`), `KB_PATHS` (JSON `{key: absolutePath}` of every `paths.*`), `KB_STATUS` |
| Skill `kb-context` | Same MANIFEST fields + `kbLocal` + dual-plane write rules (refs / memories / drafts / suggestions) | | Skill `kb-context` | Same MANIFEST fields + `kbLocal` + **resolved absolute anchor table for every `paths.*`** + `{paths.local}/identity/*.md` inlined (README excluded; ≤8 KB/file, ≤24 KB total) + dual-plane write rules |
| Skills from pack | Registered when `configured` **and** `hasSkills` **and** the inPreset/public toggle for that plane | | Skills from pack | Registered when `configured` **and** `hasSkills` **and** the inPreset/public toggle for that plane. Each pack skill gets the same resolved anchor table appended, so playbooks never need to re-read MANIFEST or compose `regions/<c>/<o>/…` |
| KB read tools | When `configured` + KB toggles: `netx__kbSearch` / `netx__kbRead`, jailed (realpath-checked) to `kbRoot` |
| 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__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. |
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` (published snapshot), `kb.reload` (re-resolve live `kbRoot` + **force** republish — remounts skills/tools even when MANIFEST is unchanged, so edited `SKILL.md`, new `local_skills/` or `identity/` files are picked up), `kb.resolve` with `{ path }` (preview unsaved paths).
MANIFEST may carry a UTF-8 BOM (PowerShell 5.1 `Out-File`/`Set-Content -Encoding UTF8`); it is stripped before parsing. A `local_skills/` bundle whose `name` duplicates a pack skill is skipped with a warning (pack wins) instead of aborting the whole pack registration.
## Pack skill registration ## Pack skill registration

View file

@ -49,10 +49,12 @@ Execution: HTTP `apiUrl` + Bearer `NETX_API_TOKEN`.
## knowledge base → `groupKb*` (not ops/topology) ## 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 and the KB in-preset / public toggle is on. Read tools are jailed to `kbRoot`; write tools additionally need MANIFEST `paths.local` and are jailed to `{kbLocal}/refs|memories|drafts|suggestions`. Host-side FS (bypass workspace sandbox).
| Tool | Role | | Tool | Role |
|------|------| |------|------|
| `kbSearch` | Keyword (space-separated, case-insensitive) or regex search over the whole package or `scope` = MANIFEST paths key(s) / relative dir. Files ranked by distinct terms matched; ≤4 hit lines each. Stat-validated in-memory cache. |
| `kbRead` | Read one text file (offset/limit line paging) or list a directory. `path` = kbRoot-relative, absolute inside kbRoot, or a paths key (`neIndex`, `cmdLib`, `rca`, …). |
| `kbWriteRef` | Site product KB under `refs/{inventory\|devices\|topology\|business\|commands\|handbooks}/`. `devices` requires `device=host_name`; primary profile slug=`PROFILE`. | | `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` | Diary under `memories/{日常笔记\|排障复盘\|AI思维链}/` — write as soon as reusable experience appears. |
| `kbWriteDraft` | New `DRAFT-…` under `drafts/` (+ `status: draft`) | | `kbWriteDraft` | New `DRAFT-…` under `drafts/` (+ `status: draft`) |

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "dsh-netxops", "name": "dsh-netxops",
"version": "0.1.70", "version": "0.1.71",
"description": "Network operations, topology and monitoring for DeepSeek Harness.", "description": "Network operations, topology and monitoring for DeepSeek Harness.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View file

@ -37,6 +37,7 @@
## 知识库双平面(KB 已配置时 — 铁律) ## 知识库双平面(KB 已配置时 — 铁律)
- **总部包只读**:正式 RCA、theory、packet、`_common`、`_skills`、总部命令库 — 检索用,不改。 - **总部包只读**:正式 RCA、theory、packet、`_common`、`_skills`、总部命令库 — 检索用,不改。
- **读/检索走宿主工具**:`netx__kbSearch`(关键词/正则,`scope` 用 MANIFEST paths 键如 `rca` / `theory` / `packet`)→ `netx__kbRead`(路径或 `neIndex` / `cmdLib` 等键,长文件用 offset/limit 翻页)。路径以 `kb-context` 的锚点表为准,不自拼。
- **唯一可写区** `{kbLocal}`:`refs/`(本网产品知识)/ `memories/`(过程记忆)/ `drafts/`(案例草稿)/ `suggestions/`(反哺总部)。 - **唯一可写区** `{kbLocal}`:`refs/`(本网产品知识)/ `memories/`(过程记忆)/ `drafts/`(案例草稿)/ `suggestions/`(反哺总部)。
- **写文件只走宿主工具**:`netx__kbWriteRef` / `kbWriteMemory` / `kbWriteDraft` / `kbWriteSuggestion` / `kbUpdateLocal` / `kbDeleteLocal`。**禁止**对 KB 用 workspace `Write` / bash。 - **写文件只走宿主工具**:`netx__kbWriteRef` / `kbWriteMemory` / `kbWriteDraft` / `kbWriteSuggestion` / `kbUpdateLocal` / `kbDeleteLocal`。**禁止**对 KB 用 workspace `Write` / bash。

View file

@ -1001,7 +1001,7 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
if (endpoint === 'kb.reload') { if (endpoint === 'kb.reload') {
const current = source() const current = source()
const kb = resolveKbRoot(current.kbRoot ?? '') const kb = resolveKbRoot(current.kbRoot ?? '')
publishKbContext(kb) publishKbContext(kb, { force: true })
applyKbEnv(kb) applyKbEnv(kb)
if (kb.status === 'configured') { if (kb.status === 'configured') {
ctx.logger.info( ctx.logger.info(

View file

@ -3,11 +3,51 @@
* Tools/scripts still use `process.env.KB_*` from `applyKbEnv`. * Tools/scripts still use `process.env.KB_*` from `applyKbEnv`.
*/ */
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
import type { KbSnapshot } from './kb-manifest.ts' import { renderKbPathTable, type KbSnapshot } from './kb-manifest.ts'
import { resolveKbLocalRoot } from './kb-local-path.ts' import { resolveKbLocalRoot } from './kb-local-path.ts'
const SKILL_NAME = 'kb-context' const SKILL_NAME = 'kb-context'
/** Per-file / total caps for inlined `_local/identity/*.md` (system-prompt-grade site rules). */
const IDENTITY_FILE_MAX = 8_000
const IDENTITY_TOTAL_MAX = 24_000
/**
* Site identity files (`{paths.local}/identity/*.md`, README excluded), inlined so the
* operator's own boundaries reach the model without an extra read.
*/
export function readKbIdentity(snapshot: KbSnapshot): Array<{ name: string; text: string }> {
const localRoot = resolveKbLocalRoot(snapshot)
if (!localRoot) return []
const dir = join(localRoot, 'identity')
let names: string[]
try {
names = readdirSync(dir).filter(name => /\.md$/i.test(name) && !/^readme\.md$/i.test(name)).sort()
} catch {
return []
}
const out: Array<{ name: string; text: string }> = []
let budget = IDENTITY_TOTAL_MAX
for (const name of names) {
if (budget <= 0) break
const full = join(dir, name)
let text: string
try {
if (!statSync(full).isFile()) continue
text = readFileSync(full, 'utf8').replace(/^\uFEFF/, '').trim()
} catch {
continue
}
if (!text) continue
const cap = Math.min(IDENTITY_FILE_MAX, budget)
if (text.length > cap) text = `${text.slice(0, cap)}\n\n…(truncated — read \`${full}\` for the rest)`
budget -= text.length
out.push({ name, text })
}
return out
}
function skillBody(snapshot: KbSnapshot): { description: string; content: string } { function skillBody(snapshot: KbSnapshot): { description: string; content: string } {
if (snapshot.status === 'configured') { if (snapshot.status === 'configured') {
@ -19,6 +59,14 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string
const localRow = localRoot const localRow = localRoot
? `| kbLocal | \`${localRoot}\` |` ? `| kbLocal | \`${localRoot}\` |`
: '| kbLocal | (MANIFEST paths.local missing) |' : '| kbLocal | (MANIFEST paths.local missing) |'
const identity = readKbIdentity(snapshot)
const identityBlock = identity.length > 0
? [
'',
'### Site identity (`identity/`, authoritative — follow over generic advice)',
...identity.flatMap(({ name, text }) => ['', `#### ${name}`, '', text]),
]
: []
const localGuide = localRoot const localGuide = localRoot
? [ ? [
'', '',
@ -46,7 +94,7 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string
] ]
return { return {
description: description:
'Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).', `Load FIRST before any kb-* skill or KB file access: resolved absolute paths (rca / theory / packet / neIndex / cmdLib / local …) for operator ${snapshot.operatorName} (${snapshot.country}) KB v${snapshot.version}, site identity rules, and KB write rules.`,
content: [ content: [
'## Knowledge base (configured)', '## Knowledge base (configured)',
'', '',
@ -62,13 +110,22 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string
`| kbCountry | ${snapshot.country} |`, `| kbCountry | ${snapshot.country} |`,
`| kbVersion | ${snapshot.version} |`, `| kbVersion | ${snapshot.version} |`,
`| kbContent (on) | ${flags} |`, `| kbContent (on) | ${flags} |`,
'',
'### Path anchors (resolved from MANIFEST `paths.*` — use verbatim, never compose `regions/<country>/<operator>/…`)',
'',
renderKbPathTable(snapshot),
...identityBlock,
...localGuide, ...localGuide,
'', '',
'Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.', '### Reading the HQ pack',
'- `netx__kbSearch` — keyword/regex search across rca / theory / packet / common / local (returns file + line hits).',
'- `netx__kbRead` — read one KB file (path relative to kbRoot or absolute inside it; optional line window).',
'- Start from the indexes: `neIndex` (NE → cases), `patternMap`, `classification`, then open the cited RCA.',
'', '',
'Business playbooks (kb-troubleshoot, kb-retrieve, …) register from', 'Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_PATHS (JSON of the anchors above), KB_STATUS.',
`${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, '',
'plus paths.localSkills when present — use those skills for KB triage;', 'Business playbooks (kb-troubleshoot, kb-retrieve, …) register from paths.skills',
'when hasSkills is true, plus paths.localSkills when present — use those skills for KB triage;',
'keep netx-ops for live netx evidence only.', 'keep netx-ops for live netx evidence only.',
].join('\n'), ].join('\n'),
} }

View file

@ -1,6 +1,6 @@
/** /**
* Host-side tools for writing under MANIFEST `paths.local` * Host-side KB tools. Read/search the whole package when configured; write under MANIFEST
* (memories / drafts / suggestions / refs). Bypasses workspace sandbox. * `paths.local` (memories / drafts / suggestions / refs). Bypasses workspace sandbox.
*/ */
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
@ -15,7 +15,8 @@ import {
listLocalFiles, listLocalFiles,
updateLocalFile, updateLocalFile,
} from './kb-local-ops.ts' } from './kb-local-ops.ts'
import { kbLocalToolsEnabled, resolveKbLocalRoot, type RefArea } from './kb-local-path.ts' import { resolveKbLocalRoot, type RefArea } from './kb-local-path.ts'
import { readKbFile, searchKb } from './kb-read-ops.ts'
import { getKbContext } from './kb-runtime.ts' import { getKbContext } from './kb-runtime.ts'
import { toLosslessJson } from './json-safe.ts' import { toLosslessJson } from './json-safe.ts'
import { getToolMountId, getToolMountStatus, publishToolMountStatus, removeToolMountStatus } from './tool-mount-status.ts' import { getToolMountId, getToolMountStatus, publishToolMountStatus, removeToolMountStatus } from './tool-mount-status.ts'
@ -66,22 +67,8 @@ function tool(
}) })
} }
/** function writeTools(localRoot: string) {
* Register KB local create/update/delete/list tools when snapshot has paths.local. return [
* @returns disposer (no-op when disabled).
*/
export function registerKbLocalTools(
ctx: Context,
snapshot: KbSnapshot = getKbContext(),
options: { plane?: 'host' | 'preset'; source?: string } = {},
): () => void {
if (!kbLocalToolsEnabled(snapshot)) return () => {}
const localRoot = resolveKbLocalRoot(snapshot)!
const toolsApi = (ctx as { tools?: { register: (t: unknown) => () => void } }).tools
if (!toolsApi || typeof toolsApi.register !== 'function') return () => {}
const entries = [
tool( tool(
'netx__kbWriteMemory', 'netx__kbWriteMemory',
`Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. `
@ -206,7 +193,7 @@ export function registerKbLocalTools(
tool( tool(
'netx__kbListLocal', 'netx__kbListLocal',
`List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). `
+ 'Read contents via absolute paths from the listing.', + 'Read contents with netx__kbRead using the listed paths.',
{ {
root: { root: {
type: 'string' as const, type: 'string' as const,
@ -221,6 +208,65 @@ export function registerKbLocalTools(
}), }),
), ),
] ]
}
function readTools(snapshot: KbSnapshot) {
return [
tool(
'netx__kbSearch',
`Search the operator knowledge base at ${snapshot.realRoot} (read-only HQ pack + _local). `
+ 'Plain mode: space-separated keywords (NE host_name, alarm, protocol, symptom…), case-insensitive; '
+ 'files ranked by how many distinct terms match (filename hits weigh more), up to 4 hit lines each. '
+ 'Then open the best hits with netx__kbRead.',
{
query: reqStr('Keywords (space-separated) or a regex when regex=true'),
scope: str('MANIFEST paths key(s) — e.g. rca, theory, theoryVendor, packet, commonPatterns, local, neIndex — or a relative dir; comma-separate several; default all'),
regex: bool('Treat query as one case-insensitive JS regex'),
limit: num('Max files 1–100 (default 20)'),
},
(args) => searchKb(liveSnapshot(), {
query: String(args.query ?? ''),
scope: args.scope != null ? String(args.scope) : undefined,
regex: args.regex === true,
limit: typeof args.limit === 'number' ? args.limit : undefined,
}),
),
tool(
'netx__kbRead',
`Read one file (or list one directory) inside the knowledge base at ${snapshot.realRoot}. `
+ 'path: relative to kbRoot, absolute inside it, or a MANIFEST paths key (rca, neIndex, cmdLib, patternMap, …). '
+ 'Long files: page with offset/limit (1-based lines; default 400).',
{
path: reqStr('KB-relative path, absolute path inside kbRoot, or a paths.* key'),
offset: num('First line (1-based, default 1)'),
limit: num('Max lines 1–2000 (default 400)'),
},
(args) => readKbFile(liveSnapshot(), {
path: String(args.path ?? ''),
offset: typeof args.offset === 'number' ? args.offset : undefined,
limit: typeof args.limit === 'number' ? args.limit : undefined,
}),
),
]
}
/**
* Register KB tools: read/search whenever configured, plus local create/update/delete/list
* when snapshot has paths.local.
* @returns disposer (no-op when disabled).
*/
export function registerKbLocalTools(
ctx: Context,
snapshot: KbSnapshot = getKbContext(),
options: { plane?: 'host' | 'preset'; source?: string } = {},
): () => void {
if (snapshot.status !== 'configured') return () => {}
const localRoot = resolveKbLocalRoot(snapshot)
const toolsApi = (ctx as { tools?: { register: (t: unknown) => () => void } }).tools
if (!toolsApi || typeof toolsApi.register !== 'function') return () => {}
const entries = [...readTools(snapshot), ...(localRoot ? writeTools(localRoot) : [])]
const disposers: Array<() => void> = [] const disposers: Array<() => void> = []
const source = options.source ?? 'netxops-kb-local' const source = options.source ?? 'netxops-kb-local'
@ -236,9 +282,10 @@ export function registerKbLocalTools(
throw error throw error
} }
ctx.logger.info( ctx.logger.info(
'netxops: kb local tools registered count=%s localRoot=%s', 'netxops: kb tools registered count=%s kbRoot=%s localRoot=%s',
disposers.length, disposers.length,
localRoot, snapshot.realRoot,
localRoot ?? '(none — read-only)',
) )
return () => { return () => {
for (const dispose of disposers) dispose() for (const dispose of disposers) dispose()

View file

@ -240,7 +240,8 @@ export function parseManifest(raw: string): {
} { } {
let data: unknown let data: unknown
try { try {
data = JSON.parse(raw) // PowerShell 5.1 `Out-File/Set-Content -Encoding UTF8` writes a BOM; JSON.parse rejects it.
data = JSON.parse(raw.replace(/^\uFEFF/, ''))
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`MANIFEST is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, `MANIFEST is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
@ -335,3 +336,36 @@ export function resolveKbRoot(kbRoot: string, maxDepth = 3): KbSnapshot {
return errorSnapshot(error instanceof Error ? error.message : String(error)) return errorSnapshot(error instanceof Error ? error.message : String(error))
} }
} }
export interface KbResolvedPath {
key: string
relative: string
absolute: string
exists: boolean
}
/**
* MANIFEST `paths.*` resolved against realRoot (stable key order).
* Pack skills must read these anchors instead of composing `regions/<c>/<o>/…`.
*/
export function resolveKbPaths(snapshot: KbSnapshot): KbResolvedPath[] {
if (snapshot.status !== 'configured') return []
const paths: Record<string, string | undefined> = { skills: '_skills', ...snapshot.paths }
return Object.entries(paths)
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].trim() !== '')
.map(([key, rel]) => {
const absolute = resolve(snapshot.realRoot, rel)
return { key, relative: rel, absolute, exists: existsSync(absolute) }
})
}
/** Markdown anchor table shared by `kb-context` and every registered pack skill. */
export function renderKbPathTable(snapshot: KbSnapshot): string {
const rows = resolveKbPaths(snapshot)
if (rows.length === 0) return '(MANIFEST has no paths — legacy package; ask HQ for a repack)'
return [
'| paths.* | Absolute path |',
'| --- | --- |',
...rows.map(row => `| ${row.key} | \`${row.absolute}\`${row.exists ? '' : ' (missing)'} |`),
].join('\n')
}

View file

@ -9,7 +9,7 @@
import { readdir, stat } from 'node:fs/promises' import { readdir, stat } from 'node:fs/promises'
import { join } from 'node:path' import { join } from 'node:path'
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
import type { KbSnapshot } from './kb-manifest.ts' import { renderKbPathTable, type KbSnapshot } from './kb-manifest.ts'
import { loadSkillBundle } from './group-skills.ts' import { loadSkillBundle } from './group-skills.ts'
export type KbSkillPlane = 'preset' | 'public' export type KbSkillPlane = 'preset' | 'public'
@ -35,6 +35,26 @@ export function resolveKbSkillsRoot(snapshot: KbSnapshot): string {
return join(snapshot.realRoot, rel) return join(snapshot.realRoot, rel)
} }
/**
* Appended to every pack skill: the playbooks say "read MANIFEST paths, never compose",
* so hand them the resolved anchors instead of costing a MANIFEST read per task.
*/
export function kbPackSkillFooter(snapshot: KbSnapshot): string {
return [
'',
'',
`## Injected KB anchors (netxops · ${snapshot.operatorName} / ${snapshot.country} v${snapshot.version})`,
'',
`kbStatus=configured · kbRoot=\`${snapshot.realRoot}\``,
'',
renderKbPathTable(snapshot),
'',
'These are already resolved from `MANIFEST.json` `paths.*`; use them verbatim.',
'Read/search the pack with `netx__kbRead` / `netx__kbSearch` when present; write only via `netx__kbWrite*`.',
'',
].join('\n')
}
/** /**
* Absolute local-skills root from MANIFEST `paths.localSkills`, or null when absent. * Absolute local-skills root from MANIFEST `paths.localSkills`, or null when absent.
*/ */
@ -49,7 +69,14 @@ async function registerSkillsUnderRoot(
skillsApi: { register: SkillsRegister }, skillsApi: { register: SkillsRegister },
root: string, root: string,
provider: string, provider: string,
options: { logMissing: boolean; label: string; signal?: AbortSignal }, options: {
logMissing: boolean
label: string
signal?: AbortSignal
footer: string
/** Names already registered by an earlier root (pack before local). */
seen: Set<string>
},
): Promise<Array<() => void>> { ): Promise<Array<() => void>> {
let entries: string[] let entries: string[]
try { try {
@ -91,15 +118,35 @@ async function registerSkillsUnderRoot(
continue continue
} }
if (options.signal?.aborted) break if (options.signal?.aborted) break
if (options.seen.has(skill.name)) {
ctx.logger.warn(
'netxops: kb %s skill %s duplicates an already registered pack skill — skip %s',
options.label,
skill.name,
full,
)
continue
}
try {
disposers.push(skillsApi.register({ disposers.push(skillsApi.register({
name: skill.name, name: skill.name,
description: skill.description, description: skill.description,
content: skill.content, content: `${skill.content.trimEnd()}${options.footer}`,
path: skill.path, path: skill.path,
resourceBase: { kind: 'directory', path: skill.directory }, resourceBase: { kind: 'directory', path: skill.directory },
provider, provider,
source: 'custom', source: 'custom',
})) }))
options.seen.add(skill.name)
} catch (error) {
// One rejected bundle must not take the rest of the pack down with it.
ctx.logger.warn(
'netxops: kb %s skill %s register failed: %s',
options.label,
skill.name,
error instanceof Error ? error.message : String(error),
)
}
} }
} catch (error) { } catch (error) {
for (const dispose of disposers.splice(0)) dispose() for (const dispose of disposers.splice(0)) dispose()
@ -137,11 +184,15 @@ export async function registerKbPackSkills(
const skillsRoot = resolveKbSkillsRoot(snapshot) const skillsRoot = resolveKbSkillsRoot(snapshot)
const localRoot = resolveKbLocalSkillsRoot(snapshot) const localRoot = resolveKbLocalSkillsRoot(snapshot)
const footer = kbPackSkillFooter(snapshot)
const seen = new Set<string>()
try { try {
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, {
logMissing: true, logMissing: true,
label: 'pack', label: 'pack',
signal: options.signal, signal: options.signal,
footer,
seen,
})) }))
if (localRoot) { if (localRoot) {
@ -149,6 +200,8 @@ export async function registerKbPackSkills(
logMissing: false, logMissing: false,
label: 'local', label: 'local',
signal: options.signal, signal: options.signal,
footer,
seen,
})) }))
} }
} catch (error) { } catch (error) {

298
src/netx/kb-read-ops.ts Normal file
View file

@ -0,0 +1,298 @@
/**
* Read-only access to the whole operator-subset package (HQ pack + `_local`),
* jailed to `realRoot`. Host-side so it works regardless of the workspace sandbox.
*/
import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
import { extname, join, relative, resolve, sep } from 'node:path'
import { resolveKbPaths, type KbSnapshot } from './kb-manifest.ts'
const TEXT_EXTENSIONS = new Set(['.md', '.markdown', '.txt', '.json', '.yml', '.yaml', '.csv', '.ps1', '.sh', '.cfg', '.conf', '.log'])
const SKIP_DIRS = new Set(['.git', 'node_modules'])
const READ_DEFAULT_LINES = 400
const READ_MAX_LINES = 2_000
const READ_MAX_CHARS = 80_000
const SEARCH_MAX_FILE_BYTES = 2 * 1024 * 1024
const SEARCH_HITS_PER_FILE = 4
const CACHE_MAX_CHARS = 64 * 1024 * 1024
function requireRoot(snapshot: KbSnapshot): string {
if (snapshot.status !== 'configured' || !snapshot.realRoot) {
throw new Error('knowledge base not configured (set kbRoot to a folder with MANIFEST.json)')
}
return snapshot.realRoot
}
function toPosix(rel: string): string {
return rel.split(sep).join('/')
}
function isInside(root: string, target: string): boolean {
const rel = relative(root, target)
return rel === '' || (!rel.startsWith('..') && !rel.split(/[/\\]/).includes('..') && !/^[a-zA-Z]:/.test(rel))
}
/**
* Resolve `pathArg` (absolute, or relative to kbRoot, or `paths.<key>` shorthand like `rca`)
* to a real path inside the package. Symlinks are followed and re-checked.
*/
export function resolveKbReadPath(snapshot: KbSnapshot, pathArg: string): { absolutePath: string; relativePath: string } {
const root = requireRoot(snapshot)
const trimmed = pathArg.trim().replace(/^["']|["']$/g, '')
if (trimmed.includes('\0')) throw new Error('path contains NUL')
const anchor = resolveKbPaths(snapshot).find(row => row.key === trimmed)
const candidate = anchor
? anchor.absolute
: (!trimmed || trimmed === '.' || trimmed === '/') ? root : resolve(root, trimmed)
if (!isInside(root, candidate)) throw new Error(`path escapes kbRoot: ${candidate}`)
let real: string
try {
real = realpathSync(candidate)
} catch {
throw new Error(`not found in KB: ${toPosix(relative(root, candidate)) || '.'}`)
}
const realRoot = realpathSync(root)
if (!isInside(realRoot, real)) throw new Error(`path escapes kbRoot after realpath: ${real}`)
return { absolutePath: real, relativePath: toPosix(relative(realRoot, real)) || '.' }
}
export type KbReadResult =
| {
ok: true
kind: 'file'
absolutePath: string
relativePath: string
totalLines: number
startLine: number
endLine: number
truncated: boolean
text: string
}
| {
ok: true
kind: 'directory'
absolutePath: string
relativePath: string
entries: Array<{ name: string; type: 'dir' | 'file'; size?: number }>
}
/**
* Read a KB file (1-based `offset`, `limit` lines) or list a KB directory.
*/
export function readKbFile(
snapshot: KbSnapshot,
args: { path: string; offset?: number; limit?: number },
): KbReadResult {
const target = resolveKbReadPath(snapshot, args.path)
const st = statSync(target.absolutePath)
if (st.isDirectory()) {
const entries = readdirSync(target.absolutePath, { withFileTypes: true })
.filter(entry => !SKIP_DIRS.has(entry.name))
.map((entry) => {
if (entry.isDirectory()) return { name: entry.name, type: 'dir' as const }
let size: number | undefined
try { size = statSync(join(target.absolutePath, entry.name)).size } catch { /* ignore */ }
return { name: entry.name, type: 'file' as const, size }
})
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1))
return { ok: true, kind: 'directory', ...target, entries }
}
if (!TEXT_EXTENSIONS.has(extname(target.absolutePath).toLowerCase())) {
throw new Error(`not a text KB file: ${target.relativePath}`)
}
const lines = readFileSync(target.absolutePath, 'utf8').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').split('\n')
const start = Math.max(1, Math.floor(args.offset ?? 1))
const limit = Math.min(Math.max(1, Math.floor(args.limit ?? READ_DEFAULT_LINES)), READ_MAX_LINES)
let slice = lines.slice(start - 1, start - 1 + limit)
let text = slice.join('\n')
let truncated = start - 1 + slice.length < lines.length
if (text.length > READ_MAX_CHARS) {
let used = 0
let keep = 0
for (const line of slice) {
if (used + line.length + 1 > READ_MAX_CHARS) break
used += line.length + 1
keep += 1
}
slice = slice.slice(0, Math.max(keep, 1))
text = slice.join('\n').slice(0, READ_MAX_CHARS)
truncated = true
}
return {
ok: true,
kind: 'file',
...target,
totalLines: lines.length,
startLine: start,
endLine: start - 1 + slice.length,
truncated,
text,
}
}
interface CachedFile {
mtimeMs: number
size: number
text: string
/** Lower-cased once so plain searches don't re-lowercase every file per term. */
lower: string
}
const CACHE_KEY = Symbol.for('dsh-netxops.kb-search-cache')
function searchCache(): { files: Map<string, CachedFile>; chars: number } {
const root = globalThis as typeof globalThis & { [CACHE_KEY]?: { files: Map<string, CachedFile>; chars: number } }
root[CACHE_KEY] ??= { files: new Map(), chars: 0 }
return root[CACHE_KEY]
}
/** Read through a stat-validated cache so repeated searches don't re-read the pack. */
function cachedText(path: string, mtimeMs: number, size: number): CachedFile {
const cache = searchCache()
const hit = cache.files.get(path)
if (hit && hit.mtimeMs === mtimeMs && hit.size === size) return hit
const text = readFileSync(path, 'utf8').replace(/^\uFEFF/, '')
if (hit) cache.chars -= hit.text.length
if (cache.chars + text.length > CACHE_MAX_CHARS) {
cache.files.clear()
cache.chars = 0
}
const entry = { mtimeMs, size, text, lower: text.toLowerCase() }
cache.files.set(path, entry)
cache.chars += text.length
return entry
}
function walkTextFiles(dir: string, out: Array<{ path: string; mtimeMs: number; size: number }>): void {
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue
const full = join(dir, entry.name)
if (entry.isDirectory()) {
walkTextFiles(full, out)
continue
}
if (!entry.isFile() || !TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue
try {
const st = statSync(full)
if (st.size <= SEARCH_MAX_FILE_BYTES) out.push({ path: full, mtimeMs: st.mtimeMs, size: st.size })
} catch {
// unreadable — skip
}
}
}
export interface KbSearchHit {
relativePath: string
absolutePath: string
score: number
matchedTerms: string[]
lines: Array<{ line: number; text: string }>
}
/**
* Search KB text files. Plain mode: whitespace-separated terms, case-insensitive, files
* ranked by distinct terms matched (filename hits weigh more). `regex=true` treats the
* query as one JS regular expression.
*/
export function searchKb(
snapshot: KbSnapshot,
args: { query: string; scope?: string; regex?: boolean; limit?: number },
): { ok: true; scope: string; scannedFiles: number; totalMatches: number; hits: KbSearchHit[] } {
const root = requireRoot(snapshot)
const query = args.query.trim()
if (!query) throw new Error('query is empty')
const scopeArg = args.scope?.trim() || 'all'
const scopes = scopeArg.split(/[,\s]+/).filter(Boolean)
const dirs = scopes.includes('all')
? [root]
: scopes.map(scope => resolveKbReadPath(snapshot, scope).absolutePath)
/** `test(lower, raw)`: plain terms match the lower-cased text, regex the raw text. */
let matchers: Array<{ term: string; test: (lower: string, raw: string) => boolean }>
if (args.regex) {
let re: RegExp
try {
re = new RegExp(query, 'i')
} catch (error) {
throw new Error(`invalid regex: ${error instanceof Error ? error.message : String(error)}`)
}
matchers = [{ term: query, test: (_lower, raw) => re.test(raw) }]
} else {
const terms = [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))]
matchers = terms.map(term => ({ term, test: (lower: string) => lower.includes(term) }))
}
const files: Array<{ path: string; mtimeMs: number; size: number }> = []
const seen = new Set<string>()
for (const dir of dirs) {
const st = statSync(dir)
const batch: typeof files = []
if (st.isFile()) batch.push({ path: dir, mtimeMs: st.mtimeMs, size: st.size })
else walkTextFiles(dir, batch)
for (const file of batch) {
if (seen.has(file.path)) continue
seen.add(file.path)
files.push(file)
}
}
const hits: KbSearchHit[] = []
for (const file of files) {
let cached: CachedFile
try {
cached = cachedText(file.path, file.mtimeMs, file.size)
} catch {
continue
}
const rel = toPosix(relative(root, file.path))
const matched = new Set<string>()
let score = 0
for (const m of matchers) {
if (m.test(rel.toLowerCase(), rel)) {
matched.add(m.term)
score += 3
}
}
// Whole-file pre-filter: only split/scan lines of files that contain a term.
const inBody = matchers.filter(m => m.test(cached.lower, cached.text))
if (inBody.length === 0 && matched.size === 0) continue
const lines: Array<{ line: number; text: string }> = []
const rows = inBody.length > 0 ? cached.text.split(/\r\n?|\n/) : []
let lineHits = 0
for (let i = 0; i < rows.length; i += 1) {
const row = rows[i]!
const lower = row.toLowerCase()
let lineHit = false
for (const m of inBody) {
if (m.test(lower, row)) {
matched.add(m.term)
lineHit = true
}
}
if (!lineHit) continue
lineHits += 1
if (lines.length < SEARCH_HITS_PER_FILE) {
const trimmed = row.trim()
lines.push({ line: i + 1, text: trimmed.length > 240 ? `${trimmed.slice(0, 240)}…` : trimmed })
}
}
// Distinct terms dominate; hit volume only breaks ties so big manuals don't swamp RCAs.
score += matched.size * 10 + Math.min(lineHits, 30) * 0.1
hits.push({
relativePath: rel,
absolutePath: file.path,
score: Math.round(score * 10) / 10,
matchedTerms: [...matched],
lines,
})
}
hits.sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath))
const limit = Math.min(Math.max(Math.floor(args.limit ?? 20), 1), 100)
return { ok: true, scope: scopeArg, scannedFiles: files.length, totalMatches: hits.length, hits: hits.slice(0, limit) }
}

View file

@ -7,7 +7,7 @@
*/ */
import type { KbSnapshot } from './kb-manifest.ts' import type { KbSnapshot } from './kb-manifest.ts'
import { unconfiguredKbSnapshot } from './kb-manifest.ts' import { resolveKbPaths, unconfiguredKbSnapshot } from './kb-manifest.ts'
import { resolveKbLocalRoot } from './kb-local-path.ts' import { resolveKbLocalRoot } from './kb-local-path.ts'
type Listener = () => void type Listener = () => void
@ -26,6 +26,7 @@ const ENV_KEYS = [
'KB_COUNTRY', 'KB_COUNTRY',
'KB_VERSION', 'KB_VERSION',
'KB_CONTENT', 'KB_CONTENT',
'KB_PATHS',
'KB_STATUS', 'KB_STATUS',
] as const ] as const
@ -52,10 +53,12 @@ export function getKbContext(): KbSnapshot {
/** /**
* Publish the latest KB snapshot for UI / skill remounts. * Publish the latest KB snapshot for UI / skill remounts.
* @param next - resolved snapshot from `resolveKbRoot`. * @param next - resolved snapshot from `resolveKbRoot`.
* @param options.force - notify even when MANIFEST is unchanged (manual reload picks up
* edited SKILL.md / new local_skills / identity files on disk).
*/ */
export function publishKbContext(next: KbSnapshot): void { export function publishKbContext(next: KbSnapshot, options: { force?: boolean } = {}): void {
const state = store() const state = store()
if (JSON.stringify(state.snapshot) === JSON.stringify(next)) return if (!options.force && JSON.stringify(state.snapshot) === JSON.stringify(next)) return
state.snapshot = { state.snapshot = {
...next, ...next,
content: { ...next.content }, content: { ...next.content },
@ -94,6 +97,9 @@ export function applyKbEnv(snapshot: KbSnapshot): void {
process.env.KB_COUNTRY = snapshot.country process.env.KB_COUNTRY = snapshot.country
process.env.KB_VERSION = snapshot.version process.env.KB_VERSION = snapshot.version
process.env.KB_CONTENT = JSON.stringify(snapshot.content) process.env.KB_CONTENT = JSON.stringify(snapshot.content)
process.env.KB_PATHS = JSON.stringify(
Object.fromEntries(resolveKbPaths(snapshot).map(row => [row.key, row.absolute])),
)
} }
/** Reset store + env to unconfigured (plugin dispose). */ /** Reset store + env to unconfigured (plugin dispose). */

174
test/kb-loading.test.mjs Normal file
View file

@ -0,0 +1,174 @@
/**
* KB loading: BOM MANIFEST, resolved path anchors, identity inlining, pack footer /
* duplicate isolation, forced reload, and jailed read/search tools.
*/
import assert from 'node:assert/strict'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import test from 'node:test'
import { registerKbContextSkill } from '../src/netx/kb-context-skill.ts'
import { resolveKbPaths, resolveKbRoot } from '../src/netx/kb-manifest.ts'
import { registerKbLocalTools } from '../src/netx/kb-local-tools.ts'
import { registerKbPackSkills } from '../src/netx/kb-pack-skills.ts'
import { readKbFile, searchKb } from '../src/netx/kb-read-ops.ts'
import { applyKbEnv, publishKbContext, resetKbContext, watchKbContext } from '../src/netx/kb-runtime.ts'
const LOCAL = 'regions/ID/IOH/_local'
const BOM = String.fromCharCode(0xfeff)
function manifest() {
return JSON.stringify({
schemaVersion: '1.0',
packageType: 'operator-subset',
operator: { name: 'IOH', country: 'ID' },
version: '2026.09-v31',
content: { hasRegions: true, hasSkills: true },
paths: {
rca: 'regions/ID/IOH/00_有效RCA',
local: LOCAL,
localSkills: `${LOCAL}/local_skills`,
neIndex: 'regions/ID/IOH/00_索引/网元索引.md',
skills: '_skills',
},
}, null, 2)
}
function skill(dir, name) {
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${name} playbook\n---\n\n# ${name}\n`, 'utf8')
}
/** Minimal operator subset on disk; MANIFEST written with a UTF-8 BOM (PowerShell 5.1). */
async function withKb(run) {
const root = mkdtempSync(join(tmpdir(), 'netxops-kb-load-'))
const outside = `${root}-secret.md`
try {
writeFileSync(join(root, 'MANIFEST.json'), `${BOM}${manifest()}`, 'utf8')
const rca = join(root, 'regions/ID/IOH/00_有效RCA/02_路由')
mkdirSync(rca, { recursive: true })
writeFileSync(join(rca, '20250911-MC-Aceh-Flap.md'), '# Aceh flap\n\n- 涉及网元 | MDN-BCP-CN1\n- BGP session flap\n', 'utf8')
writeFileSync(join(rca, '20250101-other.md'), '# other\n\nISIS only\n', 'utf8')
mkdirSync(join(root, 'regions/ID/IOH/00_索引'), { recursive: true })
writeFileSync(join(root, 'regions/ID/IOH/00_索引/网元索引.md'), 'line1\nline2\nline3\n', 'utf8')
mkdirSync(join(root, LOCAL, 'identity'), { recursive: true })
writeFileSync(join(root, LOCAL, 'identity/README.md'), 'template only', 'utf8')
writeFileSync(join(root, LOCAL, 'identity/话术与边界.md'), '不对外承诺恢复时间', 'utf8')
skill(join(root, '_skills/kb-troubleshoot'), 'kb-troubleshoot')
skill(join(root, '_skills/kb-retrieve'), 'kb-retrieve')
skill(join(root, LOCAL, 'local_skills/kb-retrieve'), 'kb-retrieve')
skill(join(root, LOCAL, 'local_skills/ioh-private'), 'ioh-private')
writeFileSync(outside, 'outside', 'utf8')
await run(root)
} finally {
rmSync(root, { recursive: true, force: true })
rmSync(outside, { force: true })
}
}
function fakeCtx() {
const skills = new Map()
const tools = new Map()
const warnings = []
// Mirrors DSH: registering an existing name throws.
const register = map => (entry) => {
if (map.has(entry.name)) throw new Error(`duplicate: ${entry.name}`)
map.set(entry.name, entry)
return () => { map.delete(entry.name) }
}
return {
skills,
tools,
warnings,
ctx: {
logger: { info() {}, warn(...args) { warnings.push(args) }, error() {} },
skills: { register: register(skills) },
tools: { register: register(tools) },
},
}
}
test('MANIFEST with UTF-8 BOM resolves as configured', () => withKb((root) => {
const kb = resolveKbRoot(root)
assert.equal(kb.status, 'configured', kb.errorMessage)
assert.equal(kb.version, '2026.09-v31')
}))
test('resolveKbPaths returns absolute anchors and KB_PATHS mirrors them', () => withKb((root) => {
const kb = resolveKbRoot(root)
const rows = Object.fromEntries(resolveKbPaths(kb).map(row => [row.key, row]))
assert.equal(rows.rca.exists, true)
assert.equal(rows.neIndex.absolute, join(root, 'regions/ID/IOH/00_索引/网元索引.md'))
applyKbEnv(kb)
assert.equal(JSON.parse(process.env.KB_PATHS).rca, rows.rca.absolute)
resetKbContext()
assert.equal(process.env.KB_PATHS, undefined)
}))
test('kb-context carries path anchors and inlines identity files (not README)', () => withKb((root) => {
const { ctx, skills } = fakeCtx()
registerKbContextSkill(ctx, resolveKbRoot(root))
const body = skills.get('kb-context').content
assert.ok(body.includes(join(root, 'regions/ID/IOH/00_索引/网元索引.md')))
assert.ok(body.includes('不对外承诺恢复时间'))
assert.equal(body.includes('template only'), false)
assert.match(skills.get('kb-context').description, /IOH/)
}))
test('pack skills get the anchor footer; duplicate local name is skipped, not fatal', () => withKb(async (root) => {
const { ctx, skills, warnings } = fakeCtx()
const dispose = await registerKbPackSkills(ctx, resolveKbRoot(root), { enabled: true })
assert.deepEqual([...skills.keys()].sort(), ['ioh-private', 'kb-retrieve', 'kb-troubleshoot'])
assert.ok(skills.get('kb-troubleshoot').content.includes(join(root, 'regions/ID/IOH/00_有效RCA')))
assert.ok(warnings.some(args => String(args[0]).includes('duplicates')))
dispose()
assert.equal(skills.size, 0)
}))
test('publishKbContext force re-notifies an unchanged snapshot', () => withKb((root) => {
const kb = resolveKbRoot(root)
publishKbContext(kb)
let calls = 0
const stop = watchKbContext(() => { calls += 1 })
publishKbContext(kb)
assert.equal(calls, 0)
publishKbContext(kb, { force: true })
assert.equal(calls, 1)
stop()
resetKbContext()
}))
test('kbSearch ranks multi-term hits first and honours scope', () => withKb((root) => {
const kb = resolveKbRoot(root)
const result = searchKb(kb, { query: 'mdn-bcp-cn bgp', scope: 'rca' })
assert.ok(result.hits[0].relativePath.endsWith('20250911-MC-Aceh-Flap.md'))
assert.deepEqual(result.hits[0].matchedTerms.sort(), ['bgp', 'mdn-bcp-cn'])
assert.equal(result.hits[0].lines[0].line, 3)
assert.equal(searchKb(kb, { query: 'isis', scope: 'neIndex' }).totalMatches, 0)
assert.equal(searchKb(kb, { query: 'IS.S\\s+only', regex: true }).totalMatches, 1)
assert.throws(() => searchKb(kb, { query: '(', regex: true }), /invalid regex/)
}))
test('kbRead pages files, lists dirs, and stays inside kbRoot', () => withKb((root) => {
const kb = resolveKbRoot(root)
const page = readKbFile(kb, { path: 'neIndex', offset: 2, limit: 1 })
assert.equal(page.text, 'line2')
assert.equal(page.truncated, true)
const dir = readKbFile(kb, { path: 'rca' })
assert.equal(dir.kind, 'directory')
assert.deepEqual(dir.entries.map(e => e.name), ['02_路由'])
assert.throws(() => readKbFile(kb, { path: `../${basename(root)}-secret.md` }), /escapes kbRoot/)
assert.throws(() => readKbFile(kb, { path: `${root}-secret.md` }), /escapes kbRoot/)
}))
test('read tools mount without paths.local; write tools need it', () => withKb((root) => {
const kb = resolveKbRoot(root)
const readOnly = fakeCtx()
registerKbLocalTools(readOnly.ctx, { ...kb, paths: { rca: kb.paths.rca } })
assert.deepEqual([...readOnly.tools.keys()].sort(), ['netx__kbRead', 'netx__kbSearch'])
const full = fakeCtx()
registerKbLocalTools(full.ctx, kb)
assert.equal(full.tools.size, 9)
}))

View file

@ -147,10 +147,11 @@ test('knowledge-base tools contribute to the mount inventory and disappear on di
const mount = fakeContext() const mount = fakeContext()
const snapshot = { status: 'configured', realRoot: process.env.DSH_HOME, paths: { local: '_local' } } const snapshot = { status: 'configured', realRoot: process.env.DSH_HOME, paths: { local: '_local' } }
const dispose = registerKbLocalTools(mount.ctx, snapshot, { plane: 'preset', source: 'kb-test' }) const dispose = registerKbLocalTools(mount.ctx, snapshot, { plane: 'preset', source: 'kb-test' })
assert.equal(mount.tools.size, 7) // 2 read (kbSearch / kbRead) + 7 local write/list tools.
assert.equal(mount.tools.size, 9)
const statuses = getToolMountStatuses() const statuses = getToolMountStatuses()
assert.ok(statuses.some(row => row.source === 'kb-test' && row.count === 7 && row.groups.includes('kb'))) assert.ok(statuses.some(row => row.source === 'kb-test' && row.count === 9 && row.groups.includes('kb')))
assert.equal(asToolsMountView({ mounts: statuses }).count, 7) assert.equal(asToolsMountView({ mounts: statuses }).count, 9)
dispose() dispose()
assert.equal(mount.tools.size, 0) assert.equal(mount.tools.size, 0)
assert.equal(getToolMountStatuses().some(row => row.source === 'kb-test'), false) assert.equal(getToolMountStatuses().some(row => row.source === 'kb-test'), false)