diff --git a/.release-build.json b/.release-build.json index 0a419d9..081143e 100644 --- a/.release-build.json +++ b/.release-build.json @@ -2,9 +2,9 @@ "format": 1, "builds": { "lib/agent-tools-biz-monitor.js": { - "output": "2e870f4d26b8b9560ad4ebced0e8c6b02bc265bd677acae0e85e7f98de1e22ff", + "output": "8e72f3ef56c2700444531f0c0af778efbb4838b1f67d42e4fc8711aa575f544a", "inputs": { - "package.json": "3b8ffe892624815c6dab4313c01056fc2c2d573a3e4e6771a6ff63918d4a8dab", + "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", @@ -16,13 +16,14 @@ "src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d", "src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d", "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-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c", - "src/netx/kb-local-tools.ts": "c3ae8119e1a917498d945161bfe4965401fce5d167144d6500fba8205993511a", - "src/netx/kb-manifest.ts": "17dcf4ede421485eabcefde63232c8d79e55e76c0374eae74405fb3a35328166", - "src/netx/kb-pack-skills.ts": "0a2e43f04b2301707439764d29a60d60aeb07946a66dfc024f1b6aed8a3556d8", - "src/netx/kb-runtime.ts": "71ea48399260ebb228633f26d77411d808c41b7c17946901582af387070f1a3d", + "src/netx/kb-local-tools.ts": "5dc2f5c058231b96c130b67cfcf0458bda9804d12ed1746a4b3db371bfcc8881", + "src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02", + "src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5", + "src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86", + "src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b", "src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508", @@ -30,9 +31,9 @@ } }, "lib/agent-tools-common.js": { - "output": "87742324e6d39db0e42a2a28866a97835aacfceb24d6105ff7a76bc9ec00af2d", + "output": "a4bd50da911bed64245ebee31a27f4f112729e7b35b99816671c3253e89719f0", "inputs": { - "package.json": "3b8ffe892624815c6dab4313c01056fc2c2d573a3e4e6771a6ff63918d4a8dab", + "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", @@ -44,13 +45,14 @@ "src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d", "src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d", "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-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c", - "src/netx/kb-local-tools.ts": "c3ae8119e1a917498d945161bfe4965401fce5d167144d6500fba8205993511a", - "src/netx/kb-manifest.ts": "17dcf4ede421485eabcefde63232c8d79e55e76c0374eae74405fb3a35328166", - "src/netx/kb-pack-skills.ts": "0a2e43f04b2301707439764d29a60d60aeb07946a66dfc024f1b6aed8a3556d8", - "src/netx/kb-runtime.ts": "71ea48399260ebb228633f26d77411d808c41b7c17946901582af387070f1a3d", + "src/netx/kb-local-tools.ts": "5dc2f5c058231b96c130b67cfcf0458bda9804d12ed1746a4b3db371bfcc8881", + "src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02", + "src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5", + "src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86", + "src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b", "src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508", @@ -58,9 +60,9 @@ } }, "lib/agent-tools-nms.js": { - "output": "5433fcebca463c2d3bde07f5cc948f572706153c41797e0d805c2a6c9fbff9a0", + "output": "b67bb855df12beb0ee24c1c3cd3fb0d42203c060568731784e9a14b545060214", "inputs": { - "package.json": "3b8ffe892624815c6dab4313c01056fc2c2d573a3e4e6771a6ff63918d4a8dab", + "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", @@ -72,13 +74,14 @@ "src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d", "src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d", "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-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c", - "src/netx/kb-local-tools.ts": "c3ae8119e1a917498d945161bfe4965401fce5d167144d6500fba8205993511a", - "src/netx/kb-manifest.ts": "17dcf4ede421485eabcefde63232c8d79e55e76c0374eae74405fb3a35328166", - "src/netx/kb-pack-skills.ts": "0a2e43f04b2301707439764d29a60d60aeb07946a66dfc024f1b6aed8a3556d8", - "src/netx/kb-runtime.ts": "71ea48399260ebb228633f26d77411d808c41b7c17946901582af387070f1a3d", + "src/netx/kb-local-tools.ts": "5dc2f5c058231b96c130b67cfcf0458bda9804d12ed1746a4b3db371bfcc8881", + "src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02", + "src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5", + "src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86", + "src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b", "src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508", @@ -86,9 +89,9 @@ } }, "lib/agent-tools-ops.js": { - "output": "9945eb9b4e400b5cab9de885f75d29cc43da53f7e129071b22d019c10ed8e698", + "output": "4515ae3096e1698f6e8b059c84eb4b91f0f3f62ca1831551484f581d29c28a80", "inputs": { - "package.json": "3b8ffe892624815c6dab4313c01056fc2c2d573a3e4e6771a6ff63918d4a8dab", + "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", @@ -100,13 +103,14 @@ "src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d", "src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d", "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-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c", - "src/netx/kb-local-tools.ts": "c3ae8119e1a917498d945161bfe4965401fce5d167144d6500fba8205993511a", - "src/netx/kb-manifest.ts": "17dcf4ede421485eabcefde63232c8d79e55e76c0374eae74405fb3a35328166", - "src/netx/kb-pack-skills.ts": "0a2e43f04b2301707439764d29a60d60aeb07946a66dfc024f1b6aed8a3556d8", - "src/netx/kb-runtime.ts": "71ea48399260ebb228633f26d77411d808c41b7c17946901582af387070f1a3d", + "src/netx/kb-local-tools.ts": "5dc2f5c058231b96c130b67cfcf0458bda9804d12ed1746a4b3db371bfcc8881", + "src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02", + "src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5", + "src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86", + "src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b", "src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508", @@ -114,9 +118,9 @@ } }, "lib/agent-tools-topology.js": { - "output": "44f4fba8d174bdcfc6b55a38d1fb1e32828f0ac8c3c87a14ff02a43693ca46ef", + "output": "54f0b223025fcc1618771cb1148f41547cb66ea296e238b9ad214fef28bd73ae", "inputs": { - "package.json": "3b8ffe892624815c6dab4313c01056fc2c2d573a3e4e6771a6ff63918d4a8dab", + "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", @@ -128,13 +132,14 @@ "src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d", "src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d", "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-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c", - "src/netx/kb-local-tools.ts": "c3ae8119e1a917498d945161bfe4965401fce5d167144d6500fba8205993511a", - "src/netx/kb-manifest.ts": "17dcf4ede421485eabcefde63232c8d79e55e76c0374eae74405fb3a35328166", - "src/netx/kb-pack-skills.ts": "0a2e43f04b2301707439764d29a60d60aeb07946a66dfc024f1b6aed8a3556d8", - "src/netx/kb-runtime.ts": "71ea48399260ebb228633f26d77411d808c41b7c17946901582af387070f1a3d", + "src/netx/kb-local-tools.ts": "5dc2f5c058231b96c130b67cfcf0458bda9804d12ed1746a4b3db371bfcc8881", + "src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02", + "src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5", + "src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86", + "src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b", "src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508", @@ -142,9 +147,9 @@ } }, "lib/agent-tools.js": { - "output": "a1978da94dae7fc721c386a147a4ddbab9ec1a055ab86ccedc3565e0f96bced5", + "output": "16f2f1ec267929d2055546eebf980544db5758590a7e1b4073c2d4ae5843ddde", "inputs": { - "package.json": "3b8ffe892624815c6dab4313c01056fc2c2d573a3e4e6771a6ff63918d4a8dab", + "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", @@ -156,13 +161,14 @@ "src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d", "src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d", "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-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c", - "src/netx/kb-local-tools.ts": "c3ae8119e1a917498d945161bfe4965401fce5d167144d6500fba8205993511a", - "src/netx/kb-manifest.ts": "17dcf4ede421485eabcefde63232c8d79e55e76c0374eae74405fb3a35328166", - "src/netx/kb-pack-skills.ts": "0a2e43f04b2301707439764d29a60d60aeb07946a66dfc024f1b6aed8a3556d8", - "src/netx/kb-runtime.ts": "71ea48399260ebb228633f26d77411d808c41b7c17946901582af387070f1a3d", + "src/netx/kb-local-tools.ts": "5dc2f5c058231b96c130b67cfcf0458bda9804d12ed1746a4b3db371bfcc8881", + "src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02", + "src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5", + "src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86", + "src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b", "src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", "src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508", @@ -172,7 +178,7 @@ "lib/client.js": { "output": "cd0b85687902aae42158838e000f7e9a6bef488abbcbf6c7885d0a87c40e00f8", "inputs": { - "package.json": "3b8ffe892624815c6dab4313c01056fc2c2d573a3e4e6771a6ff63918d4a8dab", + "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "scripts/build-client.mjs": "bbb37a7d5024772f35824a4a3b5b9bc75fc670da391151e35a3811a677e23fd1", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", @@ -193,19 +199,19 @@ "src/netx/config-values.ts": "7e8ca894d0b09f5e52686d6b293e49b46dd2cc13d714b0e0b103c3d07433e117", "src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d", "src/netx/im-targets.ts": "123613038dee8b14ce63ea46e88029287c1a8c527e6ba6cadab02f8c95b28917", - "src/netx/kb-manifest.ts": "17dcf4ede421485eabcefde63232c8d79e55e76c0374eae74405fb3a35328166", + "src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02", "src/session-export-shared.ts": "e30d884ec22a0ea2e016c1479f62a8bc8d7ddcce5369a36647d411d07a8620d5" } }, "lib/index.js": { - "output": "514890b88808556213c851071faf0c73a4e5761a5eac73c9a2bd3376a08fb304", + "output": "982dd1a42b990fdd17467a5963b11494ac1b5f32ba54dffdeee414ed84df5908", "inputs": { - "package.json": "3b8ffe892624815c6dab4313c01056fc2c2d573a3e4e6771a6ff63918d4a8dab", + "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", "scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8", "scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9", "src/agent-preset-install.ts": "1f397bb8e1c9a3f13cb7e4c442c3d13c42fce94fbc4eb1d90b85a0485bda48bc", - "src/index.ts": "a7c4a08590af7cfe2a9fa671fa5518e9b6de13d96f5360349e4d8a6ed2cb0517", + "src/index.ts": "dc869281bf04502e63abf69d7502afa834cfb2f6cbafb8e1a97e009c15844d61", "src/netx/alarm-dispatch.ts": "9f476f1c620e40d3e2017554ec2fa8a1848f75d7e77e7c9ee5ee0464e970ba62", "src/netx/alarm-im.ts": "a75b37e379500cbb90f1f4b81d448d45a97fd9e744e507608910f510480cffaf", "src/netx/alarm-push-status.ts": "21f0efa33ea6f21e3ede35b4accbc6c0c4c78fea23631f1c5b80e6e9aef8b82f", @@ -221,13 +227,14 @@ "src/netx/i18n.ts": "9278e4adaa96cae413326f4dd8c796f2712d4749bcf92e5170967e38cee2d082", "src/netx/im-targets.ts": "123613038dee8b14ce63ea46e88029287c1a8c527e6ba6cadab02f8c95b28917", "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-path.ts": "343b8f653e3016f41396125c0a82b015ba325698d4b1642bf4459dd0365d153c", - "src/netx/kb-local-tools.ts": "c3ae8119e1a917498d945161bfe4965401fce5d167144d6500fba8205993511a", - "src/netx/kb-manifest.ts": "17dcf4ede421485eabcefde63232c8d79e55e76c0374eae74405fb3a35328166", - "src/netx/kb-pack-skills.ts": "0a2e43f04b2301707439764d29a60d60aeb07946a66dfc024f1b6aed8a3556d8", - "src/netx/kb-runtime.ts": "71ea48399260ebb228633f26d77411d808c41b7c17946901582af387070f1a3d", + "src/netx/kb-local-tools.ts": "5dc2f5c058231b96c130b67cfcf0458bda9804d12ed1746a4b3db371bfcc8881", + "src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02", + "src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5", + "src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86", + "src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b", "src/netx/model-language.ts": "07df448b407086237c184a71bfc78e7cb27e50d7f8c499995d8b368b14780c9e", "src/netx/netxops-web-rpc.ts": "c6981c91b0a300806d44f45708f07c5102ec40655e5d387394608e5472ad3214", "src/netx/preset-publication.ts": "22e357cb58bf72cb0d269246f9e3e083269feb1706a9c4f152eb042330c9e967", diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 0acef7b..106f86d 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -22,7 +22,7 @@ dsh web 使用已经下载的发布包,例如: ```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 ``` diff --git a/docs/KNOWLEDGE_BASE.md b/docs/KNOWLEDGE_BASE.md index b15f53b..ee96d32 100644 --- a/docs/KNOWLEDGE_BASE.md +++ b/docs/KNOWLEDGE_BASE.md @@ -37,12 +37,15 @@ Authoritative packaging prose lives in the workspace contract notes (`插件` / | Channel | Fields | |---------|--------| -| `process.env` | `KB_ROOT`, `KB_LOCAL` (when `paths.local` set), `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT` (JSON of `has*`), `KB_STATUS` | -| Skill `kb-context` | Same MANIFEST fields + `kbLocal` + dual-plane write rules (refs / memories / drafts / suggestions) | -| Skills from pack | Registered when `configured` **and** `hasSkills` **and** the inPreset/public toggle for that plane | +| `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` + **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. Each pack skill gets the same resolved anchor table appended, so playbooks never need to re-read MANIFEST or compose `regions///…` | +| 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. | -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 diff --git a/docs/TOOL_MAP.md b/docs/TOOL_MAP.md index 0b00ad4..4e16970 100644 --- a/docs/TOOL_MAP.md +++ b/docs/TOOL_MAP.md @@ -49,10 +49,12 @@ Execution: HTTP `apiUrl` + Bearer `NETX_API_TOKEN`. ## knowledge base → `groupKb*` (not ops/topology) -Registered when KB is configured, MANIFEST has `paths.local`, and the KB in-preset / public toggle is on. Host-side FS writes (bypass workspace sandbox). Jail: `{kbLocal}/refs|memories|drafts|suggestions`. +Registered when KB is configured 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 | |------|------| +| `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`. | | `kbWriteMemory` | Diary under `memories/{日常笔记\|排障复盘\|AI思维链}/` — write as soon as reusable experience appears. | | `kbWriteDraft` | New `DRAFT-…` under `drafts/` (+ `status: draft`) | diff --git a/lib/agent-tools-biz-monitor.js b/lib/agent-tools-biz-monitor.js index 9587521..e7e7da3 100644 --- a/lib/agent-tools-biz-monitor.js +++ b/lib/agent-tools-biz-monitor.js @@ -1,6 +1,6 @@ // src/netx/group-tools-plugin.ts -import { readFileSync as readFileSync3 } from "node:fs"; -import { dirname as dirname5, join as join7 } from "node:path"; +import { readFileSync as readFileSync6 } from "node:fs"; +import { dirname as dirname6, join as join10 } from "node:path"; import { fileURLToPath as fileURLToPath3 } from "node:url"; // src/netx/config-values.ts @@ -324,9 +324,56 @@ Use only tools present in this session. Native layout supports catalog and move_ }; } +// src/netx/kb-context-skill.ts +import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync3 } from "node:fs"; +import { join as join4 } from "node:path"; + +// src/netx/kb-manifest.ts +import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "node:fs"; +import { basename, dirname as dirname2, join as join2, resolve } from "node:path"; +var EMPTY_CONTENT = { + hasRegions: false, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false +}; +function unconfiguredKbSnapshot() { + return { + status: "unconfigured", + realRoot: "", + operatorName: "", + country: "", + version: "", + content: { ...EMPTY_CONTENT }, + paths: {}, + errorMessage: "" + }; +} +function resolveKbPaths(snapshot) { + if (snapshot.status !== "configured") + return []; + const paths = { skills: "_skills", ...snapshot.paths }; + return Object.entries(paths).filter((entry) => typeof entry[1] === "string" && entry[1].trim() !== "").map(([key, rel]) => { + const absolute = resolve(snapshot.realRoot, rel); + return { key, relative: rel, absolute, exists: existsSync2(absolute) }; + }); +} +function renderKbPathTable(snapshot) { + const rows = resolveKbPaths(snapshot); + if (rows.length === 0) + return "(MANIFEST has no paths — legacy package; ask HQ for a repack)"; + return [ + "| paths.* | Absolute path |", + "| --- | --- |", + ...rows.map((row) => `| ${row.key} | \`${row.absolute}\`${row.exists ? "" : " (missing)"} |`) + ].join(` +`); +} + // src/netx/kb-local-path.ts -import { existsSync as existsSync2, realpathSync, statSync } from "node:fs"; -import { join as join2, relative, resolve, sep } from "node:path"; +import { existsSync as existsSync3, realpathSync, statSync as statSync2 } from "node:fs"; +import { join as join3, relative, resolve as resolve2, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", @@ -352,10 +399,7 @@ function resolveKbLocalRoot(snapshot) { const rel = snapshot.paths.local?.trim(); if (!rel) return null; - return resolve(snapshot.realRoot, rel); -} -function kbLocalToolsEnabled(snapshot) { - return resolveKbLocalRoot(snapshot) !== null; + return resolve2(snapshot.realRoot, rel); } function sanitizeSlug(raw, fallback = "entry") { const cleaned = raw.trim().replace(/[/\\]+/g, "-").replace(/\.\.+/g, "").replace(/[^\w.\u4e00-\u9fff-]+/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); @@ -407,7 +451,7 @@ function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); } - const absolutePath = resolve(localRoot, ...segments); + const absolutePath = resolve2(localRoot, ...segments); const rel = relative(localRoot, absolutePath); if (!rel || rel.startsWith("..") || rel.split(/[/\\]/).includes("..")) { throw new Error(`path escapes local root: ${absolutePath}`); @@ -426,18 +470,18 @@ function resolveExistingWritableFile(localRoot, pathArg) { const trimmed = pathArg.trim(); if (!trimmed) throw new Error("path is empty"); - const candidate = resolve(trimmed); + const candidate = resolve2(trimmed); let absolutePath; - const localResolved = resolve(localRoot); + const localResolved = resolve2(localRoot); const underLocal = candidate === localResolved || candidate.startsWith(localResolved + sep); if (underLocal) { absolutePath = candidate; } else { - absolutePath = resolve(localRoot, trimmed); + absolutePath = resolve2(localRoot, trimmed); } const checked = resolveWritableLocalPath(localRoot, ...relative(localRoot, absolutePath).split(/[/\\]/).filter(Boolean)); try { - if (existsSync2(checked.absolutePath)) { + if (existsSync3(checked.absolutePath)) { const realFile = realpathSync(checked.absolutePath); const realLocal = realpathSync(localRoot); const realRel = relative(realLocal, realFile); @@ -448,7 +492,7 @@ function resolveExistingWritableFile(localRoot, pathArg) { if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) { throw new Error(`path not under writable roots: ${realRel}`); } - if (!statSync(realFile).isFile()) { + if (!statSync2(realFile).isFile()) { throw new Error(`not a file: ${realFile}`); } return { @@ -465,16 +509,16 @@ function resolveExistingWritableFile(localRoot, pathArg) { return checked; } function memoryDir(bucket) { - return join2("memories", MEMORY_BUCKET_DIR[bucket]); + return join3("memories", MEMORY_BUCKET_DIR[bucket]); } function suggestionDir(kind) { - return join2("suggestions", kind); + return join3("suggestions", kind); } function draftDir(domain) { const d = domain?.trim(); if (!d) return "drafts"; - return join2("drafts", sanitizeSlug(d, "misc")); + return join3("drafts", sanitizeSlug(d, "misc")); } function refDir(area, device) { if (!REF_AREAS.includes(area)) { @@ -485,18 +529,63 @@ function refDir(area, device) { if (!host) { throw new Error("refs/devices requires device (host_name)"); } - return join2("refs", "devices", host); + return join3("refs", "devices", host); } - return join2("refs", area); + return join3("refs", area); } // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; +var IDENTITY_FILE_MAX = 8000; +var IDENTITY_TOTAL_MAX = 24000; +function readKbIdentity(snapshot) { + const localRoot = resolveKbLocalRoot(snapshot); + if (!localRoot) + return []; + const dir = join4(localRoot, "identity"); + let names; + try { + names = readdirSync2(dir).filter((name) => /\.md$/i.test(name) && !/^readme\.md$/i.test(name)).sort(); + } catch { + return []; + } + const out = []; + let budget = IDENTITY_TOTAL_MAX; + for (const name of names) { + if (budget <= 0) + break; + const full = join4(dir, name); + let text; + try { + if (!statSync3(full).isFile()) + continue; + text = readFileSync2(full, "utf8").replace(/^\uFEFF/, "").trim(); + } catch { + continue; + } + if (!text) + continue; + const cap = Math.min(IDENTITY_FILE_MAX, budget); + if (text.length > cap) + text = `${text.slice(0, cap)} + +…(truncated — read \`${full}\` for the rest)`; + budget -= text.length; + out.push({ name, text }); + } + return out; +} function skillBody(snapshot) { if (snapshot.status === "configured") { const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)"; const localRoot = resolveKbLocalRoot(snapshot); const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; + const identity = readKbIdentity(snapshot); + const identityBlock = identity.length > 0 ? [ + "", + "### Site identity (`identity/`, authoritative — follow over generic advice)", + ...identity.flatMap(({ name, text }) => ["", `#### ${name}`, "", text]) + ] : []; const localGuide = localRoot ? [ "", "### Dual plane (mandatory)", @@ -521,7 +610,7 @@ function skillBody(snapshot) { "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", + description: `Load FIRST before any kb-* skill or KB file access: resolved absolute paths (rca / theory / packet / neIndex / cmdLib / local …) for operator ${snapshot.operatorName} (${snapshot.country}) KB v${snapshot.version}, site identity rules, and KB write rules.`, content: [ "## Knowledge base (configured)", "", @@ -537,13 +626,22 @@ function skillBody(snapshot) { `| kbCountry | ${snapshot.country} |`, `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, + "", + "### Path anchors (resolved from MANIFEST `paths.*` — use verbatim, never compose `regions///…`)", + "", + renderKbPathTable(snapshot), + ...identityBlock, ...localGuide, "", - "Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "### Reading the HQ pack", + "- `netx__kbSearch` — keyword/regex search across rca / theory / packet / common / local (returns file + line hits).", + "- `netx__kbRead` — read one KB file (path relative to kbRoot or absolute inside it; optional line window).", + "- Start from the indexes: `neIndex` (NE → cases), `patternMap`, `classification`, then open the cited RCA.", "", - "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from", - `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, - "plus paths.localSkills when present — use those skills for KB triage;", + "Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_PATHS (JSON of the anchors above), KB_STATUS.", + "", + "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from paths.skills", + "when hasSkills is true, plus paths.localSkills when present — use those skills for KB triage;", "keep netx-ops for live netx evidence only." ].join(` `) @@ -584,15 +682,15 @@ import { defineTool } from "@deepseek-ai/dsh-tools"; // src/netx/kb-local-ops.ts import { - existsSync as existsSync3, + existsSync as existsSync4, mkdirSync, - readdirSync, - readFileSync, + readdirSync as readdirSync3, + readFileSync as readFileSync3, rmSync, - statSync as statSync2, + statSync as statSync4, writeFileSync } from "node:fs"; -import { dirname as dirname2, join as join3 } from "node:path"; +import { dirname as dirname3, join as join5 } from "node:path"; function requireLocalRoot(snapshot) { const root = resolveKbLocalRoot(snapshot); if (!root) { @@ -638,11 +736,11 @@ function createMemory(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = memoryFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, memoryDir(args.bucket), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -657,11 +755,11 @@ function createDraft(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = draftFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, draftDir(args.domain), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, ensureDraftBody(args.body), "utf8"); return { ok: true, @@ -674,11 +772,11 @@ function createSuggestion(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = suggestionFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, suggestionDir(args.kind), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -693,11 +791,11 @@ function createRef(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = refFileName(args.slug); const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -711,7 +809,7 @@ function createRef(snapshot, args) { function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); - if (!existsSync3(target.absolutePath)) { + if (!existsSync4(target.absolutePath)) { throw new Error(`file not found: ${target.relativePath}`); } const body = target.writableRoot === "drafts" ? ensureDraftBody(args.body) : args.body.endsWith(` @@ -728,7 +826,7 @@ function updateLocalFile(snapshot, args) { function deleteLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); - if (!existsSync3(target.absolutePath)) { + if (!existsSync4(target.absolutePath)) { throw new Error(`file not found: ${target.relativePath}`); } rmSync(target.absolutePath, { force: false }); @@ -742,17 +840,17 @@ function deleteLocalFile(snapshot, args) { function walkFiles(dir, baseLocal, out) { let entries; try { - entries = readdirSync(dir); + entries = readdirSync3(dir); } catch { return; } for (const name of entries) { if (name === "." || name === "..") continue; - const full = join3(dir, name); + const full = join5(dir, name); let st; try { - st = statSync2(full); + st = statSync4(full); } catch { continue; } @@ -774,7 +872,7 @@ function listLocalFiles(snapshot, args = {}) { const roots = args.root && args.root !== "all" ? [args.root] : [...KB_LOCAL_WRITABLE_ROOTS]; const entries = []; for (const r of roots) { - walkFiles(join3(localRoot, r), localRoot, entries); + walkFiles(join5(localRoot, r), localRoot, entries); } entries.sort((a, b) => b.mtimeMs - a.mtimeMs); const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); @@ -785,26 +883,240 @@ function listLocalFiles(snapshot, args = {}) { }; } -// src/netx/kb-manifest.ts -var EMPTY_CONTENT = { - hasRegions: false, - hasTheory: false, - hasPacket: false, - hasCommon: false, - hasSkills: false -}; -function unconfiguredKbSnapshot() { +// src/netx/kb-read-ops.ts +import { readdirSync as readdirSync4, readFileSync as readFileSync4, realpathSync as realpathSync2, statSync as statSync5 } from "node:fs"; +import { extname, join as join6, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path"; +var TEXT_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".json", ".yml", ".yaml", ".csv", ".ps1", ".sh", ".cfg", ".conf", ".log"]); +var SKIP_DIRS = new Set([".git", "node_modules"]); +var READ_DEFAULT_LINES = 400; +var READ_MAX_LINES = 2000; +var READ_MAX_CHARS = 80000; +var SEARCH_MAX_FILE_BYTES = 2 * 1024 * 1024; +var SEARCH_HITS_PER_FILE = 4; +var CACHE_MAX_CHARS = 64 * 1024 * 1024; +function requireRoot(snapshot) { + if (snapshot.status !== "configured" || !snapshot.realRoot) { + throw new Error("knowledge base not configured (set kbRoot to a folder with MANIFEST.json)"); + } + return snapshot.realRoot; +} +function toPosix(rel) { + return rel.split(sep2).join("/"); +} +function isInside(root, target) { + const rel = relative2(root, target); + return rel === "" || !rel.startsWith("..") && !rel.split(/[/\\]/).includes("..") && !/^[a-zA-Z]:/.test(rel); +} +function resolveKbReadPath(snapshot, pathArg) { + const root = requireRoot(snapshot); + const trimmed = pathArg.trim().replace(/^["']|["']$/g, ""); + if (trimmed.includes("\x00")) + throw new Error("path contains NUL"); + const anchor = resolveKbPaths(snapshot).find((row) => row.key === trimmed); + const candidate = anchor ? anchor.absolute : !trimmed || trimmed === "." || trimmed === "/" ? root : resolve3(root, trimmed); + if (!isInside(root, candidate)) + throw new Error(`path escapes kbRoot: ${candidate}`); + let real; + try { + real = realpathSync2(candidate); + } catch { + throw new Error(`not found in KB: ${toPosix(relative2(root, candidate)) || "."}`); + } + const realRoot = realpathSync2(root); + if (!isInside(realRoot, real)) + throw new Error(`path escapes kbRoot after realpath: ${real}`); + return { absolutePath: real, relativePath: toPosix(relative2(realRoot, real)) || "." }; +} +function readKbFile(snapshot, args) { + const target = resolveKbReadPath(snapshot, args.path); + const st = statSync5(target.absolutePath); + if (st.isDirectory()) { + const entries = readdirSync4(target.absolutePath, { withFileTypes: true }).filter((entry) => !SKIP_DIRS.has(entry.name)).map((entry) => { + if (entry.isDirectory()) + return { name: entry.name, type: "dir" }; + let size; + try { + size = statSync5(join6(target.absolutePath, entry.name)).size; + } catch {} + return { name: entry.name, type: "file", size }; + }).sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1); + return { ok: true, kind: "directory", ...target, entries }; + } + if (!TEXT_EXTENSIONS.has(extname(target.absolutePath).toLowerCase())) { + throw new Error(`not a text KB file: ${target.relativePath}`); + } + const lines = readFileSync4(target.absolutePath, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, ` +`).split(` +`); + const start = Math.max(1, Math.floor(args.offset ?? 1)); + const limit = Math.min(Math.max(1, Math.floor(args.limit ?? READ_DEFAULT_LINES)), READ_MAX_LINES); + let slice = lines.slice(start - 1, start - 1 + limit); + let text = slice.join(` +`); + let truncated = start - 1 + slice.length < lines.length; + if (text.length > READ_MAX_CHARS) { + let used = 0; + let keep = 0; + for (const line of slice) { + if (used + line.length + 1 > READ_MAX_CHARS) + break; + used += line.length + 1; + keep += 1; + } + slice = slice.slice(0, Math.max(keep, 1)); + text = slice.join(` +`).slice(0, READ_MAX_CHARS); + truncated = true; + } return { - status: "unconfigured", - realRoot: "", - operatorName: "", - country: "", - version: "", - content: { ...EMPTY_CONTENT }, - paths: {}, - errorMessage: "" + ok: true, + kind: "file", + ...target, + totalLines: lines.length, + startLine: start, + endLine: start - 1 + slice.length, + truncated, + text }; } +var CACHE_KEY = Symbol.for("dsh-netxops.kb-search-cache"); +function searchCache() { + const root = globalThis; + root[CACHE_KEY] ??= { files: new Map, chars: 0 }; + return root[CACHE_KEY]; +} +function cachedText(path, mtimeMs, size) { + const cache = searchCache(); + const hit = cache.files.get(path); + if (hit && hit.mtimeMs === mtimeMs && hit.size === size) + return hit; + const text = readFileSync4(path, "utf8").replace(/^\uFEFF/, ""); + if (hit) + cache.chars -= hit.text.length; + if (cache.chars + text.length > CACHE_MAX_CHARS) { + cache.files.clear(); + cache.chars = 0; + } + const entry = { mtimeMs, size, text, lower: text.toLowerCase() }; + cache.files.set(path, entry); + cache.chars += text.length; + return entry; +} +function walkTextFiles(dir, out) { + let entries; + try { + entries = readdirSync4(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) + continue; + const full = join6(dir, entry.name); + if (entry.isDirectory()) { + walkTextFiles(full, out); + continue; + } + if (!entry.isFile() || !TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) + continue; + try { + const st = statSync5(full); + if (st.size <= SEARCH_MAX_FILE_BYTES) + out.push({ path: full, mtimeMs: st.mtimeMs, size: st.size }); + } catch {} + } +} +function searchKb(snapshot, args) { + const root = requireRoot(snapshot); + const query = args.query.trim(); + if (!query) + throw new Error("query is empty"); + const scopeArg = args.scope?.trim() || "all"; + const scopes = scopeArg.split(/[,\s]+/).filter(Boolean); + const dirs = scopes.includes("all") ? [root] : scopes.map((scope) => resolveKbReadPath(snapshot, scope).absolutePath); + let matchers; + if (args.regex) { + let re; + try { + re = new RegExp(query, "i"); + } catch (error) { + throw new Error(`invalid regex: ${error instanceof Error ? error.message : String(error)}`); + } + matchers = [{ term: query, test: (_lower, raw) => re.test(raw) }]; + } else { + const terms = [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))]; + matchers = terms.map((term) => ({ term, test: (lower) => lower.includes(term) })); + } + const files = []; + const seen = new Set; + for (const dir of dirs) { + const st = statSync5(dir); + const batch = []; + if (st.isFile()) + batch.push({ path: dir, mtimeMs: st.mtimeMs, size: st.size }); + else + walkTextFiles(dir, batch); + for (const file of batch) { + if (seen.has(file.path)) + continue; + seen.add(file.path); + files.push(file); + } + } + const hits = []; + for (const file of files) { + let cached; + try { + cached = cachedText(file.path, file.mtimeMs, file.size); + } catch { + continue; + } + const rel = toPosix(relative2(root, file.path)); + const matched = new Set; + let score = 0; + for (const m of matchers) { + if (m.test(rel.toLowerCase(), rel)) { + matched.add(m.term); + score += 3; + } + } + const inBody = matchers.filter((m) => m.test(cached.lower, cached.text)); + if (inBody.length === 0 && matched.size === 0) + continue; + const lines = []; + const rows = inBody.length > 0 ? cached.text.split(/\r\n?|\n/) : []; + let lineHits = 0; + for (let i = 0;i < rows.length; i += 1) { + const row = rows[i]; + const lower = row.toLowerCase(); + let lineHit = false; + for (const m of inBody) { + if (m.test(lower, row)) { + matched.add(m.term); + lineHit = true; + } + } + if (!lineHit) + continue; + lineHits += 1; + if (lines.length < SEARCH_HITS_PER_FILE) { + const trimmed = row.trim(); + lines.push({ line: i + 1, text: trimmed.length > 240 ? `${trimmed.slice(0, 240)}…` : trimmed }); + } + } + score += matched.size * 10 + Math.min(lineHits, 30) * 0.1; + hits.push({ + relativePath: rel, + absolutePath: file.path, + score: Math.round(score * 10) / 10, + matchedTerms: [...matched], + lines + }); + } + hits.sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath)); + const limit = Math.min(Math.max(Math.floor(args.limit ?? 20), 1), 100); + return { ok: true, scope: scopeArg, scannedFiles: files.length, totalMatches: hits.length, hits: hits.slice(0, limit) }; +} // src/netx/kb-runtime.ts var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); @@ -862,7 +1174,7 @@ function omitUndefined(record) { // src/netx/tool-mount-status.ts import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs"; import { homedir } from "node:os"; -import { dirname as dirname3, join as join4 } from "node:path"; +import { dirname as dirname4, join as join7 } from "node:path"; var KEY = Symbol.for("dsh-netxops.mount-inventory"); function store2() { const root = globalThis; @@ -886,8 +1198,8 @@ function getToolMountStatus() { } function persist() { try { - const path = join4(process.env.DSH_HOME?.trim() || join4(homedir(), ".dsh"), "netxops-mount-status.json"); - mkdirSync2(dirname3(path), { recursive: true }); + const path = join7(process.env.DSH_HOME?.trim() || join7(homedir(), ".dsh"), "netxops-mount-status.json"); + mkdirSync2(dirname4(path), { recursive: true }); writeFileSync2(path, `${JSON.stringify({ mount: getToolMountStatus() ?? null, mounts: getToolMountStatuses() }, null, 2)} `, "utf8"); } catch {} @@ -937,14 +1249,8 @@ function tool(name, description, parameters, execute) { } }); } -function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { - if (!kbLocalToolsEnabled(snapshot)) - return () => {}; - const localRoot = resolveKbLocalRoot(snapshot); - const toolsApi = ctx.tools; - if (!toolsApi || typeof toolsApi.register !== "function") - return () => {}; - const entries = [ +function writeTools(localRoot) { + return [ tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", { bucket: { type: "string", @@ -1024,7 +1330,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents with netx__kbRead using the listed paths.", { root: { type: "string", enum: ["memories", "drafts", "suggestions", "refs", "all"], @@ -1036,6 +1342,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { limit: typeof args.limit === "number" ? args.limit : undefined })) ]; +} +function readTools(snapshot) { + return [ + tool("netx__kbSearch", `Search the operator knowledge base at ${snapshot.realRoot} (read-only HQ pack + _local). ` + "Plain mode: space-separated keywords (NE host_name, alarm, protocol, symptom…), case-insensitive; " + "files ranked by how many distinct terms match (filename hits weigh more), up to 4 hit lines each. " + "Then open the best hits with netx__kbRead.", { + query: reqStr("Keywords (space-separated) or a regex when regex=true"), + scope: str("MANIFEST paths key(s) — e.g. rca, theory, theoryVendor, packet, commonPatterns, local, neIndex — or a relative dir; comma-separate several; default all"), + regex: bool("Treat query as one case-insensitive JS regex"), + limit: num("Max files 1–100 (default 20)") + }, (args) => searchKb(liveSnapshot(), { + query: String(args.query ?? ""), + scope: args.scope != null ? String(args.scope) : undefined, + regex: args.regex === true, + limit: typeof args.limit === "number" ? args.limit : undefined + })), + tool("netx__kbRead", `Read one file (or list one directory) inside the knowledge base at ${snapshot.realRoot}. ` + "path: relative to kbRoot, absolute inside it, or a MANIFEST paths key (rca, neIndex, cmdLib, patternMap, …). " + "Long files: page with offset/limit (1-based lines; default 400).", { + path: reqStr("KB-relative path, absolute path inside kbRoot, or a paths.* key"), + offset: num("First line (1-based, default 1)"), + limit: num("Max lines 1–2000 (default 400)") + }, (args) => readKbFile(liveSnapshot(), { + path: String(args.path ?? ""), + offset: typeof args.offset === "number" ? args.offset : undefined, + limit: typeof args.limit === "number" ? args.limit : undefined + })) + ]; +} +function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { + if (snapshot.status !== "configured") + return () => {}; + const localRoot = resolveKbLocalRoot(snapshot); + const toolsApi = ctx.tools; + if (!toolsApi || typeof toolsApi.register !== "function") + return () => {}; + const entries = [...readTools(snapshot), ...localRoot ? writeTools(localRoot) : []]; const disposers = []; const source = options.source ?? "netxops-kb-local"; const id = getToolMountId(ctx, source); @@ -1057,7 +1396,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { publishToolMountStatus({ ...status, names: [], count: 0, error: String(error) }); throw error; } - ctx.logger.info("netxops: kb local tools registered count=%s localRoot=%s", disposers.length, localRoot); + ctx.logger.info("netxops: kb tools registered count=%s kbRoot=%s localRoot=%s", disposers.length, snapshot.realRoot, localRoot ?? "(none — read-only)"); return () => { for (const dispose of disposers) dispose(); @@ -1067,19 +1406,35 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { // src/netx/kb-pack-skills.ts import { readdir as readdir2, stat as stat2 } from "node:fs/promises"; -import { join as join5 } from "node:path"; +import { join as join8 } from "node:path"; function kbPackSkillsEnabled(snapshot) { return snapshot.status === "configured" && snapshot.content.hasSkills === true; } function resolveKbSkillsRoot(snapshot) { const rel = snapshot.paths.skills?.trim() || "_skills"; - return join5(snapshot.realRoot, rel); + return join8(snapshot.realRoot, rel); +} +function kbPackSkillFooter(snapshot) { + return [ + "", + "", + `## Injected KB anchors (netxops · ${snapshot.operatorName} / ${snapshot.country} v${snapshot.version})`, + "", + `kbStatus=configured · kbRoot=\`${snapshot.realRoot}\``, + "", + renderKbPathTable(snapshot), + "", + "These are already resolved from `MANIFEST.json` `paths.*`; use them verbatim.", + "Read/search the pack with `netx__kbRead` / `netx__kbSearch` when present; write only via `netx__kbWrite*`.", + "" + ].join(` +`); } function resolveKbLocalSkillsRoot(snapshot) { const rel = snapshot.paths.localSkills?.trim(); if (!rel) return null; - return join5(snapshot.realRoot, rel); + return join8(snapshot.realRoot, rel); } async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; @@ -1098,7 +1453,7 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) for (const entry of entries) { if (options.signal?.aborted) break; - const full = join5(root, entry); + const full = join8(root, entry); let isDir = false; try { isDir = (await stat2(full)).isDirectory(); @@ -1120,15 +1475,24 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) } if (options.signal?.aborted) break; - disposers.push(skillsApi.register({ - name: skill.name, - description: skill.description, - content: skill.content, - path: skill.path, - resourceBase: { kind: "directory", path: skill.directory }, - provider, - source: "custom" - })); + if (options.seen.has(skill.name)) { + ctx.logger.warn("netxops: kb %s skill %s duplicates an already registered pack skill — skip %s", options.label, skill.name, full); + continue; + } + try { + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: `${skill.content.trimEnd()}${options.footer}`, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider, + source: "custom" + })); + options.seen.add(skill.name); + } catch (error) { + ctx.logger.warn("netxops: kb %s skill %s register failed: %s", options.label, skill.name, error instanceof Error ? error.message : String(error)); + } } } catch (error) { for (const dispose of disposers.splice(0)) @@ -1149,17 +1513,23 @@ async function registerKbPackSkills(ctx, snapshot, options) { const disposers = []; const skillsRoot = resolveKbSkillsRoot(snapshot); const localRoot = resolveKbLocalSkillsRoot(snapshot); + const footer = kbPackSkillFooter(snapshot); + const seen = new Set; try { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { logMissing: true, label: "pack", - signal: options.signal + signal: options.signal, + footer, + seen })); if (localRoot) { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, { logMissing: false, label: "local", - signal: options.signal + signal: options.signal, + footer, + seen })); } } catch (error) { @@ -1361,8 +1731,8 @@ function watchNetxConnection(listener) { } // src/netx/tools.ts -import { readFileSync as readFileSync2 } from "node:fs"; -import { dirname as dirname4, join as join6 } from "node:path"; +import { readFileSync as readFileSync5 } from "node:fs"; +import { dirname as dirname5, join as join9 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; @@ -2804,8 +3174,8 @@ async function layoutTopologyView(client, args, signal) { // src/netx/tools.ts function packageVersion() { try { - const here = dirname4(fileURLToPath2(import.meta.url)); - const pkg = JSON.parse(readFileSync2(join6(here, "..", "package.json"), "utf8")); + const here = dirname5(fileURLToPath2(import.meta.url)); + const pkg = JSON.parse(readFileSync5(join9(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; @@ -3276,8 +3646,8 @@ function registerNetxTools(ctx, connection, options) { // src/netx/group-tools-plugin.ts function packageVersion2() { try { - const here = dirname5(fileURLToPath3(import.meta.url)); - const pkg = JSON.parse(readFileSync3(join7(here, "..", "package.json"), "utf8")); + const here = dirname6(fileURLToPath3(import.meta.url)); + const pkg = JSON.parse(readFileSync6(join10(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index e9a1452..bbdb2d9 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -1,6 +1,6 @@ // src/netx/group-tools-plugin.ts -import { readFileSync as readFileSync3 } from "node:fs"; -import { dirname as dirname5, join as join7 } from "node:path"; +import { readFileSync as readFileSync6 } from "node:fs"; +import { dirname as dirname6, join as join10 } from "node:path"; import { fileURLToPath as fileURLToPath3 } from "node:url"; // src/netx/config-values.ts @@ -324,9 +324,56 @@ Use only tools present in this session. Native layout supports catalog and move_ }; } +// src/netx/kb-context-skill.ts +import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync3 } from "node:fs"; +import { join as join4 } from "node:path"; + +// src/netx/kb-manifest.ts +import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "node:fs"; +import { basename, dirname as dirname2, join as join2, resolve } from "node:path"; +var EMPTY_CONTENT = { + hasRegions: false, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false +}; +function unconfiguredKbSnapshot() { + return { + status: "unconfigured", + realRoot: "", + operatorName: "", + country: "", + version: "", + content: { ...EMPTY_CONTENT }, + paths: {}, + errorMessage: "" + }; +} +function resolveKbPaths(snapshot) { + if (snapshot.status !== "configured") + return []; + const paths = { skills: "_skills", ...snapshot.paths }; + return Object.entries(paths).filter((entry) => typeof entry[1] === "string" && entry[1].trim() !== "").map(([key, rel]) => { + const absolute = resolve(snapshot.realRoot, rel); + return { key, relative: rel, absolute, exists: existsSync2(absolute) }; + }); +} +function renderKbPathTable(snapshot) { + const rows = resolveKbPaths(snapshot); + if (rows.length === 0) + return "(MANIFEST has no paths — legacy package; ask HQ for a repack)"; + return [ + "| paths.* | Absolute path |", + "| --- | --- |", + ...rows.map((row) => `| ${row.key} | \`${row.absolute}\`${row.exists ? "" : " (missing)"} |`) + ].join(` +`); +} + // src/netx/kb-local-path.ts -import { existsSync as existsSync2, realpathSync, statSync } from "node:fs"; -import { join as join2, relative, resolve, sep } from "node:path"; +import { existsSync as existsSync3, realpathSync, statSync as statSync2 } from "node:fs"; +import { join as join3, relative, resolve as resolve2, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", @@ -352,10 +399,7 @@ function resolveKbLocalRoot(snapshot) { const rel = snapshot.paths.local?.trim(); if (!rel) return null; - return resolve(snapshot.realRoot, rel); -} -function kbLocalToolsEnabled(snapshot) { - return resolveKbLocalRoot(snapshot) !== null; + return resolve2(snapshot.realRoot, rel); } function sanitizeSlug(raw, fallback = "entry") { const cleaned = raw.trim().replace(/[/\\]+/g, "-").replace(/\.\.+/g, "").replace(/[^\w.\u4e00-\u9fff-]+/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); @@ -407,7 +451,7 @@ function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); } - const absolutePath = resolve(localRoot, ...segments); + const absolutePath = resolve2(localRoot, ...segments); const rel = relative(localRoot, absolutePath); if (!rel || rel.startsWith("..") || rel.split(/[/\\]/).includes("..")) { throw new Error(`path escapes local root: ${absolutePath}`); @@ -426,18 +470,18 @@ function resolveExistingWritableFile(localRoot, pathArg) { const trimmed = pathArg.trim(); if (!trimmed) throw new Error("path is empty"); - const candidate = resolve(trimmed); + const candidate = resolve2(trimmed); let absolutePath; - const localResolved = resolve(localRoot); + const localResolved = resolve2(localRoot); const underLocal = candidate === localResolved || candidate.startsWith(localResolved + sep); if (underLocal) { absolutePath = candidate; } else { - absolutePath = resolve(localRoot, trimmed); + absolutePath = resolve2(localRoot, trimmed); } const checked = resolveWritableLocalPath(localRoot, ...relative(localRoot, absolutePath).split(/[/\\]/).filter(Boolean)); try { - if (existsSync2(checked.absolutePath)) { + if (existsSync3(checked.absolutePath)) { const realFile = realpathSync(checked.absolutePath); const realLocal = realpathSync(localRoot); const realRel = relative(realLocal, realFile); @@ -448,7 +492,7 @@ function resolveExistingWritableFile(localRoot, pathArg) { if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) { throw new Error(`path not under writable roots: ${realRel}`); } - if (!statSync(realFile).isFile()) { + if (!statSync2(realFile).isFile()) { throw new Error(`not a file: ${realFile}`); } return { @@ -465,16 +509,16 @@ function resolveExistingWritableFile(localRoot, pathArg) { return checked; } function memoryDir(bucket) { - return join2("memories", MEMORY_BUCKET_DIR[bucket]); + return join3("memories", MEMORY_BUCKET_DIR[bucket]); } function suggestionDir(kind) { - return join2("suggestions", kind); + return join3("suggestions", kind); } function draftDir(domain) { const d = domain?.trim(); if (!d) return "drafts"; - return join2("drafts", sanitizeSlug(d, "misc")); + return join3("drafts", sanitizeSlug(d, "misc")); } function refDir(area, device) { if (!REF_AREAS.includes(area)) { @@ -485,18 +529,63 @@ function refDir(area, device) { if (!host) { throw new Error("refs/devices requires device (host_name)"); } - return join2("refs", "devices", host); + return join3("refs", "devices", host); } - return join2("refs", area); + return join3("refs", area); } // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; +var IDENTITY_FILE_MAX = 8000; +var IDENTITY_TOTAL_MAX = 24000; +function readKbIdentity(snapshot) { + const localRoot = resolveKbLocalRoot(snapshot); + if (!localRoot) + return []; + const dir = join4(localRoot, "identity"); + let names; + try { + names = readdirSync2(dir).filter((name) => /\.md$/i.test(name) && !/^readme\.md$/i.test(name)).sort(); + } catch { + return []; + } + const out = []; + let budget = IDENTITY_TOTAL_MAX; + for (const name of names) { + if (budget <= 0) + break; + const full = join4(dir, name); + let text; + try { + if (!statSync3(full).isFile()) + continue; + text = readFileSync2(full, "utf8").replace(/^\uFEFF/, "").trim(); + } catch { + continue; + } + if (!text) + continue; + const cap = Math.min(IDENTITY_FILE_MAX, budget); + if (text.length > cap) + text = `${text.slice(0, cap)} + +…(truncated — read \`${full}\` for the rest)`; + budget -= text.length; + out.push({ name, text }); + } + return out; +} function skillBody(snapshot) { if (snapshot.status === "configured") { const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)"; const localRoot = resolveKbLocalRoot(snapshot); const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; + const identity = readKbIdentity(snapshot); + const identityBlock = identity.length > 0 ? [ + "", + "### Site identity (`identity/`, authoritative — follow over generic advice)", + ...identity.flatMap(({ name, text }) => ["", `#### ${name}`, "", text]) + ] : []; const localGuide = localRoot ? [ "", "### Dual plane (mandatory)", @@ -521,7 +610,7 @@ function skillBody(snapshot) { "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", + description: `Load FIRST before any kb-* skill or KB file access: resolved absolute paths (rca / theory / packet / neIndex / cmdLib / local …) for operator ${snapshot.operatorName} (${snapshot.country}) KB v${snapshot.version}, site identity rules, and KB write rules.`, content: [ "## Knowledge base (configured)", "", @@ -537,13 +626,22 @@ function skillBody(snapshot) { `| kbCountry | ${snapshot.country} |`, `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, + "", + "### Path anchors (resolved from MANIFEST `paths.*` — use verbatim, never compose `regions///…`)", + "", + renderKbPathTable(snapshot), + ...identityBlock, ...localGuide, "", - "Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "### Reading the HQ pack", + "- `netx__kbSearch` — keyword/regex search across rca / theory / packet / common / local (returns file + line hits).", + "- `netx__kbRead` — read one KB file (path relative to kbRoot or absolute inside it; optional line window).", + "- Start from the indexes: `neIndex` (NE → cases), `patternMap`, `classification`, then open the cited RCA.", "", - "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from", - `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, - "plus paths.localSkills when present — use those skills for KB triage;", + "Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_PATHS (JSON of the anchors above), KB_STATUS.", + "", + "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from paths.skills", + "when hasSkills is true, plus paths.localSkills when present — use those skills for KB triage;", "keep netx-ops for live netx evidence only." ].join(` `) @@ -584,15 +682,15 @@ import { defineTool } from "@deepseek-ai/dsh-tools"; // src/netx/kb-local-ops.ts import { - existsSync as existsSync3, + existsSync as existsSync4, mkdirSync, - readdirSync, - readFileSync, + readdirSync as readdirSync3, + readFileSync as readFileSync3, rmSync, - statSync as statSync2, + statSync as statSync4, writeFileSync } from "node:fs"; -import { dirname as dirname2, join as join3 } from "node:path"; +import { dirname as dirname3, join as join5 } from "node:path"; function requireLocalRoot(snapshot) { const root = resolveKbLocalRoot(snapshot); if (!root) { @@ -638,11 +736,11 @@ function createMemory(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = memoryFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, memoryDir(args.bucket), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -657,11 +755,11 @@ function createDraft(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = draftFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, draftDir(args.domain), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, ensureDraftBody(args.body), "utf8"); return { ok: true, @@ -674,11 +772,11 @@ function createSuggestion(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = suggestionFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, suggestionDir(args.kind), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -693,11 +791,11 @@ function createRef(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = refFileName(args.slug); const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -711,7 +809,7 @@ function createRef(snapshot, args) { function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); - if (!existsSync3(target.absolutePath)) { + if (!existsSync4(target.absolutePath)) { throw new Error(`file not found: ${target.relativePath}`); } const body = target.writableRoot === "drafts" ? ensureDraftBody(args.body) : args.body.endsWith(` @@ -728,7 +826,7 @@ function updateLocalFile(snapshot, args) { function deleteLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); - if (!existsSync3(target.absolutePath)) { + if (!existsSync4(target.absolutePath)) { throw new Error(`file not found: ${target.relativePath}`); } rmSync(target.absolutePath, { force: false }); @@ -742,17 +840,17 @@ function deleteLocalFile(snapshot, args) { function walkFiles(dir, baseLocal, out) { let entries; try { - entries = readdirSync(dir); + entries = readdirSync3(dir); } catch { return; } for (const name of entries) { if (name === "." || name === "..") continue; - const full = join3(dir, name); + const full = join5(dir, name); let st; try { - st = statSync2(full); + st = statSync4(full); } catch { continue; } @@ -774,7 +872,7 @@ function listLocalFiles(snapshot, args = {}) { const roots = args.root && args.root !== "all" ? [args.root] : [...KB_LOCAL_WRITABLE_ROOTS]; const entries = []; for (const r of roots) { - walkFiles(join3(localRoot, r), localRoot, entries); + walkFiles(join5(localRoot, r), localRoot, entries); } entries.sort((a, b) => b.mtimeMs - a.mtimeMs); const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); @@ -785,26 +883,240 @@ function listLocalFiles(snapshot, args = {}) { }; } -// src/netx/kb-manifest.ts -var EMPTY_CONTENT = { - hasRegions: false, - hasTheory: false, - hasPacket: false, - hasCommon: false, - hasSkills: false -}; -function unconfiguredKbSnapshot() { +// src/netx/kb-read-ops.ts +import { readdirSync as readdirSync4, readFileSync as readFileSync4, realpathSync as realpathSync2, statSync as statSync5 } from "node:fs"; +import { extname, join as join6, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path"; +var TEXT_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".json", ".yml", ".yaml", ".csv", ".ps1", ".sh", ".cfg", ".conf", ".log"]); +var SKIP_DIRS = new Set([".git", "node_modules"]); +var READ_DEFAULT_LINES = 400; +var READ_MAX_LINES = 2000; +var READ_MAX_CHARS = 80000; +var SEARCH_MAX_FILE_BYTES = 2 * 1024 * 1024; +var SEARCH_HITS_PER_FILE = 4; +var CACHE_MAX_CHARS = 64 * 1024 * 1024; +function requireRoot(snapshot) { + if (snapshot.status !== "configured" || !snapshot.realRoot) { + throw new Error("knowledge base not configured (set kbRoot to a folder with MANIFEST.json)"); + } + return snapshot.realRoot; +} +function toPosix(rel) { + return rel.split(sep2).join("/"); +} +function isInside(root, target) { + const rel = relative2(root, target); + return rel === "" || !rel.startsWith("..") && !rel.split(/[/\\]/).includes("..") && !/^[a-zA-Z]:/.test(rel); +} +function resolveKbReadPath(snapshot, pathArg) { + const root = requireRoot(snapshot); + const trimmed = pathArg.trim().replace(/^["']|["']$/g, ""); + if (trimmed.includes("\x00")) + throw new Error("path contains NUL"); + const anchor = resolveKbPaths(snapshot).find((row) => row.key === trimmed); + const candidate = anchor ? anchor.absolute : !trimmed || trimmed === "." || trimmed === "/" ? root : resolve3(root, trimmed); + if (!isInside(root, candidate)) + throw new Error(`path escapes kbRoot: ${candidate}`); + let real; + try { + real = realpathSync2(candidate); + } catch { + throw new Error(`not found in KB: ${toPosix(relative2(root, candidate)) || "."}`); + } + const realRoot = realpathSync2(root); + if (!isInside(realRoot, real)) + throw new Error(`path escapes kbRoot after realpath: ${real}`); + return { absolutePath: real, relativePath: toPosix(relative2(realRoot, real)) || "." }; +} +function readKbFile(snapshot, args) { + const target = resolveKbReadPath(snapshot, args.path); + const st = statSync5(target.absolutePath); + if (st.isDirectory()) { + const entries = readdirSync4(target.absolutePath, { withFileTypes: true }).filter((entry) => !SKIP_DIRS.has(entry.name)).map((entry) => { + if (entry.isDirectory()) + return { name: entry.name, type: "dir" }; + let size; + try { + size = statSync5(join6(target.absolutePath, entry.name)).size; + } catch {} + return { name: entry.name, type: "file", size }; + }).sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1); + return { ok: true, kind: "directory", ...target, entries }; + } + if (!TEXT_EXTENSIONS.has(extname(target.absolutePath).toLowerCase())) { + throw new Error(`not a text KB file: ${target.relativePath}`); + } + const lines = readFileSync4(target.absolutePath, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, ` +`).split(` +`); + const start = Math.max(1, Math.floor(args.offset ?? 1)); + const limit = Math.min(Math.max(1, Math.floor(args.limit ?? READ_DEFAULT_LINES)), READ_MAX_LINES); + let slice = lines.slice(start - 1, start - 1 + limit); + let text = slice.join(` +`); + let truncated = start - 1 + slice.length < lines.length; + if (text.length > READ_MAX_CHARS) { + let used = 0; + let keep = 0; + for (const line of slice) { + if (used + line.length + 1 > READ_MAX_CHARS) + break; + used += line.length + 1; + keep += 1; + } + slice = slice.slice(0, Math.max(keep, 1)); + text = slice.join(` +`).slice(0, READ_MAX_CHARS); + truncated = true; + } return { - status: "unconfigured", - realRoot: "", - operatorName: "", - country: "", - version: "", - content: { ...EMPTY_CONTENT }, - paths: {}, - errorMessage: "" + ok: true, + kind: "file", + ...target, + totalLines: lines.length, + startLine: start, + endLine: start - 1 + slice.length, + truncated, + text }; } +var CACHE_KEY = Symbol.for("dsh-netxops.kb-search-cache"); +function searchCache() { + const root = globalThis; + root[CACHE_KEY] ??= { files: new Map, chars: 0 }; + return root[CACHE_KEY]; +} +function cachedText(path, mtimeMs, size) { + const cache = searchCache(); + const hit = cache.files.get(path); + if (hit && hit.mtimeMs === mtimeMs && hit.size === size) + return hit; + const text = readFileSync4(path, "utf8").replace(/^\uFEFF/, ""); + if (hit) + cache.chars -= hit.text.length; + if (cache.chars + text.length > CACHE_MAX_CHARS) { + cache.files.clear(); + cache.chars = 0; + } + const entry = { mtimeMs, size, text, lower: text.toLowerCase() }; + cache.files.set(path, entry); + cache.chars += text.length; + return entry; +} +function walkTextFiles(dir, out) { + let entries; + try { + entries = readdirSync4(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) + continue; + const full = join6(dir, entry.name); + if (entry.isDirectory()) { + walkTextFiles(full, out); + continue; + } + if (!entry.isFile() || !TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) + continue; + try { + const st = statSync5(full); + if (st.size <= SEARCH_MAX_FILE_BYTES) + out.push({ path: full, mtimeMs: st.mtimeMs, size: st.size }); + } catch {} + } +} +function searchKb(snapshot, args) { + const root = requireRoot(snapshot); + const query = args.query.trim(); + if (!query) + throw new Error("query is empty"); + const scopeArg = args.scope?.trim() || "all"; + const scopes = scopeArg.split(/[,\s]+/).filter(Boolean); + const dirs = scopes.includes("all") ? [root] : scopes.map((scope) => resolveKbReadPath(snapshot, scope).absolutePath); + let matchers; + if (args.regex) { + let re; + try { + re = new RegExp(query, "i"); + } catch (error) { + throw new Error(`invalid regex: ${error instanceof Error ? error.message : String(error)}`); + } + matchers = [{ term: query, test: (_lower, raw) => re.test(raw) }]; + } else { + const terms = [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))]; + matchers = terms.map((term) => ({ term, test: (lower) => lower.includes(term) })); + } + const files = []; + const seen = new Set; + for (const dir of dirs) { + const st = statSync5(dir); + const batch = []; + if (st.isFile()) + batch.push({ path: dir, mtimeMs: st.mtimeMs, size: st.size }); + else + walkTextFiles(dir, batch); + for (const file of batch) { + if (seen.has(file.path)) + continue; + seen.add(file.path); + files.push(file); + } + } + const hits = []; + for (const file of files) { + let cached; + try { + cached = cachedText(file.path, file.mtimeMs, file.size); + } catch { + continue; + } + const rel = toPosix(relative2(root, file.path)); + const matched = new Set; + let score = 0; + for (const m of matchers) { + if (m.test(rel.toLowerCase(), rel)) { + matched.add(m.term); + score += 3; + } + } + const inBody = matchers.filter((m) => m.test(cached.lower, cached.text)); + if (inBody.length === 0 && matched.size === 0) + continue; + const lines = []; + const rows = inBody.length > 0 ? cached.text.split(/\r\n?|\n/) : []; + let lineHits = 0; + for (let i = 0;i < rows.length; i += 1) { + const row = rows[i]; + const lower = row.toLowerCase(); + let lineHit = false; + for (const m of inBody) { + if (m.test(lower, row)) { + matched.add(m.term); + lineHit = true; + } + } + if (!lineHit) + continue; + lineHits += 1; + if (lines.length < SEARCH_HITS_PER_FILE) { + const trimmed = row.trim(); + lines.push({ line: i + 1, text: trimmed.length > 240 ? `${trimmed.slice(0, 240)}…` : trimmed }); + } + } + score += matched.size * 10 + Math.min(lineHits, 30) * 0.1; + hits.push({ + relativePath: rel, + absolutePath: file.path, + score: Math.round(score * 10) / 10, + matchedTerms: [...matched], + lines + }); + } + hits.sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath)); + const limit = Math.min(Math.max(Math.floor(args.limit ?? 20), 1), 100); + return { ok: true, scope: scopeArg, scannedFiles: files.length, totalMatches: hits.length, hits: hits.slice(0, limit) }; +} // src/netx/kb-runtime.ts var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); @@ -862,7 +1174,7 @@ function omitUndefined(record) { // src/netx/tool-mount-status.ts import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs"; import { homedir } from "node:os"; -import { dirname as dirname3, join as join4 } from "node:path"; +import { dirname as dirname4, join as join7 } from "node:path"; var KEY = Symbol.for("dsh-netxops.mount-inventory"); function store2() { const root = globalThis; @@ -886,8 +1198,8 @@ function getToolMountStatus() { } function persist() { try { - const path = join4(process.env.DSH_HOME?.trim() || join4(homedir(), ".dsh"), "netxops-mount-status.json"); - mkdirSync2(dirname3(path), { recursive: true }); + const path = join7(process.env.DSH_HOME?.trim() || join7(homedir(), ".dsh"), "netxops-mount-status.json"); + mkdirSync2(dirname4(path), { recursive: true }); writeFileSync2(path, `${JSON.stringify({ mount: getToolMountStatus() ?? null, mounts: getToolMountStatuses() }, null, 2)} `, "utf8"); } catch {} @@ -937,14 +1249,8 @@ function tool(name, description, parameters, execute) { } }); } -function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { - if (!kbLocalToolsEnabled(snapshot)) - return () => {}; - const localRoot = resolveKbLocalRoot(snapshot); - const toolsApi = ctx.tools; - if (!toolsApi || typeof toolsApi.register !== "function") - return () => {}; - const entries = [ +function writeTools(localRoot) { + return [ tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", { bucket: { type: "string", @@ -1024,7 +1330,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents with netx__kbRead using the listed paths.", { root: { type: "string", enum: ["memories", "drafts", "suggestions", "refs", "all"], @@ -1036,6 +1342,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { limit: typeof args.limit === "number" ? args.limit : undefined })) ]; +} +function readTools(snapshot) { + return [ + tool("netx__kbSearch", `Search the operator knowledge base at ${snapshot.realRoot} (read-only HQ pack + _local). ` + "Plain mode: space-separated keywords (NE host_name, alarm, protocol, symptom…), case-insensitive; " + "files ranked by how many distinct terms match (filename hits weigh more), up to 4 hit lines each. " + "Then open the best hits with netx__kbRead.", { + query: reqStr("Keywords (space-separated) or a regex when regex=true"), + scope: str("MANIFEST paths key(s) — e.g. rca, theory, theoryVendor, packet, commonPatterns, local, neIndex — or a relative dir; comma-separate several; default all"), + regex: bool("Treat query as one case-insensitive JS regex"), + limit: num("Max files 1–100 (default 20)") + }, (args) => searchKb(liveSnapshot(), { + query: String(args.query ?? ""), + scope: args.scope != null ? String(args.scope) : undefined, + regex: args.regex === true, + limit: typeof args.limit === "number" ? args.limit : undefined + })), + tool("netx__kbRead", `Read one file (or list one directory) inside the knowledge base at ${snapshot.realRoot}. ` + "path: relative to kbRoot, absolute inside it, or a MANIFEST paths key (rca, neIndex, cmdLib, patternMap, …). " + "Long files: page with offset/limit (1-based lines; default 400).", { + path: reqStr("KB-relative path, absolute path inside kbRoot, or a paths.* key"), + offset: num("First line (1-based, default 1)"), + limit: num("Max lines 1–2000 (default 400)") + }, (args) => readKbFile(liveSnapshot(), { + path: String(args.path ?? ""), + offset: typeof args.offset === "number" ? args.offset : undefined, + limit: typeof args.limit === "number" ? args.limit : undefined + })) + ]; +} +function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { + if (snapshot.status !== "configured") + return () => {}; + const localRoot = resolveKbLocalRoot(snapshot); + const toolsApi = ctx.tools; + if (!toolsApi || typeof toolsApi.register !== "function") + return () => {}; + const entries = [...readTools(snapshot), ...localRoot ? writeTools(localRoot) : []]; const disposers = []; const source = options.source ?? "netxops-kb-local"; const id = getToolMountId(ctx, source); @@ -1057,7 +1396,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { publishToolMountStatus({ ...status, names: [], count: 0, error: String(error) }); throw error; } - ctx.logger.info("netxops: kb local tools registered count=%s localRoot=%s", disposers.length, localRoot); + ctx.logger.info("netxops: kb tools registered count=%s kbRoot=%s localRoot=%s", disposers.length, snapshot.realRoot, localRoot ?? "(none — read-only)"); return () => { for (const dispose of disposers) dispose(); @@ -1067,19 +1406,35 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { // src/netx/kb-pack-skills.ts import { readdir as readdir2, stat as stat2 } from "node:fs/promises"; -import { join as join5 } from "node:path"; +import { join as join8 } from "node:path"; function kbPackSkillsEnabled(snapshot) { return snapshot.status === "configured" && snapshot.content.hasSkills === true; } function resolveKbSkillsRoot(snapshot) { const rel = snapshot.paths.skills?.trim() || "_skills"; - return join5(snapshot.realRoot, rel); + return join8(snapshot.realRoot, rel); +} +function kbPackSkillFooter(snapshot) { + return [ + "", + "", + `## Injected KB anchors (netxops · ${snapshot.operatorName} / ${snapshot.country} v${snapshot.version})`, + "", + `kbStatus=configured · kbRoot=\`${snapshot.realRoot}\``, + "", + renderKbPathTable(snapshot), + "", + "These are already resolved from `MANIFEST.json` `paths.*`; use them verbatim.", + "Read/search the pack with `netx__kbRead` / `netx__kbSearch` when present; write only via `netx__kbWrite*`.", + "" + ].join(` +`); } function resolveKbLocalSkillsRoot(snapshot) { const rel = snapshot.paths.localSkills?.trim(); if (!rel) return null; - return join5(snapshot.realRoot, rel); + return join8(snapshot.realRoot, rel); } async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; @@ -1098,7 +1453,7 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) for (const entry of entries) { if (options.signal?.aborted) break; - const full = join5(root, entry); + const full = join8(root, entry); let isDir = false; try { isDir = (await stat2(full)).isDirectory(); @@ -1120,15 +1475,24 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) } if (options.signal?.aborted) break; - disposers.push(skillsApi.register({ - name: skill.name, - description: skill.description, - content: skill.content, - path: skill.path, - resourceBase: { kind: "directory", path: skill.directory }, - provider, - source: "custom" - })); + if (options.seen.has(skill.name)) { + ctx.logger.warn("netxops: kb %s skill %s duplicates an already registered pack skill — skip %s", options.label, skill.name, full); + continue; + } + try { + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: `${skill.content.trimEnd()}${options.footer}`, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider, + source: "custom" + })); + options.seen.add(skill.name); + } catch (error) { + ctx.logger.warn("netxops: kb %s skill %s register failed: %s", options.label, skill.name, error instanceof Error ? error.message : String(error)); + } } } catch (error) { for (const dispose of disposers.splice(0)) @@ -1149,17 +1513,23 @@ async function registerKbPackSkills(ctx, snapshot, options) { const disposers = []; const skillsRoot = resolveKbSkillsRoot(snapshot); const localRoot = resolveKbLocalSkillsRoot(snapshot); + const footer = kbPackSkillFooter(snapshot); + const seen = new Set; try { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { logMissing: true, label: "pack", - signal: options.signal + signal: options.signal, + footer, + seen })); if (localRoot) { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, { logMissing: false, label: "local", - signal: options.signal + signal: options.signal, + footer, + seen })); } } catch (error) { @@ -1361,8 +1731,8 @@ function watchNetxConnection(listener) { } // src/netx/tools.ts -import { readFileSync as readFileSync2 } from "node:fs"; -import { dirname as dirname4, join as join6 } from "node:path"; +import { readFileSync as readFileSync5 } from "node:fs"; +import { dirname as dirname5, join as join9 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; @@ -2804,8 +3174,8 @@ async function layoutTopologyView(client, args, signal) { // src/netx/tools.ts function packageVersion() { try { - const here = dirname4(fileURLToPath2(import.meta.url)); - const pkg = JSON.parse(readFileSync2(join6(here, "..", "package.json"), "utf8")); + const here = dirname5(fileURLToPath2(import.meta.url)); + const pkg = JSON.parse(readFileSync5(join9(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; @@ -3276,8 +3646,8 @@ function registerNetxTools(ctx, connection, options) { // src/netx/group-tools-plugin.ts function packageVersion2() { try { - const here = dirname5(fileURLToPath3(import.meta.url)); - const pkg = JSON.parse(readFileSync3(join7(here, "..", "package.json"), "utf8")); + const here = dirname6(fileURLToPath3(import.meta.url)); + const pkg = JSON.parse(readFileSync6(join10(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index a8749b8..6e437c8 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -1,6 +1,6 @@ // src/netx/group-tools-plugin.ts -import { readFileSync as readFileSync3 } from "node:fs"; -import { dirname as dirname5, join as join7 } from "node:path"; +import { readFileSync as readFileSync6 } from "node:fs"; +import { dirname as dirname6, join as join10 } from "node:path"; import { fileURLToPath as fileURLToPath3 } from "node:url"; // src/netx/config-values.ts @@ -324,9 +324,56 @@ Use only tools present in this session. Native layout supports catalog and move_ }; } +// src/netx/kb-context-skill.ts +import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync3 } from "node:fs"; +import { join as join4 } from "node:path"; + +// src/netx/kb-manifest.ts +import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "node:fs"; +import { basename, dirname as dirname2, join as join2, resolve } from "node:path"; +var EMPTY_CONTENT = { + hasRegions: false, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false +}; +function unconfiguredKbSnapshot() { + return { + status: "unconfigured", + realRoot: "", + operatorName: "", + country: "", + version: "", + content: { ...EMPTY_CONTENT }, + paths: {}, + errorMessage: "" + }; +} +function resolveKbPaths(snapshot) { + if (snapshot.status !== "configured") + return []; + const paths = { skills: "_skills", ...snapshot.paths }; + return Object.entries(paths).filter((entry) => typeof entry[1] === "string" && entry[1].trim() !== "").map(([key, rel]) => { + const absolute = resolve(snapshot.realRoot, rel); + return { key, relative: rel, absolute, exists: existsSync2(absolute) }; + }); +} +function renderKbPathTable(snapshot) { + const rows = resolveKbPaths(snapshot); + if (rows.length === 0) + return "(MANIFEST has no paths — legacy package; ask HQ for a repack)"; + return [ + "| paths.* | Absolute path |", + "| --- | --- |", + ...rows.map((row) => `| ${row.key} | \`${row.absolute}\`${row.exists ? "" : " (missing)"} |`) + ].join(` +`); +} + // src/netx/kb-local-path.ts -import { existsSync as existsSync2, realpathSync, statSync } from "node:fs"; -import { join as join2, relative, resolve, sep } from "node:path"; +import { existsSync as existsSync3, realpathSync, statSync as statSync2 } from "node:fs"; +import { join as join3, relative, resolve as resolve2, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", @@ -352,10 +399,7 @@ function resolveKbLocalRoot(snapshot) { const rel = snapshot.paths.local?.trim(); if (!rel) return null; - return resolve(snapshot.realRoot, rel); -} -function kbLocalToolsEnabled(snapshot) { - return resolveKbLocalRoot(snapshot) !== null; + return resolve2(snapshot.realRoot, rel); } function sanitizeSlug(raw, fallback = "entry") { const cleaned = raw.trim().replace(/[/\\]+/g, "-").replace(/\.\.+/g, "").replace(/[^\w.\u4e00-\u9fff-]+/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); @@ -407,7 +451,7 @@ function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); } - const absolutePath = resolve(localRoot, ...segments); + const absolutePath = resolve2(localRoot, ...segments); const rel = relative(localRoot, absolutePath); if (!rel || rel.startsWith("..") || rel.split(/[/\\]/).includes("..")) { throw new Error(`path escapes local root: ${absolutePath}`); @@ -426,18 +470,18 @@ function resolveExistingWritableFile(localRoot, pathArg) { const trimmed = pathArg.trim(); if (!trimmed) throw new Error("path is empty"); - const candidate = resolve(trimmed); + const candidate = resolve2(trimmed); let absolutePath; - const localResolved = resolve(localRoot); + const localResolved = resolve2(localRoot); const underLocal = candidate === localResolved || candidate.startsWith(localResolved + sep); if (underLocal) { absolutePath = candidate; } else { - absolutePath = resolve(localRoot, trimmed); + absolutePath = resolve2(localRoot, trimmed); } const checked = resolveWritableLocalPath(localRoot, ...relative(localRoot, absolutePath).split(/[/\\]/).filter(Boolean)); try { - if (existsSync2(checked.absolutePath)) { + if (existsSync3(checked.absolutePath)) { const realFile = realpathSync(checked.absolutePath); const realLocal = realpathSync(localRoot); const realRel = relative(realLocal, realFile); @@ -448,7 +492,7 @@ function resolveExistingWritableFile(localRoot, pathArg) { if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) { throw new Error(`path not under writable roots: ${realRel}`); } - if (!statSync(realFile).isFile()) { + if (!statSync2(realFile).isFile()) { throw new Error(`not a file: ${realFile}`); } return { @@ -465,16 +509,16 @@ function resolveExistingWritableFile(localRoot, pathArg) { return checked; } function memoryDir(bucket) { - return join2("memories", MEMORY_BUCKET_DIR[bucket]); + return join3("memories", MEMORY_BUCKET_DIR[bucket]); } function suggestionDir(kind) { - return join2("suggestions", kind); + return join3("suggestions", kind); } function draftDir(domain) { const d = domain?.trim(); if (!d) return "drafts"; - return join2("drafts", sanitizeSlug(d, "misc")); + return join3("drafts", sanitizeSlug(d, "misc")); } function refDir(area, device) { if (!REF_AREAS.includes(area)) { @@ -485,18 +529,63 @@ function refDir(area, device) { if (!host) { throw new Error("refs/devices requires device (host_name)"); } - return join2("refs", "devices", host); + return join3("refs", "devices", host); } - return join2("refs", area); + return join3("refs", area); } // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; +var IDENTITY_FILE_MAX = 8000; +var IDENTITY_TOTAL_MAX = 24000; +function readKbIdentity(snapshot) { + const localRoot = resolveKbLocalRoot(snapshot); + if (!localRoot) + return []; + const dir = join4(localRoot, "identity"); + let names; + try { + names = readdirSync2(dir).filter((name) => /\.md$/i.test(name) && !/^readme\.md$/i.test(name)).sort(); + } catch { + return []; + } + const out = []; + let budget = IDENTITY_TOTAL_MAX; + for (const name of names) { + if (budget <= 0) + break; + const full = join4(dir, name); + let text; + try { + if (!statSync3(full).isFile()) + continue; + text = readFileSync2(full, "utf8").replace(/^\uFEFF/, "").trim(); + } catch { + continue; + } + if (!text) + continue; + const cap = Math.min(IDENTITY_FILE_MAX, budget); + if (text.length > cap) + text = `${text.slice(0, cap)} + +…(truncated — read \`${full}\` for the rest)`; + budget -= text.length; + out.push({ name, text }); + } + return out; +} function skillBody(snapshot) { if (snapshot.status === "configured") { const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)"; const localRoot = resolveKbLocalRoot(snapshot); const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; + const identity = readKbIdentity(snapshot); + const identityBlock = identity.length > 0 ? [ + "", + "### Site identity (`identity/`, authoritative — follow over generic advice)", + ...identity.flatMap(({ name, text }) => ["", `#### ${name}`, "", text]) + ] : []; const localGuide = localRoot ? [ "", "### Dual plane (mandatory)", @@ -521,7 +610,7 @@ function skillBody(snapshot) { "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", + description: `Load FIRST before any kb-* skill or KB file access: resolved absolute paths (rca / theory / packet / neIndex / cmdLib / local …) for operator ${snapshot.operatorName} (${snapshot.country}) KB v${snapshot.version}, site identity rules, and KB write rules.`, content: [ "## Knowledge base (configured)", "", @@ -537,13 +626,22 @@ function skillBody(snapshot) { `| kbCountry | ${snapshot.country} |`, `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, + "", + "### Path anchors (resolved from MANIFEST `paths.*` — use verbatim, never compose `regions///…`)", + "", + renderKbPathTable(snapshot), + ...identityBlock, ...localGuide, "", - "Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "### Reading the HQ pack", + "- `netx__kbSearch` — keyword/regex search across rca / theory / packet / common / local (returns file + line hits).", + "- `netx__kbRead` — read one KB file (path relative to kbRoot or absolute inside it; optional line window).", + "- Start from the indexes: `neIndex` (NE → cases), `patternMap`, `classification`, then open the cited RCA.", "", - "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from", - `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, - "plus paths.localSkills when present — use those skills for KB triage;", + "Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_PATHS (JSON of the anchors above), KB_STATUS.", + "", + "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from paths.skills", + "when hasSkills is true, plus paths.localSkills when present — use those skills for KB triage;", "keep netx-ops for live netx evidence only." ].join(` `) @@ -584,15 +682,15 @@ import { defineTool } from "@deepseek-ai/dsh-tools"; // src/netx/kb-local-ops.ts import { - existsSync as existsSync3, + existsSync as existsSync4, mkdirSync, - readdirSync, - readFileSync, + readdirSync as readdirSync3, + readFileSync as readFileSync3, rmSync, - statSync as statSync2, + statSync as statSync4, writeFileSync } from "node:fs"; -import { dirname as dirname2, join as join3 } from "node:path"; +import { dirname as dirname3, join as join5 } from "node:path"; function requireLocalRoot(snapshot) { const root = resolveKbLocalRoot(snapshot); if (!root) { @@ -638,11 +736,11 @@ function createMemory(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = memoryFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, memoryDir(args.bucket), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -657,11 +755,11 @@ function createDraft(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = draftFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, draftDir(args.domain), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, ensureDraftBody(args.body), "utf8"); return { ok: true, @@ -674,11 +772,11 @@ function createSuggestion(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = suggestionFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, suggestionDir(args.kind), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -693,11 +791,11 @@ function createRef(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = refFileName(args.slug); const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -711,7 +809,7 @@ function createRef(snapshot, args) { function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); - if (!existsSync3(target.absolutePath)) { + if (!existsSync4(target.absolutePath)) { throw new Error(`file not found: ${target.relativePath}`); } const body = target.writableRoot === "drafts" ? ensureDraftBody(args.body) : args.body.endsWith(` @@ -728,7 +826,7 @@ function updateLocalFile(snapshot, args) { function deleteLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); - if (!existsSync3(target.absolutePath)) { + if (!existsSync4(target.absolutePath)) { throw new Error(`file not found: ${target.relativePath}`); } rmSync(target.absolutePath, { force: false }); @@ -742,17 +840,17 @@ function deleteLocalFile(snapshot, args) { function walkFiles(dir, baseLocal, out) { let entries; try { - entries = readdirSync(dir); + entries = readdirSync3(dir); } catch { return; } for (const name of entries) { if (name === "." || name === "..") continue; - const full = join3(dir, name); + const full = join5(dir, name); let st; try { - st = statSync2(full); + st = statSync4(full); } catch { continue; } @@ -774,7 +872,7 @@ function listLocalFiles(snapshot, args = {}) { const roots = args.root && args.root !== "all" ? [args.root] : [...KB_LOCAL_WRITABLE_ROOTS]; const entries = []; for (const r of roots) { - walkFiles(join3(localRoot, r), localRoot, entries); + walkFiles(join5(localRoot, r), localRoot, entries); } entries.sort((a, b) => b.mtimeMs - a.mtimeMs); const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); @@ -785,26 +883,240 @@ function listLocalFiles(snapshot, args = {}) { }; } -// src/netx/kb-manifest.ts -var EMPTY_CONTENT = { - hasRegions: false, - hasTheory: false, - hasPacket: false, - hasCommon: false, - hasSkills: false -}; -function unconfiguredKbSnapshot() { +// src/netx/kb-read-ops.ts +import { readdirSync as readdirSync4, readFileSync as readFileSync4, realpathSync as realpathSync2, statSync as statSync5 } from "node:fs"; +import { extname, join as join6, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path"; +var TEXT_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".json", ".yml", ".yaml", ".csv", ".ps1", ".sh", ".cfg", ".conf", ".log"]); +var SKIP_DIRS = new Set([".git", "node_modules"]); +var READ_DEFAULT_LINES = 400; +var READ_MAX_LINES = 2000; +var READ_MAX_CHARS = 80000; +var SEARCH_MAX_FILE_BYTES = 2 * 1024 * 1024; +var SEARCH_HITS_PER_FILE = 4; +var CACHE_MAX_CHARS = 64 * 1024 * 1024; +function requireRoot(snapshot) { + if (snapshot.status !== "configured" || !snapshot.realRoot) { + throw new Error("knowledge base not configured (set kbRoot to a folder with MANIFEST.json)"); + } + return snapshot.realRoot; +} +function toPosix(rel) { + return rel.split(sep2).join("/"); +} +function isInside(root, target) { + const rel = relative2(root, target); + return rel === "" || !rel.startsWith("..") && !rel.split(/[/\\]/).includes("..") && !/^[a-zA-Z]:/.test(rel); +} +function resolveKbReadPath(snapshot, pathArg) { + const root = requireRoot(snapshot); + const trimmed = pathArg.trim().replace(/^["']|["']$/g, ""); + if (trimmed.includes("\x00")) + throw new Error("path contains NUL"); + const anchor = resolveKbPaths(snapshot).find((row) => row.key === trimmed); + const candidate = anchor ? anchor.absolute : !trimmed || trimmed === "." || trimmed === "/" ? root : resolve3(root, trimmed); + if (!isInside(root, candidate)) + throw new Error(`path escapes kbRoot: ${candidate}`); + let real; + try { + real = realpathSync2(candidate); + } catch { + throw new Error(`not found in KB: ${toPosix(relative2(root, candidate)) || "."}`); + } + const realRoot = realpathSync2(root); + if (!isInside(realRoot, real)) + throw new Error(`path escapes kbRoot after realpath: ${real}`); + return { absolutePath: real, relativePath: toPosix(relative2(realRoot, real)) || "." }; +} +function readKbFile(snapshot, args) { + const target = resolveKbReadPath(snapshot, args.path); + const st = statSync5(target.absolutePath); + if (st.isDirectory()) { + const entries = readdirSync4(target.absolutePath, { withFileTypes: true }).filter((entry) => !SKIP_DIRS.has(entry.name)).map((entry) => { + if (entry.isDirectory()) + return { name: entry.name, type: "dir" }; + let size; + try { + size = statSync5(join6(target.absolutePath, entry.name)).size; + } catch {} + return { name: entry.name, type: "file", size }; + }).sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1); + return { ok: true, kind: "directory", ...target, entries }; + } + if (!TEXT_EXTENSIONS.has(extname(target.absolutePath).toLowerCase())) { + throw new Error(`not a text KB file: ${target.relativePath}`); + } + const lines = readFileSync4(target.absolutePath, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, ` +`).split(` +`); + const start = Math.max(1, Math.floor(args.offset ?? 1)); + const limit = Math.min(Math.max(1, Math.floor(args.limit ?? READ_DEFAULT_LINES)), READ_MAX_LINES); + let slice = lines.slice(start - 1, start - 1 + limit); + let text = slice.join(` +`); + let truncated = start - 1 + slice.length < lines.length; + if (text.length > READ_MAX_CHARS) { + let used = 0; + let keep = 0; + for (const line of slice) { + if (used + line.length + 1 > READ_MAX_CHARS) + break; + used += line.length + 1; + keep += 1; + } + slice = slice.slice(0, Math.max(keep, 1)); + text = slice.join(` +`).slice(0, READ_MAX_CHARS); + truncated = true; + } return { - status: "unconfigured", - realRoot: "", - operatorName: "", - country: "", - version: "", - content: { ...EMPTY_CONTENT }, - paths: {}, - errorMessage: "" + ok: true, + kind: "file", + ...target, + totalLines: lines.length, + startLine: start, + endLine: start - 1 + slice.length, + truncated, + text }; } +var CACHE_KEY = Symbol.for("dsh-netxops.kb-search-cache"); +function searchCache() { + const root = globalThis; + root[CACHE_KEY] ??= { files: new Map, chars: 0 }; + return root[CACHE_KEY]; +} +function cachedText(path, mtimeMs, size) { + const cache = searchCache(); + const hit = cache.files.get(path); + if (hit && hit.mtimeMs === mtimeMs && hit.size === size) + return hit; + const text = readFileSync4(path, "utf8").replace(/^\uFEFF/, ""); + if (hit) + cache.chars -= hit.text.length; + if (cache.chars + text.length > CACHE_MAX_CHARS) { + cache.files.clear(); + cache.chars = 0; + } + const entry = { mtimeMs, size, text, lower: text.toLowerCase() }; + cache.files.set(path, entry); + cache.chars += text.length; + return entry; +} +function walkTextFiles(dir, out) { + let entries; + try { + entries = readdirSync4(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) + continue; + const full = join6(dir, entry.name); + if (entry.isDirectory()) { + walkTextFiles(full, out); + continue; + } + if (!entry.isFile() || !TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) + continue; + try { + const st = statSync5(full); + if (st.size <= SEARCH_MAX_FILE_BYTES) + out.push({ path: full, mtimeMs: st.mtimeMs, size: st.size }); + } catch {} + } +} +function searchKb(snapshot, args) { + const root = requireRoot(snapshot); + const query = args.query.trim(); + if (!query) + throw new Error("query is empty"); + const scopeArg = args.scope?.trim() || "all"; + const scopes = scopeArg.split(/[,\s]+/).filter(Boolean); + const dirs = scopes.includes("all") ? [root] : scopes.map((scope) => resolveKbReadPath(snapshot, scope).absolutePath); + let matchers; + if (args.regex) { + let re; + try { + re = new RegExp(query, "i"); + } catch (error) { + throw new Error(`invalid regex: ${error instanceof Error ? error.message : String(error)}`); + } + matchers = [{ term: query, test: (_lower, raw) => re.test(raw) }]; + } else { + const terms = [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))]; + matchers = terms.map((term) => ({ term, test: (lower) => lower.includes(term) })); + } + const files = []; + const seen = new Set; + for (const dir of dirs) { + const st = statSync5(dir); + const batch = []; + if (st.isFile()) + batch.push({ path: dir, mtimeMs: st.mtimeMs, size: st.size }); + else + walkTextFiles(dir, batch); + for (const file of batch) { + if (seen.has(file.path)) + continue; + seen.add(file.path); + files.push(file); + } + } + const hits = []; + for (const file of files) { + let cached; + try { + cached = cachedText(file.path, file.mtimeMs, file.size); + } catch { + continue; + } + const rel = toPosix(relative2(root, file.path)); + const matched = new Set; + let score = 0; + for (const m of matchers) { + if (m.test(rel.toLowerCase(), rel)) { + matched.add(m.term); + score += 3; + } + } + const inBody = matchers.filter((m) => m.test(cached.lower, cached.text)); + if (inBody.length === 0 && matched.size === 0) + continue; + const lines = []; + const rows = inBody.length > 0 ? cached.text.split(/\r\n?|\n/) : []; + let lineHits = 0; + for (let i = 0;i < rows.length; i += 1) { + const row = rows[i]; + const lower = row.toLowerCase(); + let lineHit = false; + for (const m of inBody) { + if (m.test(lower, row)) { + matched.add(m.term); + lineHit = true; + } + } + if (!lineHit) + continue; + lineHits += 1; + if (lines.length < SEARCH_HITS_PER_FILE) { + const trimmed = row.trim(); + lines.push({ line: i + 1, text: trimmed.length > 240 ? `${trimmed.slice(0, 240)}…` : trimmed }); + } + } + score += matched.size * 10 + Math.min(lineHits, 30) * 0.1; + hits.push({ + relativePath: rel, + absolutePath: file.path, + score: Math.round(score * 10) / 10, + matchedTerms: [...matched], + lines + }); + } + hits.sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath)); + const limit = Math.min(Math.max(Math.floor(args.limit ?? 20), 1), 100); + return { ok: true, scope: scopeArg, scannedFiles: files.length, totalMatches: hits.length, hits: hits.slice(0, limit) }; +} // src/netx/kb-runtime.ts var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); @@ -862,7 +1174,7 @@ function omitUndefined(record) { // src/netx/tool-mount-status.ts import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs"; import { homedir } from "node:os"; -import { dirname as dirname3, join as join4 } from "node:path"; +import { dirname as dirname4, join as join7 } from "node:path"; var KEY = Symbol.for("dsh-netxops.mount-inventory"); function store2() { const root = globalThis; @@ -886,8 +1198,8 @@ function getToolMountStatus() { } function persist() { try { - const path = join4(process.env.DSH_HOME?.trim() || join4(homedir(), ".dsh"), "netxops-mount-status.json"); - mkdirSync2(dirname3(path), { recursive: true }); + const path = join7(process.env.DSH_HOME?.trim() || join7(homedir(), ".dsh"), "netxops-mount-status.json"); + mkdirSync2(dirname4(path), { recursive: true }); writeFileSync2(path, `${JSON.stringify({ mount: getToolMountStatus() ?? null, mounts: getToolMountStatuses() }, null, 2)} `, "utf8"); } catch {} @@ -937,14 +1249,8 @@ function tool(name, description, parameters, execute) { } }); } -function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { - if (!kbLocalToolsEnabled(snapshot)) - return () => {}; - const localRoot = resolveKbLocalRoot(snapshot); - const toolsApi = ctx.tools; - if (!toolsApi || typeof toolsApi.register !== "function") - return () => {}; - const entries = [ +function writeTools(localRoot) { + return [ tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", { bucket: { type: "string", @@ -1024,7 +1330,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents with netx__kbRead using the listed paths.", { root: { type: "string", enum: ["memories", "drafts", "suggestions", "refs", "all"], @@ -1036,6 +1342,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { limit: typeof args.limit === "number" ? args.limit : undefined })) ]; +} +function readTools(snapshot) { + return [ + tool("netx__kbSearch", `Search the operator knowledge base at ${snapshot.realRoot} (read-only HQ pack + _local). ` + "Plain mode: space-separated keywords (NE host_name, alarm, protocol, symptom…), case-insensitive; " + "files ranked by how many distinct terms match (filename hits weigh more), up to 4 hit lines each. " + "Then open the best hits with netx__kbRead.", { + query: reqStr("Keywords (space-separated) or a regex when regex=true"), + scope: str("MANIFEST paths key(s) — e.g. rca, theory, theoryVendor, packet, commonPatterns, local, neIndex — or a relative dir; comma-separate several; default all"), + regex: bool("Treat query as one case-insensitive JS regex"), + limit: num("Max files 1–100 (default 20)") + }, (args) => searchKb(liveSnapshot(), { + query: String(args.query ?? ""), + scope: args.scope != null ? String(args.scope) : undefined, + regex: args.regex === true, + limit: typeof args.limit === "number" ? args.limit : undefined + })), + tool("netx__kbRead", `Read one file (or list one directory) inside the knowledge base at ${snapshot.realRoot}. ` + "path: relative to kbRoot, absolute inside it, or a MANIFEST paths key (rca, neIndex, cmdLib, patternMap, …). " + "Long files: page with offset/limit (1-based lines; default 400).", { + path: reqStr("KB-relative path, absolute path inside kbRoot, or a paths.* key"), + offset: num("First line (1-based, default 1)"), + limit: num("Max lines 1–2000 (default 400)") + }, (args) => readKbFile(liveSnapshot(), { + path: String(args.path ?? ""), + offset: typeof args.offset === "number" ? args.offset : undefined, + limit: typeof args.limit === "number" ? args.limit : undefined + })) + ]; +} +function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { + if (snapshot.status !== "configured") + return () => {}; + const localRoot = resolveKbLocalRoot(snapshot); + const toolsApi = ctx.tools; + if (!toolsApi || typeof toolsApi.register !== "function") + return () => {}; + const entries = [...readTools(snapshot), ...localRoot ? writeTools(localRoot) : []]; const disposers = []; const source = options.source ?? "netxops-kb-local"; const id = getToolMountId(ctx, source); @@ -1057,7 +1396,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { publishToolMountStatus({ ...status, names: [], count: 0, error: String(error) }); throw error; } - ctx.logger.info("netxops: kb local tools registered count=%s localRoot=%s", disposers.length, localRoot); + ctx.logger.info("netxops: kb tools registered count=%s kbRoot=%s localRoot=%s", disposers.length, snapshot.realRoot, localRoot ?? "(none — read-only)"); return () => { for (const dispose of disposers) dispose(); @@ -1067,19 +1406,35 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { // src/netx/kb-pack-skills.ts import { readdir as readdir2, stat as stat2 } from "node:fs/promises"; -import { join as join5 } from "node:path"; +import { join as join8 } from "node:path"; function kbPackSkillsEnabled(snapshot) { return snapshot.status === "configured" && snapshot.content.hasSkills === true; } function resolveKbSkillsRoot(snapshot) { const rel = snapshot.paths.skills?.trim() || "_skills"; - return join5(snapshot.realRoot, rel); + return join8(snapshot.realRoot, rel); +} +function kbPackSkillFooter(snapshot) { + return [ + "", + "", + `## Injected KB anchors (netxops · ${snapshot.operatorName} / ${snapshot.country} v${snapshot.version})`, + "", + `kbStatus=configured · kbRoot=\`${snapshot.realRoot}\``, + "", + renderKbPathTable(snapshot), + "", + "These are already resolved from `MANIFEST.json` `paths.*`; use them verbatim.", + "Read/search the pack with `netx__kbRead` / `netx__kbSearch` when present; write only via `netx__kbWrite*`.", + "" + ].join(` +`); } function resolveKbLocalSkillsRoot(snapshot) { const rel = snapshot.paths.localSkills?.trim(); if (!rel) return null; - return join5(snapshot.realRoot, rel); + return join8(snapshot.realRoot, rel); } async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; @@ -1098,7 +1453,7 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) for (const entry of entries) { if (options.signal?.aborted) break; - const full = join5(root, entry); + const full = join8(root, entry); let isDir = false; try { isDir = (await stat2(full)).isDirectory(); @@ -1120,15 +1475,24 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) } if (options.signal?.aborted) break; - disposers.push(skillsApi.register({ - name: skill.name, - description: skill.description, - content: skill.content, - path: skill.path, - resourceBase: { kind: "directory", path: skill.directory }, - provider, - source: "custom" - })); + if (options.seen.has(skill.name)) { + ctx.logger.warn("netxops: kb %s skill %s duplicates an already registered pack skill — skip %s", options.label, skill.name, full); + continue; + } + try { + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: `${skill.content.trimEnd()}${options.footer}`, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider, + source: "custom" + })); + options.seen.add(skill.name); + } catch (error) { + ctx.logger.warn("netxops: kb %s skill %s register failed: %s", options.label, skill.name, error instanceof Error ? error.message : String(error)); + } } } catch (error) { for (const dispose of disposers.splice(0)) @@ -1149,17 +1513,23 @@ async function registerKbPackSkills(ctx, snapshot, options) { const disposers = []; const skillsRoot = resolveKbSkillsRoot(snapshot); const localRoot = resolveKbLocalSkillsRoot(snapshot); + const footer = kbPackSkillFooter(snapshot); + const seen = new Set; try { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { logMissing: true, label: "pack", - signal: options.signal + signal: options.signal, + footer, + seen })); if (localRoot) { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, { logMissing: false, label: "local", - signal: options.signal + signal: options.signal, + footer, + seen })); } } catch (error) { @@ -1361,8 +1731,8 @@ function watchNetxConnection(listener) { } // src/netx/tools.ts -import { readFileSync as readFileSync2 } from "node:fs"; -import { dirname as dirname4, join as join6 } from "node:path"; +import { readFileSync as readFileSync5 } from "node:fs"; +import { dirname as dirname5, join as join9 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; @@ -2804,8 +3174,8 @@ async function layoutTopologyView(client, args, signal) { // src/netx/tools.ts function packageVersion() { try { - const here = dirname4(fileURLToPath2(import.meta.url)); - const pkg = JSON.parse(readFileSync2(join6(here, "..", "package.json"), "utf8")); + const here = dirname5(fileURLToPath2(import.meta.url)); + const pkg = JSON.parse(readFileSync5(join9(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; @@ -3276,8 +3646,8 @@ function registerNetxTools(ctx, connection, options) { // src/netx/group-tools-plugin.ts function packageVersion2() { try { - const here = dirname5(fileURLToPath3(import.meta.url)); - const pkg = JSON.parse(readFileSync3(join7(here, "..", "package.json"), "utf8")); + const here = dirname6(fileURLToPath3(import.meta.url)); + const pkg = JSON.parse(readFileSync6(join10(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index dd94373..d50a822 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -1,6 +1,6 @@ // src/netx/group-tools-plugin.ts -import { readFileSync as readFileSync3 } from "node:fs"; -import { dirname as dirname5, join as join7 } from "node:path"; +import { readFileSync as readFileSync6 } from "node:fs"; +import { dirname as dirname6, join as join10 } from "node:path"; import { fileURLToPath as fileURLToPath3 } from "node:url"; // src/netx/config-values.ts @@ -324,9 +324,56 @@ Use only tools present in this session. Native layout supports catalog and move_ }; } +// src/netx/kb-context-skill.ts +import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync3 } from "node:fs"; +import { join as join4 } from "node:path"; + +// src/netx/kb-manifest.ts +import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "node:fs"; +import { basename, dirname as dirname2, join as join2, resolve } from "node:path"; +var EMPTY_CONTENT = { + hasRegions: false, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false +}; +function unconfiguredKbSnapshot() { + return { + status: "unconfigured", + realRoot: "", + operatorName: "", + country: "", + version: "", + content: { ...EMPTY_CONTENT }, + paths: {}, + errorMessage: "" + }; +} +function resolveKbPaths(snapshot) { + if (snapshot.status !== "configured") + return []; + const paths = { skills: "_skills", ...snapshot.paths }; + return Object.entries(paths).filter((entry) => typeof entry[1] === "string" && entry[1].trim() !== "").map(([key, rel]) => { + const absolute = resolve(snapshot.realRoot, rel); + return { key, relative: rel, absolute, exists: existsSync2(absolute) }; + }); +} +function renderKbPathTable(snapshot) { + const rows = resolveKbPaths(snapshot); + if (rows.length === 0) + return "(MANIFEST has no paths — legacy package; ask HQ for a repack)"; + return [ + "| paths.* | Absolute path |", + "| --- | --- |", + ...rows.map((row) => `| ${row.key} | \`${row.absolute}\`${row.exists ? "" : " (missing)"} |`) + ].join(` +`); +} + // src/netx/kb-local-path.ts -import { existsSync as existsSync2, realpathSync, statSync } from "node:fs"; -import { join as join2, relative, resolve, sep } from "node:path"; +import { existsSync as existsSync3, realpathSync, statSync as statSync2 } from "node:fs"; +import { join as join3, relative, resolve as resolve2, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", @@ -352,10 +399,7 @@ function resolveKbLocalRoot(snapshot) { const rel = snapshot.paths.local?.trim(); if (!rel) return null; - return resolve(snapshot.realRoot, rel); -} -function kbLocalToolsEnabled(snapshot) { - return resolveKbLocalRoot(snapshot) !== null; + return resolve2(snapshot.realRoot, rel); } function sanitizeSlug(raw, fallback = "entry") { const cleaned = raw.trim().replace(/[/\\]+/g, "-").replace(/\.\.+/g, "").replace(/[^\w.\u4e00-\u9fff-]+/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); @@ -407,7 +451,7 @@ function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); } - const absolutePath = resolve(localRoot, ...segments); + const absolutePath = resolve2(localRoot, ...segments); const rel = relative(localRoot, absolutePath); if (!rel || rel.startsWith("..") || rel.split(/[/\\]/).includes("..")) { throw new Error(`path escapes local root: ${absolutePath}`); @@ -426,18 +470,18 @@ function resolveExistingWritableFile(localRoot, pathArg) { const trimmed = pathArg.trim(); if (!trimmed) throw new Error("path is empty"); - const candidate = resolve(trimmed); + const candidate = resolve2(trimmed); let absolutePath; - const localResolved = resolve(localRoot); + const localResolved = resolve2(localRoot); const underLocal = candidate === localResolved || candidate.startsWith(localResolved + sep); if (underLocal) { absolutePath = candidate; } else { - absolutePath = resolve(localRoot, trimmed); + absolutePath = resolve2(localRoot, trimmed); } const checked = resolveWritableLocalPath(localRoot, ...relative(localRoot, absolutePath).split(/[/\\]/).filter(Boolean)); try { - if (existsSync2(checked.absolutePath)) { + if (existsSync3(checked.absolutePath)) { const realFile = realpathSync(checked.absolutePath); const realLocal = realpathSync(localRoot); const realRel = relative(realLocal, realFile); @@ -448,7 +492,7 @@ function resolveExistingWritableFile(localRoot, pathArg) { if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) { throw new Error(`path not under writable roots: ${realRel}`); } - if (!statSync(realFile).isFile()) { + if (!statSync2(realFile).isFile()) { throw new Error(`not a file: ${realFile}`); } return { @@ -465,16 +509,16 @@ function resolveExistingWritableFile(localRoot, pathArg) { return checked; } function memoryDir(bucket) { - return join2("memories", MEMORY_BUCKET_DIR[bucket]); + return join3("memories", MEMORY_BUCKET_DIR[bucket]); } function suggestionDir(kind) { - return join2("suggestions", kind); + return join3("suggestions", kind); } function draftDir(domain) { const d = domain?.trim(); if (!d) return "drafts"; - return join2("drafts", sanitizeSlug(d, "misc")); + return join3("drafts", sanitizeSlug(d, "misc")); } function refDir(area, device) { if (!REF_AREAS.includes(area)) { @@ -485,18 +529,63 @@ function refDir(area, device) { if (!host) { throw new Error("refs/devices requires device (host_name)"); } - return join2("refs", "devices", host); + return join3("refs", "devices", host); } - return join2("refs", area); + return join3("refs", area); } // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; +var IDENTITY_FILE_MAX = 8000; +var IDENTITY_TOTAL_MAX = 24000; +function readKbIdentity(snapshot) { + const localRoot = resolveKbLocalRoot(snapshot); + if (!localRoot) + return []; + const dir = join4(localRoot, "identity"); + let names; + try { + names = readdirSync2(dir).filter((name) => /\.md$/i.test(name) && !/^readme\.md$/i.test(name)).sort(); + } catch { + return []; + } + const out = []; + let budget = IDENTITY_TOTAL_MAX; + for (const name of names) { + if (budget <= 0) + break; + const full = join4(dir, name); + let text; + try { + if (!statSync3(full).isFile()) + continue; + text = readFileSync2(full, "utf8").replace(/^\uFEFF/, "").trim(); + } catch { + continue; + } + if (!text) + continue; + const cap = Math.min(IDENTITY_FILE_MAX, budget); + if (text.length > cap) + text = `${text.slice(0, cap)} + +…(truncated — read \`${full}\` for the rest)`; + budget -= text.length; + out.push({ name, text }); + } + return out; +} function skillBody(snapshot) { if (snapshot.status === "configured") { const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)"; const localRoot = resolveKbLocalRoot(snapshot); const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; + const identity = readKbIdentity(snapshot); + const identityBlock = identity.length > 0 ? [ + "", + "### Site identity (`identity/`, authoritative — follow over generic advice)", + ...identity.flatMap(({ name, text }) => ["", `#### ${name}`, "", text]) + ] : []; const localGuide = localRoot ? [ "", "### Dual plane (mandatory)", @@ -521,7 +610,7 @@ function skillBody(snapshot) { "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", + description: `Load FIRST before any kb-* skill or KB file access: resolved absolute paths (rca / theory / packet / neIndex / cmdLib / local …) for operator ${snapshot.operatorName} (${snapshot.country}) KB v${snapshot.version}, site identity rules, and KB write rules.`, content: [ "## Knowledge base (configured)", "", @@ -537,13 +626,22 @@ function skillBody(snapshot) { `| kbCountry | ${snapshot.country} |`, `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, + "", + "### Path anchors (resolved from MANIFEST `paths.*` — use verbatim, never compose `regions///…`)", + "", + renderKbPathTable(snapshot), + ...identityBlock, ...localGuide, "", - "Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "### Reading the HQ pack", + "- `netx__kbSearch` — keyword/regex search across rca / theory / packet / common / local (returns file + line hits).", + "- `netx__kbRead` — read one KB file (path relative to kbRoot or absolute inside it; optional line window).", + "- Start from the indexes: `neIndex` (NE → cases), `patternMap`, `classification`, then open the cited RCA.", "", - "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from", - `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, - "plus paths.localSkills when present — use those skills for KB triage;", + "Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_PATHS (JSON of the anchors above), KB_STATUS.", + "", + "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from paths.skills", + "when hasSkills is true, plus paths.localSkills when present — use those skills for KB triage;", "keep netx-ops for live netx evidence only." ].join(` `) @@ -584,15 +682,15 @@ import { defineTool } from "@deepseek-ai/dsh-tools"; // src/netx/kb-local-ops.ts import { - existsSync as existsSync3, + existsSync as existsSync4, mkdirSync, - readdirSync, - readFileSync, + readdirSync as readdirSync3, + readFileSync as readFileSync3, rmSync, - statSync as statSync2, + statSync as statSync4, writeFileSync } from "node:fs"; -import { dirname as dirname2, join as join3 } from "node:path"; +import { dirname as dirname3, join as join5 } from "node:path"; function requireLocalRoot(snapshot) { const root = resolveKbLocalRoot(snapshot); if (!root) { @@ -638,11 +736,11 @@ function createMemory(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = memoryFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, memoryDir(args.bucket), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -657,11 +755,11 @@ function createDraft(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = draftFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, draftDir(args.domain), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, ensureDraftBody(args.body), "utf8"); return { ok: true, @@ -674,11 +772,11 @@ function createSuggestion(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = suggestionFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, suggestionDir(args.kind), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -693,11 +791,11 @@ function createRef(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = refFileName(args.slug); const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -711,7 +809,7 @@ function createRef(snapshot, args) { function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); - if (!existsSync3(target.absolutePath)) { + if (!existsSync4(target.absolutePath)) { throw new Error(`file not found: ${target.relativePath}`); } const body = target.writableRoot === "drafts" ? ensureDraftBody(args.body) : args.body.endsWith(` @@ -728,7 +826,7 @@ function updateLocalFile(snapshot, args) { function deleteLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); - if (!existsSync3(target.absolutePath)) { + if (!existsSync4(target.absolutePath)) { throw new Error(`file not found: ${target.relativePath}`); } rmSync(target.absolutePath, { force: false }); @@ -742,17 +840,17 @@ function deleteLocalFile(snapshot, args) { function walkFiles(dir, baseLocal, out) { let entries; try { - entries = readdirSync(dir); + entries = readdirSync3(dir); } catch { return; } for (const name of entries) { if (name === "." || name === "..") continue; - const full = join3(dir, name); + const full = join5(dir, name); let st; try { - st = statSync2(full); + st = statSync4(full); } catch { continue; } @@ -774,7 +872,7 @@ function listLocalFiles(snapshot, args = {}) { const roots = args.root && args.root !== "all" ? [args.root] : [...KB_LOCAL_WRITABLE_ROOTS]; const entries = []; for (const r of roots) { - walkFiles(join3(localRoot, r), localRoot, entries); + walkFiles(join5(localRoot, r), localRoot, entries); } entries.sort((a, b) => b.mtimeMs - a.mtimeMs); const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); @@ -785,26 +883,240 @@ function listLocalFiles(snapshot, args = {}) { }; } -// src/netx/kb-manifest.ts -var EMPTY_CONTENT = { - hasRegions: false, - hasTheory: false, - hasPacket: false, - hasCommon: false, - hasSkills: false -}; -function unconfiguredKbSnapshot() { +// src/netx/kb-read-ops.ts +import { readdirSync as readdirSync4, readFileSync as readFileSync4, realpathSync as realpathSync2, statSync as statSync5 } from "node:fs"; +import { extname, join as join6, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path"; +var TEXT_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".json", ".yml", ".yaml", ".csv", ".ps1", ".sh", ".cfg", ".conf", ".log"]); +var SKIP_DIRS = new Set([".git", "node_modules"]); +var READ_DEFAULT_LINES = 400; +var READ_MAX_LINES = 2000; +var READ_MAX_CHARS = 80000; +var SEARCH_MAX_FILE_BYTES = 2 * 1024 * 1024; +var SEARCH_HITS_PER_FILE = 4; +var CACHE_MAX_CHARS = 64 * 1024 * 1024; +function requireRoot(snapshot) { + if (snapshot.status !== "configured" || !snapshot.realRoot) { + throw new Error("knowledge base not configured (set kbRoot to a folder with MANIFEST.json)"); + } + return snapshot.realRoot; +} +function toPosix(rel) { + return rel.split(sep2).join("/"); +} +function isInside(root, target) { + const rel = relative2(root, target); + return rel === "" || !rel.startsWith("..") && !rel.split(/[/\\]/).includes("..") && !/^[a-zA-Z]:/.test(rel); +} +function resolveKbReadPath(snapshot, pathArg) { + const root = requireRoot(snapshot); + const trimmed = pathArg.trim().replace(/^["']|["']$/g, ""); + if (trimmed.includes("\x00")) + throw new Error("path contains NUL"); + const anchor = resolveKbPaths(snapshot).find((row) => row.key === trimmed); + const candidate = anchor ? anchor.absolute : !trimmed || trimmed === "." || trimmed === "/" ? root : resolve3(root, trimmed); + if (!isInside(root, candidate)) + throw new Error(`path escapes kbRoot: ${candidate}`); + let real; + try { + real = realpathSync2(candidate); + } catch { + throw new Error(`not found in KB: ${toPosix(relative2(root, candidate)) || "."}`); + } + const realRoot = realpathSync2(root); + if (!isInside(realRoot, real)) + throw new Error(`path escapes kbRoot after realpath: ${real}`); + return { absolutePath: real, relativePath: toPosix(relative2(realRoot, real)) || "." }; +} +function readKbFile(snapshot, args) { + const target = resolveKbReadPath(snapshot, args.path); + const st = statSync5(target.absolutePath); + if (st.isDirectory()) { + const entries = readdirSync4(target.absolutePath, { withFileTypes: true }).filter((entry) => !SKIP_DIRS.has(entry.name)).map((entry) => { + if (entry.isDirectory()) + return { name: entry.name, type: "dir" }; + let size; + try { + size = statSync5(join6(target.absolutePath, entry.name)).size; + } catch {} + return { name: entry.name, type: "file", size }; + }).sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1); + return { ok: true, kind: "directory", ...target, entries }; + } + if (!TEXT_EXTENSIONS.has(extname(target.absolutePath).toLowerCase())) { + throw new Error(`not a text KB file: ${target.relativePath}`); + } + const lines = readFileSync4(target.absolutePath, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, ` +`).split(` +`); + const start = Math.max(1, Math.floor(args.offset ?? 1)); + const limit = Math.min(Math.max(1, Math.floor(args.limit ?? READ_DEFAULT_LINES)), READ_MAX_LINES); + let slice = lines.slice(start - 1, start - 1 + limit); + let text = slice.join(` +`); + let truncated = start - 1 + slice.length < lines.length; + if (text.length > READ_MAX_CHARS) { + let used = 0; + let keep = 0; + for (const line of slice) { + if (used + line.length + 1 > READ_MAX_CHARS) + break; + used += line.length + 1; + keep += 1; + } + slice = slice.slice(0, Math.max(keep, 1)); + text = slice.join(` +`).slice(0, READ_MAX_CHARS); + truncated = true; + } return { - status: "unconfigured", - realRoot: "", - operatorName: "", - country: "", - version: "", - content: { ...EMPTY_CONTENT }, - paths: {}, - errorMessage: "" + ok: true, + kind: "file", + ...target, + totalLines: lines.length, + startLine: start, + endLine: start - 1 + slice.length, + truncated, + text }; } +var CACHE_KEY = Symbol.for("dsh-netxops.kb-search-cache"); +function searchCache() { + const root = globalThis; + root[CACHE_KEY] ??= { files: new Map, chars: 0 }; + return root[CACHE_KEY]; +} +function cachedText(path, mtimeMs, size) { + const cache = searchCache(); + const hit = cache.files.get(path); + if (hit && hit.mtimeMs === mtimeMs && hit.size === size) + return hit; + const text = readFileSync4(path, "utf8").replace(/^\uFEFF/, ""); + if (hit) + cache.chars -= hit.text.length; + if (cache.chars + text.length > CACHE_MAX_CHARS) { + cache.files.clear(); + cache.chars = 0; + } + const entry = { mtimeMs, size, text, lower: text.toLowerCase() }; + cache.files.set(path, entry); + cache.chars += text.length; + return entry; +} +function walkTextFiles(dir, out) { + let entries; + try { + entries = readdirSync4(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) + continue; + const full = join6(dir, entry.name); + if (entry.isDirectory()) { + walkTextFiles(full, out); + continue; + } + if (!entry.isFile() || !TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) + continue; + try { + const st = statSync5(full); + if (st.size <= SEARCH_MAX_FILE_BYTES) + out.push({ path: full, mtimeMs: st.mtimeMs, size: st.size }); + } catch {} + } +} +function searchKb(snapshot, args) { + const root = requireRoot(snapshot); + const query = args.query.trim(); + if (!query) + throw new Error("query is empty"); + const scopeArg = args.scope?.trim() || "all"; + const scopes = scopeArg.split(/[,\s]+/).filter(Boolean); + const dirs = scopes.includes("all") ? [root] : scopes.map((scope) => resolveKbReadPath(snapshot, scope).absolutePath); + let matchers; + if (args.regex) { + let re; + try { + re = new RegExp(query, "i"); + } catch (error) { + throw new Error(`invalid regex: ${error instanceof Error ? error.message : String(error)}`); + } + matchers = [{ term: query, test: (_lower, raw) => re.test(raw) }]; + } else { + const terms = [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))]; + matchers = terms.map((term) => ({ term, test: (lower) => lower.includes(term) })); + } + const files = []; + const seen = new Set; + for (const dir of dirs) { + const st = statSync5(dir); + const batch = []; + if (st.isFile()) + batch.push({ path: dir, mtimeMs: st.mtimeMs, size: st.size }); + else + walkTextFiles(dir, batch); + for (const file of batch) { + if (seen.has(file.path)) + continue; + seen.add(file.path); + files.push(file); + } + } + const hits = []; + for (const file of files) { + let cached; + try { + cached = cachedText(file.path, file.mtimeMs, file.size); + } catch { + continue; + } + const rel = toPosix(relative2(root, file.path)); + const matched = new Set; + let score = 0; + for (const m of matchers) { + if (m.test(rel.toLowerCase(), rel)) { + matched.add(m.term); + score += 3; + } + } + const inBody = matchers.filter((m) => m.test(cached.lower, cached.text)); + if (inBody.length === 0 && matched.size === 0) + continue; + const lines = []; + const rows = inBody.length > 0 ? cached.text.split(/\r\n?|\n/) : []; + let lineHits = 0; + for (let i = 0;i < rows.length; i += 1) { + const row = rows[i]; + const lower = row.toLowerCase(); + let lineHit = false; + for (const m of inBody) { + if (m.test(lower, row)) { + matched.add(m.term); + lineHit = true; + } + } + if (!lineHit) + continue; + lineHits += 1; + if (lines.length < SEARCH_HITS_PER_FILE) { + const trimmed = row.trim(); + lines.push({ line: i + 1, text: trimmed.length > 240 ? `${trimmed.slice(0, 240)}…` : trimmed }); + } + } + score += matched.size * 10 + Math.min(lineHits, 30) * 0.1; + hits.push({ + relativePath: rel, + absolutePath: file.path, + score: Math.round(score * 10) / 10, + matchedTerms: [...matched], + lines + }); + } + hits.sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath)); + const limit = Math.min(Math.max(Math.floor(args.limit ?? 20), 1), 100); + return { ok: true, scope: scopeArg, scannedFiles: files.length, totalMatches: hits.length, hits: hits.slice(0, limit) }; +} // src/netx/kb-runtime.ts var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); @@ -862,7 +1174,7 @@ function omitUndefined(record) { // src/netx/tool-mount-status.ts import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs"; import { homedir } from "node:os"; -import { dirname as dirname3, join as join4 } from "node:path"; +import { dirname as dirname4, join as join7 } from "node:path"; var KEY = Symbol.for("dsh-netxops.mount-inventory"); function store2() { const root = globalThis; @@ -886,8 +1198,8 @@ function getToolMountStatus() { } function persist() { try { - const path = join4(process.env.DSH_HOME?.trim() || join4(homedir(), ".dsh"), "netxops-mount-status.json"); - mkdirSync2(dirname3(path), { recursive: true }); + const path = join7(process.env.DSH_HOME?.trim() || join7(homedir(), ".dsh"), "netxops-mount-status.json"); + mkdirSync2(dirname4(path), { recursive: true }); writeFileSync2(path, `${JSON.stringify({ mount: getToolMountStatus() ?? null, mounts: getToolMountStatuses() }, null, 2)} `, "utf8"); } catch {} @@ -937,14 +1249,8 @@ function tool(name, description, parameters, execute) { } }); } -function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { - if (!kbLocalToolsEnabled(snapshot)) - return () => {}; - const localRoot = resolveKbLocalRoot(snapshot); - const toolsApi = ctx.tools; - if (!toolsApi || typeof toolsApi.register !== "function") - return () => {}; - const entries = [ +function writeTools(localRoot) { + return [ tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", { bucket: { type: "string", @@ -1024,7 +1330,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents with netx__kbRead using the listed paths.", { root: { type: "string", enum: ["memories", "drafts", "suggestions", "refs", "all"], @@ -1036,6 +1342,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { limit: typeof args.limit === "number" ? args.limit : undefined })) ]; +} +function readTools(snapshot) { + return [ + tool("netx__kbSearch", `Search the operator knowledge base at ${snapshot.realRoot} (read-only HQ pack + _local). ` + "Plain mode: space-separated keywords (NE host_name, alarm, protocol, symptom…), case-insensitive; " + "files ranked by how many distinct terms match (filename hits weigh more), up to 4 hit lines each. " + "Then open the best hits with netx__kbRead.", { + query: reqStr("Keywords (space-separated) or a regex when regex=true"), + scope: str("MANIFEST paths key(s) — e.g. rca, theory, theoryVendor, packet, commonPatterns, local, neIndex — or a relative dir; comma-separate several; default all"), + regex: bool("Treat query as one case-insensitive JS regex"), + limit: num("Max files 1–100 (default 20)") + }, (args) => searchKb(liveSnapshot(), { + query: String(args.query ?? ""), + scope: args.scope != null ? String(args.scope) : undefined, + regex: args.regex === true, + limit: typeof args.limit === "number" ? args.limit : undefined + })), + tool("netx__kbRead", `Read one file (or list one directory) inside the knowledge base at ${snapshot.realRoot}. ` + "path: relative to kbRoot, absolute inside it, or a MANIFEST paths key (rca, neIndex, cmdLib, patternMap, …). " + "Long files: page with offset/limit (1-based lines; default 400).", { + path: reqStr("KB-relative path, absolute path inside kbRoot, or a paths.* key"), + offset: num("First line (1-based, default 1)"), + limit: num("Max lines 1–2000 (default 400)") + }, (args) => readKbFile(liveSnapshot(), { + path: String(args.path ?? ""), + offset: typeof args.offset === "number" ? args.offset : undefined, + limit: typeof args.limit === "number" ? args.limit : undefined + })) + ]; +} +function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { + if (snapshot.status !== "configured") + return () => {}; + const localRoot = resolveKbLocalRoot(snapshot); + const toolsApi = ctx.tools; + if (!toolsApi || typeof toolsApi.register !== "function") + return () => {}; + const entries = [...readTools(snapshot), ...localRoot ? writeTools(localRoot) : []]; const disposers = []; const source = options.source ?? "netxops-kb-local"; const id = getToolMountId(ctx, source); @@ -1057,7 +1396,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { publishToolMountStatus({ ...status, names: [], count: 0, error: String(error) }); throw error; } - ctx.logger.info("netxops: kb local tools registered count=%s localRoot=%s", disposers.length, localRoot); + ctx.logger.info("netxops: kb tools registered count=%s kbRoot=%s localRoot=%s", disposers.length, snapshot.realRoot, localRoot ?? "(none — read-only)"); return () => { for (const dispose of disposers) dispose(); @@ -1067,19 +1406,35 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { // src/netx/kb-pack-skills.ts import { readdir as readdir2, stat as stat2 } from "node:fs/promises"; -import { join as join5 } from "node:path"; +import { join as join8 } from "node:path"; function kbPackSkillsEnabled(snapshot) { return snapshot.status === "configured" && snapshot.content.hasSkills === true; } function resolveKbSkillsRoot(snapshot) { const rel = snapshot.paths.skills?.trim() || "_skills"; - return join5(snapshot.realRoot, rel); + return join8(snapshot.realRoot, rel); +} +function kbPackSkillFooter(snapshot) { + return [ + "", + "", + `## Injected KB anchors (netxops · ${snapshot.operatorName} / ${snapshot.country} v${snapshot.version})`, + "", + `kbStatus=configured · kbRoot=\`${snapshot.realRoot}\``, + "", + renderKbPathTable(snapshot), + "", + "These are already resolved from `MANIFEST.json` `paths.*`; use them verbatim.", + "Read/search the pack with `netx__kbRead` / `netx__kbSearch` when present; write only via `netx__kbWrite*`.", + "" + ].join(` +`); } function resolveKbLocalSkillsRoot(snapshot) { const rel = snapshot.paths.localSkills?.trim(); if (!rel) return null; - return join5(snapshot.realRoot, rel); + return join8(snapshot.realRoot, rel); } async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; @@ -1098,7 +1453,7 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) for (const entry of entries) { if (options.signal?.aborted) break; - const full = join5(root, entry); + const full = join8(root, entry); let isDir = false; try { isDir = (await stat2(full)).isDirectory(); @@ -1120,15 +1475,24 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) } if (options.signal?.aborted) break; - disposers.push(skillsApi.register({ - name: skill.name, - description: skill.description, - content: skill.content, - path: skill.path, - resourceBase: { kind: "directory", path: skill.directory }, - provider, - source: "custom" - })); + if (options.seen.has(skill.name)) { + ctx.logger.warn("netxops: kb %s skill %s duplicates an already registered pack skill — skip %s", options.label, skill.name, full); + continue; + } + try { + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: `${skill.content.trimEnd()}${options.footer}`, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider, + source: "custom" + })); + options.seen.add(skill.name); + } catch (error) { + ctx.logger.warn("netxops: kb %s skill %s register failed: %s", options.label, skill.name, error instanceof Error ? error.message : String(error)); + } } } catch (error) { for (const dispose of disposers.splice(0)) @@ -1149,17 +1513,23 @@ async function registerKbPackSkills(ctx, snapshot, options) { const disposers = []; const skillsRoot = resolveKbSkillsRoot(snapshot); const localRoot = resolveKbLocalSkillsRoot(snapshot); + const footer = kbPackSkillFooter(snapshot); + const seen = new Set; try { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { logMissing: true, label: "pack", - signal: options.signal + signal: options.signal, + footer, + seen })); if (localRoot) { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, { logMissing: false, label: "local", - signal: options.signal + signal: options.signal, + footer, + seen })); } } catch (error) { @@ -1361,8 +1731,8 @@ function watchNetxConnection(listener) { } // src/netx/tools.ts -import { readFileSync as readFileSync2 } from "node:fs"; -import { dirname as dirname4, join as join6 } from "node:path"; +import { readFileSync as readFileSync5 } from "node:fs"; +import { dirname as dirname5, join as join9 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; @@ -2804,8 +3174,8 @@ async function layoutTopologyView(client, args, signal) { // src/netx/tools.ts function packageVersion() { try { - const here = dirname4(fileURLToPath2(import.meta.url)); - const pkg = JSON.parse(readFileSync2(join6(here, "..", "package.json"), "utf8")); + const here = dirname5(fileURLToPath2(import.meta.url)); + const pkg = JSON.parse(readFileSync5(join9(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; @@ -3276,8 +3646,8 @@ function registerNetxTools(ctx, connection, options) { // src/netx/group-tools-plugin.ts function packageVersion2() { try { - const here = dirname5(fileURLToPath3(import.meta.url)); - const pkg = JSON.parse(readFileSync3(join7(here, "..", "package.json"), "utf8")); + const here = dirname6(fileURLToPath3(import.meta.url)); + const pkg = JSON.parse(readFileSync6(join10(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index d31fa8c..311912e 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -1,6 +1,6 @@ // src/netx/group-tools-plugin.ts -import { readFileSync as readFileSync3 } from "node:fs"; -import { dirname as dirname5, join as join7 } from "node:path"; +import { readFileSync as readFileSync6 } from "node:fs"; +import { dirname as dirname6, join as join10 } from "node:path"; import { fileURLToPath as fileURLToPath3 } from "node:url"; // src/netx/config-values.ts @@ -324,9 +324,56 @@ Use only tools present in this session. Native layout supports catalog and move_ }; } +// src/netx/kb-context-skill.ts +import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync3 } from "node:fs"; +import { join as join4 } from "node:path"; + +// src/netx/kb-manifest.ts +import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "node:fs"; +import { basename, dirname as dirname2, join as join2, resolve } from "node:path"; +var EMPTY_CONTENT = { + hasRegions: false, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false +}; +function unconfiguredKbSnapshot() { + return { + status: "unconfigured", + realRoot: "", + operatorName: "", + country: "", + version: "", + content: { ...EMPTY_CONTENT }, + paths: {}, + errorMessage: "" + }; +} +function resolveKbPaths(snapshot) { + if (snapshot.status !== "configured") + return []; + const paths = { skills: "_skills", ...snapshot.paths }; + return Object.entries(paths).filter((entry) => typeof entry[1] === "string" && entry[1].trim() !== "").map(([key, rel]) => { + const absolute = resolve(snapshot.realRoot, rel); + return { key, relative: rel, absolute, exists: existsSync2(absolute) }; + }); +} +function renderKbPathTable(snapshot) { + const rows = resolveKbPaths(snapshot); + if (rows.length === 0) + return "(MANIFEST has no paths — legacy package; ask HQ for a repack)"; + return [ + "| paths.* | Absolute path |", + "| --- | --- |", + ...rows.map((row) => `| ${row.key} | \`${row.absolute}\`${row.exists ? "" : " (missing)"} |`) + ].join(` +`); +} + // src/netx/kb-local-path.ts -import { existsSync as existsSync2, realpathSync, statSync } from "node:fs"; -import { join as join2, relative, resolve, sep } from "node:path"; +import { existsSync as existsSync3, realpathSync, statSync as statSync2 } from "node:fs"; +import { join as join3, relative, resolve as resolve2, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", @@ -352,10 +399,7 @@ function resolveKbLocalRoot(snapshot) { const rel = snapshot.paths.local?.trim(); if (!rel) return null; - return resolve(snapshot.realRoot, rel); -} -function kbLocalToolsEnabled(snapshot) { - return resolveKbLocalRoot(snapshot) !== null; + return resolve2(snapshot.realRoot, rel); } function sanitizeSlug(raw, fallback = "entry") { const cleaned = raw.trim().replace(/[/\\]+/g, "-").replace(/\.\.+/g, "").replace(/[^\w.\u4e00-\u9fff-]+/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); @@ -407,7 +451,7 @@ function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); } - const absolutePath = resolve(localRoot, ...segments); + const absolutePath = resolve2(localRoot, ...segments); const rel = relative(localRoot, absolutePath); if (!rel || rel.startsWith("..") || rel.split(/[/\\]/).includes("..")) { throw new Error(`path escapes local root: ${absolutePath}`); @@ -426,18 +470,18 @@ function resolveExistingWritableFile(localRoot, pathArg) { const trimmed = pathArg.trim(); if (!trimmed) throw new Error("path is empty"); - const candidate = resolve(trimmed); + const candidate = resolve2(trimmed); let absolutePath; - const localResolved = resolve(localRoot); + const localResolved = resolve2(localRoot); const underLocal = candidate === localResolved || candidate.startsWith(localResolved + sep); if (underLocal) { absolutePath = candidate; } else { - absolutePath = resolve(localRoot, trimmed); + absolutePath = resolve2(localRoot, trimmed); } const checked = resolveWritableLocalPath(localRoot, ...relative(localRoot, absolutePath).split(/[/\\]/).filter(Boolean)); try { - if (existsSync2(checked.absolutePath)) { + if (existsSync3(checked.absolutePath)) { const realFile = realpathSync(checked.absolutePath); const realLocal = realpathSync(localRoot); const realRel = relative(realLocal, realFile); @@ -448,7 +492,7 @@ function resolveExistingWritableFile(localRoot, pathArg) { if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) { throw new Error(`path not under writable roots: ${realRel}`); } - if (!statSync(realFile).isFile()) { + if (!statSync2(realFile).isFile()) { throw new Error(`not a file: ${realFile}`); } return { @@ -465,16 +509,16 @@ function resolveExistingWritableFile(localRoot, pathArg) { return checked; } function memoryDir(bucket) { - return join2("memories", MEMORY_BUCKET_DIR[bucket]); + return join3("memories", MEMORY_BUCKET_DIR[bucket]); } function suggestionDir(kind) { - return join2("suggestions", kind); + return join3("suggestions", kind); } function draftDir(domain) { const d = domain?.trim(); if (!d) return "drafts"; - return join2("drafts", sanitizeSlug(d, "misc")); + return join3("drafts", sanitizeSlug(d, "misc")); } function refDir(area, device) { if (!REF_AREAS.includes(area)) { @@ -485,18 +529,63 @@ function refDir(area, device) { if (!host) { throw new Error("refs/devices requires device (host_name)"); } - return join2("refs", "devices", host); + return join3("refs", "devices", host); } - return join2("refs", area); + return join3("refs", area); } // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; +var IDENTITY_FILE_MAX = 8000; +var IDENTITY_TOTAL_MAX = 24000; +function readKbIdentity(snapshot) { + const localRoot = resolveKbLocalRoot(snapshot); + if (!localRoot) + return []; + const dir = join4(localRoot, "identity"); + let names; + try { + names = readdirSync2(dir).filter((name) => /\.md$/i.test(name) && !/^readme\.md$/i.test(name)).sort(); + } catch { + return []; + } + const out = []; + let budget = IDENTITY_TOTAL_MAX; + for (const name of names) { + if (budget <= 0) + break; + const full = join4(dir, name); + let text; + try { + if (!statSync3(full).isFile()) + continue; + text = readFileSync2(full, "utf8").replace(/^\uFEFF/, "").trim(); + } catch { + continue; + } + if (!text) + continue; + const cap = Math.min(IDENTITY_FILE_MAX, budget); + if (text.length > cap) + text = `${text.slice(0, cap)} + +…(truncated — read \`${full}\` for the rest)`; + budget -= text.length; + out.push({ name, text }); + } + return out; +} function skillBody(snapshot) { if (snapshot.status === "configured") { const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)"; const localRoot = resolveKbLocalRoot(snapshot); const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; + const identity = readKbIdentity(snapshot); + const identityBlock = identity.length > 0 ? [ + "", + "### Site identity (`identity/`, authoritative — follow over generic advice)", + ...identity.flatMap(({ name, text }) => ["", `#### ${name}`, "", text]) + ] : []; const localGuide = localRoot ? [ "", "### Dual plane (mandatory)", @@ -521,7 +610,7 @@ function skillBody(snapshot) { "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", + description: `Load FIRST before any kb-* skill or KB file access: resolved absolute paths (rca / theory / packet / neIndex / cmdLib / local …) for operator ${snapshot.operatorName} (${snapshot.country}) KB v${snapshot.version}, site identity rules, and KB write rules.`, content: [ "## Knowledge base (configured)", "", @@ -537,13 +626,22 @@ function skillBody(snapshot) { `| kbCountry | ${snapshot.country} |`, `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, + "", + "### Path anchors (resolved from MANIFEST `paths.*` — use verbatim, never compose `regions///…`)", + "", + renderKbPathTable(snapshot), + ...identityBlock, ...localGuide, "", - "Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "### Reading the HQ pack", + "- `netx__kbSearch` — keyword/regex search across rca / theory / packet / common / local (returns file + line hits).", + "- `netx__kbRead` — read one KB file (path relative to kbRoot or absolute inside it; optional line window).", + "- Start from the indexes: `neIndex` (NE → cases), `patternMap`, `classification`, then open the cited RCA.", "", - "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from", - `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, - "plus paths.localSkills when present — use those skills for KB triage;", + "Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_PATHS (JSON of the anchors above), KB_STATUS.", + "", + "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from paths.skills", + "when hasSkills is true, plus paths.localSkills when present — use those skills for KB triage;", "keep netx-ops for live netx evidence only." ].join(` `) @@ -584,15 +682,15 @@ import { defineTool } from "@deepseek-ai/dsh-tools"; // src/netx/kb-local-ops.ts import { - existsSync as existsSync3, + existsSync as existsSync4, mkdirSync, - readdirSync, - readFileSync, + readdirSync as readdirSync3, + readFileSync as readFileSync3, rmSync, - statSync as statSync2, + statSync as statSync4, writeFileSync } from "node:fs"; -import { dirname as dirname2, join as join3 } from "node:path"; +import { dirname as dirname3, join as join5 } from "node:path"; function requireLocalRoot(snapshot) { const root = resolveKbLocalRoot(snapshot); if (!root) { @@ -638,11 +736,11 @@ function createMemory(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = memoryFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, memoryDir(args.bucket), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -657,11 +755,11 @@ function createDraft(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = draftFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, draftDir(args.domain), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, ensureDraftBody(args.body), "utf8"); return { ok: true, @@ -674,11 +772,11 @@ function createSuggestion(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = suggestionFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, suggestionDir(args.kind), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -693,11 +791,11 @@ function createRef(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = refFileName(args.slug); const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -711,7 +809,7 @@ function createRef(snapshot, args) { function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); - if (!existsSync3(target.absolutePath)) { + if (!existsSync4(target.absolutePath)) { throw new Error(`file not found: ${target.relativePath}`); } const body = target.writableRoot === "drafts" ? ensureDraftBody(args.body) : args.body.endsWith(` @@ -728,7 +826,7 @@ function updateLocalFile(snapshot, args) { function deleteLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); - if (!existsSync3(target.absolutePath)) { + if (!existsSync4(target.absolutePath)) { throw new Error(`file not found: ${target.relativePath}`); } rmSync(target.absolutePath, { force: false }); @@ -742,17 +840,17 @@ function deleteLocalFile(snapshot, args) { function walkFiles(dir, baseLocal, out) { let entries; try { - entries = readdirSync(dir); + entries = readdirSync3(dir); } catch { return; } for (const name of entries) { if (name === "." || name === "..") continue; - const full = join3(dir, name); + const full = join5(dir, name); let st; try { - st = statSync2(full); + st = statSync4(full); } catch { continue; } @@ -774,7 +872,7 @@ function listLocalFiles(snapshot, args = {}) { const roots = args.root && args.root !== "all" ? [args.root] : [...KB_LOCAL_WRITABLE_ROOTS]; const entries = []; for (const r of roots) { - walkFiles(join3(localRoot, r), localRoot, entries); + walkFiles(join5(localRoot, r), localRoot, entries); } entries.sort((a, b) => b.mtimeMs - a.mtimeMs); const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); @@ -785,26 +883,240 @@ function listLocalFiles(snapshot, args = {}) { }; } -// src/netx/kb-manifest.ts -var EMPTY_CONTENT = { - hasRegions: false, - hasTheory: false, - hasPacket: false, - hasCommon: false, - hasSkills: false -}; -function unconfiguredKbSnapshot() { +// src/netx/kb-read-ops.ts +import { readdirSync as readdirSync4, readFileSync as readFileSync4, realpathSync as realpathSync2, statSync as statSync5 } from "node:fs"; +import { extname, join as join6, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path"; +var TEXT_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".json", ".yml", ".yaml", ".csv", ".ps1", ".sh", ".cfg", ".conf", ".log"]); +var SKIP_DIRS = new Set([".git", "node_modules"]); +var READ_DEFAULT_LINES = 400; +var READ_MAX_LINES = 2000; +var READ_MAX_CHARS = 80000; +var SEARCH_MAX_FILE_BYTES = 2 * 1024 * 1024; +var SEARCH_HITS_PER_FILE = 4; +var CACHE_MAX_CHARS = 64 * 1024 * 1024; +function requireRoot(snapshot) { + if (snapshot.status !== "configured" || !snapshot.realRoot) { + throw new Error("knowledge base not configured (set kbRoot to a folder with MANIFEST.json)"); + } + return snapshot.realRoot; +} +function toPosix(rel) { + return rel.split(sep2).join("/"); +} +function isInside(root, target) { + const rel = relative2(root, target); + return rel === "" || !rel.startsWith("..") && !rel.split(/[/\\]/).includes("..") && !/^[a-zA-Z]:/.test(rel); +} +function resolveKbReadPath(snapshot, pathArg) { + const root = requireRoot(snapshot); + const trimmed = pathArg.trim().replace(/^["']|["']$/g, ""); + if (trimmed.includes("\x00")) + throw new Error("path contains NUL"); + const anchor = resolveKbPaths(snapshot).find((row) => row.key === trimmed); + const candidate = anchor ? anchor.absolute : !trimmed || trimmed === "." || trimmed === "/" ? root : resolve3(root, trimmed); + if (!isInside(root, candidate)) + throw new Error(`path escapes kbRoot: ${candidate}`); + let real; + try { + real = realpathSync2(candidate); + } catch { + throw new Error(`not found in KB: ${toPosix(relative2(root, candidate)) || "."}`); + } + const realRoot = realpathSync2(root); + if (!isInside(realRoot, real)) + throw new Error(`path escapes kbRoot after realpath: ${real}`); + return { absolutePath: real, relativePath: toPosix(relative2(realRoot, real)) || "." }; +} +function readKbFile(snapshot, args) { + const target = resolveKbReadPath(snapshot, args.path); + const st = statSync5(target.absolutePath); + if (st.isDirectory()) { + const entries = readdirSync4(target.absolutePath, { withFileTypes: true }).filter((entry) => !SKIP_DIRS.has(entry.name)).map((entry) => { + if (entry.isDirectory()) + return { name: entry.name, type: "dir" }; + let size; + try { + size = statSync5(join6(target.absolutePath, entry.name)).size; + } catch {} + return { name: entry.name, type: "file", size }; + }).sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1); + return { ok: true, kind: "directory", ...target, entries }; + } + if (!TEXT_EXTENSIONS.has(extname(target.absolutePath).toLowerCase())) { + throw new Error(`not a text KB file: ${target.relativePath}`); + } + const lines = readFileSync4(target.absolutePath, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, ` +`).split(` +`); + const start = Math.max(1, Math.floor(args.offset ?? 1)); + const limit = Math.min(Math.max(1, Math.floor(args.limit ?? READ_DEFAULT_LINES)), READ_MAX_LINES); + let slice = lines.slice(start - 1, start - 1 + limit); + let text = slice.join(` +`); + let truncated = start - 1 + slice.length < lines.length; + if (text.length > READ_MAX_CHARS) { + let used = 0; + let keep = 0; + for (const line of slice) { + if (used + line.length + 1 > READ_MAX_CHARS) + break; + used += line.length + 1; + keep += 1; + } + slice = slice.slice(0, Math.max(keep, 1)); + text = slice.join(` +`).slice(0, READ_MAX_CHARS); + truncated = true; + } return { - status: "unconfigured", - realRoot: "", - operatorName: "", - country: "", - version: "", - content: { ...EMPTY_CONTENT }, - paths: {}, - errorMessage: "" + ok: true, + kind: "file", + ...target, + totalLines: lines.length, + startLine: start, + endLine: start - 1 + slice.length, + truncated, + text }; } +var CACHE_KEY = Symbol.for("dsh-netxops.kb-search-cache"); +function searchCache() { + const root = globalThis; + root[CACHE_KEY] ??= { files: new Map, chars: 0 }; + return root[CACHE_KEY]; +} +function cachedText(path, mtimeMs, size) { + const cache = searchCache(); + const hit = cache.files.get(path); + if (hit && hit.mtimeMs === mtimeMs && hit.size === size) + return hit; + const text = readFileSync4(path, "utf8").replace(/^\uFEFF/, ""); + if (hit) + cache.chars -= hit.text.length; + if (cache.chars + text.length > CACHE_MAX_CHARS) { + cache.files.clear(); + cache.chars = 0; + } + const entry = { mtimeMs, size, text, lower: text.toLowerCase() }; + cache.files.set(path, entry); + cache.chars += text.length; + return entry; +} +function walkTextFiles(dir, out) { + let entries; + try { + entries = readdirSync4(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) + continue; + const full = join6(dir, entry.name); + if (entry.isDirectory()) { + walkTextFiles(full, out); + continue; + } + if (!entry.isFile() || !TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) + continue; + try { + const st = statSync5(full); + if (st.size <= SEARCH_MAX_FILE_BYTES) + out.push({ path: full, mtimeMs: st.mtimeMs, size: st.size }); + } catch {} + } +} +function searchKb(snapshot, args) { + const root = requireRoot(snapshot); + const query = args.query.trim(); + if (!query) + throw new Error("query is empty"); + const scopeArg = args.scope?.trim() || "all"; + const scopes = scopeArg.split(/[,\s]+/).filter(Boolean); + const dirs = scopes.includes("all") ? [root] : scopes.map((scope) => resolveKbReadPath(snapshot, scope).absolutePath); + let matchers; + if (args.regex) { + let re; + try { + re = new RegExp(query, "i"); + } catch (error) { + throw new Error(`invalid regex: ${error instanceof Error ? error.message : String(error)}`); + } + matchers = [{ term: query, test: (_lower, raw) => re.test(raw) }]; + } else { + const terms = [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))]; + matchers = terms.map((term) => ({ term, test: (lower) => lower.includes(term) })); + } + const files = []; + const seen = new Set; + for (const dir of dirs) { + const st = statSync5(dir); + const batch = []; + if (st.isFile()) + batch.push({ path: dir, mtimeMs: st.mtimeMs, size: st.size }); + else + walkTextFiles(dir, batch); + for (const file of batch) { + if (seen.has(file.path)) + continue; + seen.add(file.path); + files.push(file); + } + } + const hits = []; + for (const file of files) { + let cached; + try { + cached = cachedText(file.path, file.mtimeMs, file.size); + } catch { + continue; + } + const rel = toPosix(relative2(root, file.path)); + const matched = new Set; + let score = 0; + for (const m of matchers) { + if (m.test(rel.toLowerCase(), rel)) { + matched.add(m.term); + score += 3; + } + } + const inBody = matchers.filter((m) => m.test(cached.lower, cached.text)); + if (inBody.length === 0 && matched.size === 0) + continue; + const lines = []; + const rows = inBody.length > 0 ? cached.text.split(/\r\n?|\n/) : []; + let lineHits = 0; + for (let i = 0;i < rows.length; i += 1) { + const row = rows[i]; + const lower = row.toLowerCase(); + let lineHit = false; + for (const m of inBody) { + if (m.test(lower, row)) { + matched.add(m.term); + lineHit = true; + } + } + if (!lineHit) + continue; + lineHits += 1; + if (lines.length < SEARCH_HITS_PER_FILE) { + const trimmed = row.trim(); + lines.push({ line: i + 1, text: trimmed.length > 240 ? `${trimmed.slice(0, 240)}…` : trimmed }); + } + } + score += matched.size * 10 + Math.min(lineHits, 30) * 0.1; + hits.push({ + relativePath: rel, + absolutePath: file.path, + score: Math.round(score * 10) / 10, + matchedTerms: [...matched], + lines + }); + } + hits.sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath)); + const limit = Math.min(Math.max(Math.floor(args.limit ?? 20), 1), 100); + return { ok: true, scope: scopeArg, scannedFiles: files.length, totalMatches: hits.length, hits: hits.slice(0, limit) }; +} // src/netx/kb-runtime.ts var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); @@ -862,7 +1174,7 @@ function omitUndefined(record) { // src/netx/tool-mount-status.ts import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs"; import { homedir } from "node:os"; -import { dirname as dirname3, join as join4 } from "node:path"; +import { dirname as dirname4, join as join7 } from "node:path"; var KEY = Symbol.for("dsh-netxops.mount-inventory"); function store2() { const root = globalThis; @@ -886,8 +1198,8 @@ function getToolMountStatus() { } function persist() { try { - const path = join4(process.env.DSH_HOME?.trim() || join4(homedir(), ".dsh"), "netxops-mount-status.json"); - mkdirSync2(dirname3(path), { recursive: true }); + const path = join7(process.env.DSH_HOME?.trim() || join7(homedir(), ".dsh"), "netxops-mount-status.json"); + mkdirSync2(dirname4(path), { recursive: true }); writeFileSync2(path, `${JSON.stringify({ mount: getToolMountStatus() ?? null, mounts: getToolMountStatuses() }, null, 2)} `, "utf8"); } catch {} @@ -937,14 +1249,8 @@ function tool(name, description, parameters, execute) { } }); } -function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { - if (!kbLocalToolsEnabled(snapshot)) - return () => {}; - const localRoot = resolveKbLocalRoot(snapshot); - const toolsApi = ctx.tools; - if (!toolsApi || typeof toolsApi.register !== "function") - return () => {}; - const entries = [ +function writeTools(localRoot) { + return [ tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", { bucket: { type: "string", @@ -1024,7 +1330,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents with netx__kbRead using the listed paths.", { root: { type: "string", enum: ["memories", "drafts", "suggestions", "refs", "all"], @@ -1036,6 +1342,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { limit: typeof args.limit === "number" ? args.limit : undefined })) ]; +} +function readTools(snapshot) { + return [ + tool("netx__kbSearch", `Search the operator knowledge base at ${snapshot.realRoot} (read-only HQ pack + _local). ` + "Plain mode: space-separated keywords (NE host_name, alarm, protocol, symptom…), case-insensitive; " + "files ranked by how many distinct terms match (filename hits weigh more), up to 4 hit lines each. " + "Then open the best hits with netx__kbRead.", { + query: reqStr("Keywords (space-separated) or a regex when regex=true"), + scope: str("MANIFEST paths key(s) — e.g. rca, theory, theoryVendor, packet, commonPatterns, local, neIndex — or a relative dir; comma-separate several; default all"), + regex: bool("Treat query as one case-insensitive JS regex"), + limit: num("Max files 1–100 (default 20)") + }, (args) => searchKb(liveSnapshot(), { + query: String(args.query ?? ""), + scope: args.scope != null ? String(args.scope) : undefined, + regex: args.regex === true, + limit: typeof args.limit === "number" ? args.limit : undefined + })), + tool("netx__kbRead", `Read one file (or list one directory) inside the knowledge base at ${snapshot.realRoot}. ` + "path: relative to kbRoot, absolute inside it, or a MANIFEST paths key (rca, neIndex, cmdLib, patternMap, …). " + "Long files: page with offset/limit (1-based lines; default 400).", { + path: reqStr("KB-relative path, absolute path inside kbRoot, or a paths.* key"), + offset: num("First line (1-based, default 1)"), + limit: num("Max lines 1–2000 (default 400)") + }, (args) => readKbFile(liveSnapshot(), { + path: String(args.path ?? ""), + offset: typeof args.offset === "number" ? args.offset : undefined, + limit: typeof args.limit === "number" ? args.limit : undefined + })) + ]; +} +function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { + if (snapshot.status !== "configured") + return () => {}; + const localRoot = resolveKbLocalRoot(snapshot); + const toolsApi = ctx.tools; + if (!toolsApi || typeof toolsApi.register !== "function") + return () => {}; + const entries = [...readTools(snapshot), ...localRoot ? writeTools(localRoot) : []]; const disposers = []; const source = options.source ?? "netxops-kb-local"; const id = getToolMountId(ctx, source); @@ -1057,7 +1396,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { publishToolMountStatus({ ...status, names: [], count: 0, error: String(error) }); throw error; } - ctx.logger.info("netxops: kb local tools registered count=%s localRoot=%s", disposers.length, localRoot); + ctx.logger.info("netxops: kb tools registered count=%s kbRoot=%s localRoot=%s", disposers.length, snapshot.realRoot, localRoot ?? "(none — read-only)"); return () => { for (const dispose of disposers) dispose(); @@ -1067,19 +1406,35 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { // src/netx/kb-pack-skills.ts import { readdir as readdir2, stat as stat2 } from "node:fs/promises"; -import { join as join5 } from "node:path"; +import { join as join8 } from "node:path"; function kbPackSkillsEnabled(snapshot) { return snapshot.status === "configured" && snapshot.content.hasSkills === true; } function resolveKbSkillsRoot(snapshot) { const rel = snapshot.paths.skills?.trim() || "_skills"; - return join5(snapshot.realRoot, rel); + return join8(snapshot.realRoot, rel); +} +function kbPackSkillFooter(snapshot) { + return [ + "", + "", + `## Injected KB anchors (netxops · ${snapshot.operatorName} / ${snapshot.country} v${snapshot.version})`, + "", + `kbStatus=configured · kbRoot=\`${snapshot.realRoot}\``, + "", + renderKbPathTable(snapshot), + "", + "These are already resolved from `MANIFEST.json` `paths.*`; use them verbatim.", + "Read/search the pack with `netx__kbRead` / `netx__kbSearch` when present; write only via `netx__kbWrite*`.", + "" + ].join(` +`); } function resolveKbLocalSkillsRoot(snapshot) { const rel = snapshot.paths.localSkills?.trim(); if (!rel) return null; - return join5(snapshot.realRoot, rel); + return join8(snapshot.realRoot, rel); } async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; @@ -1098,7 +1453,7 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) for (const entry of entries) { if (options.signal?.aborted) break; - const full = join5(root, entry); + const full = join8(root, entry); let isDir = false; try { isDir = (await stat2(full)).isDirectory(); @@ -1120,15 +1475,24 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) } if (options.signal?.aborted) break; - disposers.push(skillsApi.register({ - name: skill.name, - description: skill.description, - content: skill.content, - path: skill.path, - resourceBase: { kind: "directory", path: skill.directory }, - provider, - source: "custom" - })); + if (options.seen.has(skill.name)) { + ctx.logger.warn("netxops: kb %s skill %s duplicates an already registered pack skill — skip %s", options.label, skill.name, full); + continue; + } + try { + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: `${skill.content.trimEnd()}${options.footer}`, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider, + source: "custom" + })); + options.seen.add(skill.name); + } catch (error) { + ctx.logger.warn("netxops: kb %s skill %s register failed: %s", options.label, skill.name, error instanceof Error ? error.message : String(error)); + } } } catch (error) { for (const dispose of disposers.splice(0)) @@ -1149,17 +1513,23 @@ async function registerKbPackSkills(ctx, snapshot, options) { const disposers = []; const skillsRoot = resolveKbSkillsRoot(snapshot); const localRoot = resolveKbLocalSkillsRoot(snapshot); + const footer = kbPackSkillFooter(snapshot); + const seen = new Set; try { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { logMissing: true, label: "pack", - signal: options.signal + signal: options.signal, + footer, + seen })); if (localRoot) { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, { logMissing: false, label: "local", - signal: options.signal + signal: options.signal, + footer, + seen })); } } catch (error) { @@ -1361,8 +1731,8 @@ function watchNetxConnection(listener) { } // src/netx/tools.ts -import { readFileSync as readFileSync2 } from "node:fs"; -import { dirname as dirname4, join as join6 } from "node:path"; +import { readFileSync as readFileSync5 } from "node:fs"; +import { dirname as dirname5, join as join9 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; @@ -2804,8 +3174,8 @@ async function layoutTopologyView(client, args, signal) { // src/netx/tools.ts function packageVersion() { try { - const here = dirname4(fileURLToPath2(import.meta.url)); - const pkg = JSON.parse(readFileSync2(join6(here, "..", "package.json"), "utf8")); + const here = dirname5(fileURLToPath2(import.meta.url)); + const pkg = JSON.parse(readFileSync5(join9(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; @@ -3276,8 +3646,8 @@ function registerNetxTools(ctx, connection, options) { // src/netx/group-tools-plugin.ts function packageVersion2() { try { - const here = dirname5(fileURLToPath3(import.meta.url)); - const pkg = JSON.parse(readFileSync3(join7(here, "..", "package.json"), "utf8")); + const here = dirname6(fileURLToPath3(import.meta.url)); + const pkg = JSON.parse(readFileSync6(join10(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; diff --git a/lib/agent-tools.js b/lib/agent-tools.js index 874780a..ef6d689 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -1,6 +1,6 @@ // src/netx/group-tools-plugin.ts -import { readFileSync as readFileSync3 } from "node:fs"; -import { dirname as dirname5, join as join7 } from "node:path"; +import { readFileSync as readFileSync6 } from "node:fs"; +import { dirname as dirname6, join as join10 } from "node:path"; import { fileURLToPath as fileURLToPath3 } from "node:url"; // src/netx/config-values.ts @@ -324,9 +324,56 @@ Use only tools present in this session. Native layout supports catalog and move_ }; } +// src/netx/kb-context-skill.ts +import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync3 } from "node:fs"; +import { join as join4 } from "node:path"; + +// src/netx/kb-manifest.ts +import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "node:fs"; +import { basename, dirname as dirname2, join as join2, resolve } from "node:path"; +var EMPTY_CONTENT = { + hasRegions: false, + hasTheory: false, + hasPacket: false, + hasCommon: false, + hasSkills: false +}; +function unconfiguredKbSnapshot() { + return { + status: "unconfigured", + realRoot: "", + operatorName: "", + country: "", + version: "", + content: { ...EMPTY_CONTENT }, + paths: {}, + errorMessage: "" + }; +} +function resolveKbPaths(snapshot) { + if (snapshot.status !== "configured") + return []; + const paths = { skills: "_skills", ...snapshot.paths }; + return Object.entries(paths).filter((entry) => typeof entry[1] === "string" && entry[1].trim() !== "").map(([key, rel]) => { + const absolute = resolve(snapshot.realRoot, rel); + return { key, relative: rel, absolute, exists: existsSync2(absolute) }; + }); +} +function renderKbPathTable(snapshot) { + const rows = resolveKbPaths(snapshot); + if (rows.length === 0) + return "(MANIFEST has no paths — legacy package; ask HQ for a repack)"; + return [ + "| paths.* | Absolute path |", + "| --- | --- |", + ...rows.map((row) => `| ${row.key} | \`${row.absolute}\`${row.exists ? "" : " (missing)"} |`) + ].join(` +`); +} + // src/netx/kb-local-path.ts -import { existsSync as existsSync2, realpathSync, statSync } from "node:fs"; -import { join as join2, relative, resolve, sep } from "node:path"; +import { existsSync as existsSync3, realpathSync, statSync as statSync2 } from "node:fs"; +import { join as join3, relative, resolve as resolve2, sep } from "node:path"; var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ "memories", "drafts", @@ -352,10 +399,7 @@ function resolveKbLocalRoot(snapshot) { const rel = snapshot.paths.local?.trim(); if (!rel) return null; - return resolve(snapshot.realRoot, rel); -} -function kbLocalToolsEnabled(snapshot) { - return resolveKbLocalRoot(snapshot) !== null; + return resolve2(snapshot.realRoot, rel); } function sanitizeSlug(raw, fallback = "entry") { const cleaned = raw.trim().replace(/[/\\]+/g, "-").replace(/\.\.+/g, "").replace(/[^\w.\u4e00-\u9fff-]+/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); @@ -407,7 +451,7 @@ function resolveWritableLocalPath(localRoot, ...segments) { if (segments.some((s) => s.includes("\x00"))) { throw new Error("path segment contains NUL"); } - const absolutePath = resolve(localRoot, ...segments); + const absolutePath = resolve2(localRoot, ...segments); const rel = relative(localRoot, absolutePath); if (!rel || rel.startsWith("..") || rel.split(/[/\\]/).includes("..")) { throw new Error(`path escapes local root: ${absolutePath}`); @@ -426,18 +470,18 @@ function resolveExistingWritableFile(localRoot, pathArg) { const trimmed = pathArg.trim(); if (!trimmed) throw new Error("path is empty"); - const candidate = resolve(trimmed); + const candidate = resolve2(trimmed); let absolutePath; - const localResolved = resolve(localRoot); + const localResolved = resolve2(localRoot); const underLocal = candidate === localResolved || candidate.startsWith(localResolved + sep); if (underLocal) { absolutePath = candidate; } else { - absolutePath = resolve(localRoot, trimmed); + absolutePath = resolve2(localRoot, trimmed); } const checked = resolveWritableLocalPath(localRoot, ...relative(localRoot, absolutePath).split(/[/\\]/).filter(Boolean)); try { - if (existsSync2(checked.absolutePath)) { + if (existsSync3(checked.absolutePath)) { const realFile = realpathSync(checked.absolutePath); const realLocal = realpathSync(localRoot); const realRel = relative(realLocal, realFile); @@ -448,7 +492,7 @@ function resolveExistingWritableFile(localRoot, pathArg) { if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) { throw new Error(`path not under writable roots: ${realRel}`); } - if (!statSync(realFile).isFile()) { + if (!statSync2(realFile).isFile()) { throw new Error(`not a file: ${realFile}`); } return { @@ -465,16 +509,16 @@ function resolveExistingWritableFile(localRoot, pathArg) { return checked; } function memoryDir(bucket) { - return join2("memories", MEMORY_BUCKET_DIR[bucket]); + return join3("memories", MEMORY_BUCKET_DIR[bucket]); } function suggestionDir(kind) { - return join2("suggestions", kind); + return join3("suggestions", kind); } function draftDir(domain) { const d = domain?.trim(); if (!d) return "drafts"; - return join2("drafts", sanitizeSlug(d, "misc")); + return join3("drafts", sanitizeSlug(d, "misc")); } function refDir(area, device) { if (!REF_AREAS.includes(area)) { @@ -485,18 +529,63 @@ function refDir(area, device) { if (!host) { throw new Error("refs/devices requires device (host_name)"); } - return join2("refs", "devices", host); + return join3("refs", "devices", host); } - return join2("refs", area); + return join3("refs", area); } // src/netx/kb-context-skill.ts var SKILL_NAME = "kb-context"; +var IDENTITY_FILE_MAX = 8000; +var IDENTITY_TOTAL_MAX = 24000; +function readKbIdentity(snapshot) { + const localRoot = resolveKbLocalRoot(snapshot); + if (!localRoot) + return []; + const dir = join4(localRoot, "identity"); + let names; + try { + names = readdirSync2(dir).filter((name) => /\.md$/i.test(name) && !/^readme\.md$/i.test(name)).sort(); + } catch { + return []; + } + const out = []; + let budget = IDENTITY_TOTAL_MAX; + for (const name of names) { + if (budget <= 0) + break; + const full = join4(dir, name); + let text; + try { + if (!statSync3(full).isFile()) + continue; + text = readFileSync2(full, "utf8").replace(/^\uFEFF/, "").trim(); + } catch { + continue; + } + if (!text) + continue; + const cap = Math.min(IDENTITY_FILE_MAX, budget); + if (text.length > cap) + text = `${text.slice(0, cap)} + +…(truncated — read \`${full}\` for the rest)`; + budget -= text.length; + out.push({ name, text }); + } + return out; +} function skillBody(snapshot) { if (snapshot.status === "configured") { const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)"; const localRoot = resolveKbLocalRoot(snapshot); const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; + const identity = readKbIdentity(snapshot); + const identityBlock = identity.length > 0 ? [ + "", + "### Site identity (`identity/`, authoritative — follow over generic advice)", + ...identity.flatMap(({ name, text }) => ["", `#### ${name}`, "", text]) + ] : []; const localGuide = localRoot ? [ "", "### Dual plane (mandatory)", @@ -521,7 +610,7 @@ function skillBody(snapshot) { "No local write tools until `paths.local` is present in MANIFEST." ]; return { - description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", + description: `Load FIRST before any kb-* skill or KB file access: resolved absolute paths (rca / theory / packet / neIndex / cmdLib / local …) for operator ${snapshot.operatorName} (${snapshot.country}) KB v${snapshot.version}, site identity rules, and KB write rules.`, content: [ "## Knowledge base (configured)", "", @@ -537,13 +626,22 @@ function skillBody(snapshot) { `| kbCountry | ${snapshot.country} |`, `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, + "", + "### Path anchors (resolved from MANIFEST `paths.*` — use verbatim, never compose `regions///…`)", + "", + renderKbPathTable(snapshot), + ...identityBlock, ...localGuide, "", - "Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", + "### Reading the HQ pack", + "- `netx__kbSearch` — keyword/regex search across rca / theory / packet / common / local (returns file + line hits).", + "- `netx__kbRead` — read one KB file (path relative to kbRoot or absolute inside it; optional line window).", + "- Start from the indexes: `neIndex` (NE → cases), `patternMap`, `classification`, then open the cited RCA.", "", - "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from", - `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, - "plus paths.localSkills when present — use those skills for KB triage;", + "Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_PATHS (JSON of the anchors above), KB_STATUS.", + "", + "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from paths.skills", + "when hasSkills is true, plus paths.localSkills when present — use those skills for KB triage;", "keep netx-ops for live netx evidence only." ].join(` `) @@ -584,15 +682,15 @@ import { defineTool } from "@deepseek-ai/dsh-tools"; // src/netx/kb-local-ops.ts import { - existsSync as existsSync3, + existsSync as existsSync4, mkdirSync, - readdirSync, - readFileSync, + readdirSync as readdirSync3, + readFileSync as readFileSync3, rmSync, - statSync as statSync2, + statSync as statSync4, writeFileSync } from "node:fs"; -import { dirname as dirname2, join as join3 } from "node:path"; +import { dirname as dirname3, join as join5 } from "node:path"; function requireLocalRoot(snapshot) { const root = resolveKbLocalRoot(snapshot); if (!root) { @@ -638,11 +736,11 @@ function createMemory(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = memoryFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, memoryDir(args.bucket), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -657,11 +755,11 @@ function createDraft(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = draftFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, draftDir(args.domain), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, ensureDraftBody(args.body), "utf8"); return { ok: true, @@ -674,11 +772,11 @@ function createSuggestion(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = suggestionFileName({ date: args.date, slug: args.slug }); const target = resolveWritableLocalPath(localRoot, suggestionDir(args.kind), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -693,11 +791,11 @@ function createRef(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const name = refFileName(args.slug); const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); - if (existsSync3(target.absolutePath) && !args.overwrite) { + if (existsSync4(target.absolutePath) && !args.overwrite) { throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); } - mkdirSync(dirname2(target.absolutePath), { recursive: true }); - const existed = existsSync3(target.absolutePath); + mkdirSync(dirname3(target.absolutePath), { recursive: true }); + const existed = existsSync4(target.absolutePath); writeFileSync(target.absolutePath, args.body.endsWith(` `) ? args.body : `${args.body} `, "utf8"); @@ -711,7 +809,7 @@ function createRef(snapshot, args) { function updateLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); - if (!existsSync3(target.absolutePath)) { + if (!existsSync4(target.absolutePath)) { throw new Error(`file not found: ${target.relativePath}`); } const body = target.writableRoot === "drafts" ? ensureDraftBody(args.body) : args.body.endsWith(` @@ -728,7 +826,7 @@ function updateLocalFile(snapshot, args) { function deleteLocalFile(snapshot, args) { const localRoot = requireLocalRoot(snapshot); const target = resolveExistingWritableFile(localRoot, args.path); - if (!existsSync3(target.absolutePath)) { + if (!existsSync4(target.absolutePath)) { throw new Error(`file not found: ${target.relativePath}`); } rmSync(target.absolutePath, { force: false }); @@ -742,17 +840,17 @@ function deleteLocalFile(snapshot, args) { function walkFiles(dir, baseLocal, out) { let entries; try { - entries = readdirSync(dir); + entries = readdirSync3(dir); } catch { return; } for (const name of entries) { if (name === "." || name === "..") continue; - const full = join3(dir, name); + const full = join5(dir, name); let st; try { - st = statSync2(full); + st = statSync4(full); } catch { continue; } @@ -774,7 +872,7 @@ function listLocalFiles(snapshot, args = {}) { const roots = args.root && args.root !== "all" ? [args.root] : [...KB_LOCAL_WRITABLE_ROOTS]; const entries = []; for (const r of roots) { - walkFiles(join3(localRoot, r), localRoot, entries); + walkFiles(join5(localRoot, r), localRoot, entries); } entries.sort((a, b) => b.mtimeMs - a.mtimeMs); const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); @@ -785,26 +883,240 @@ function listLocalFiles(snapshot, args = {}) { }; } -// src/netx/kb-manifest.ts -var EMPTY_CONTENT = { - hasRegions: false, - hasTheory: false, - hasPacket: false, - hasCommon: false, - hasSkills: false -}; -function unconfiguredKbSnapshot() { +// src/netx/kb-read-ops.ts +import { readdirSync as readdirSync4, readFileSync as readFileSync4, realpathSync as realpathSync2, statSync as statSync5 } from "node:fs"; +import { extname, join as join6, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path"; +var TEXT_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".json", ".yml", ".yaml", ".csv", ".ps1", ".sh", ".cfg", ".conf", ".log"]); +var SKIP_DIRS = new Set([".git", "node_modules"]); +var READ_DEFAULT_LINES = 400; +var READ_MAX_LINES = 2000; +var READ_MAX_CHARS = 80000; +var SEARCH_MAX_FILE_BYTES = 2 * 1024 * 1024; +var SEARCH_HITS_PER_FILE = 4; +var CACHE_MAX_CHARS = 64 * 1024 * 1024; +function requireRoot(snapshot) { + if (snapshot.status !== "configured" || !snapshot.realRoot) { + throw new Error("knowledge base not configured (set kbRoot to a folder with MANIFEST.json)"); + } + return snapshot.realRoot; +} +function toPosix(rel) { + return rel.split(sep2).join("/"); +} +function isInside(root, target) { + const rel = relative2(root, target); + return rel === "" || !rel.startsWith("..") && !rel.split(/[/\\]/).includes("..") && !/^[a-zA-Z]:/.test(rel); +} +function resolveKbReadPath(snapshot, pathArg) { + const root = requireRoot(snapshot); + const trimmed = pathArg.trim().replace(/^["']|["']$/g, ""); + if (trimmed.includes("\x00")) + throw new Error("path contains NUL"); + const anchor = resolveKbPaths(snapshot).find((row) => row.key === trimmed); + const candidate = anchor ? anchor.absolute : !trimmed || trimmed === "." || trimmed === "/" ? root : resolve3(root, trimmed); + if (!isInside(root, candidate)) + throw new Error(`path escapes kbRoot: ${candidate}`); + let real; + try { + real = realpathSync2(candidate); + } catch { + throw new Error(`not found in KB: ${toPosix(relative2(root, candidate)) || "."}`); + } + const realRoot = realpathSync2(root); + if (!isInside(realRoot, real)) + throw new Error(`path escapes kbRoot after realpath: ${real}`); + return { absolutePath: real, relativePath: toPosix(relative2(realRoot, real)) || "." }; +} +function readKbFile(snapshot, args) { + const target = resolveKbReadPath(snapshot, args.path); + const st = statSync5(target.absolutePath); + if (st.isDirectory()) { + const entries = readdirSync4(target.absolutePath, { withFileTypes: true }).filter((entry) => !SKIP_DIRS.has(entry.name)).map((entry) => { + if (entry.isDirectory()) + return { name: entry.name, type: "dir" }; + let size; + try { + size = statSync5(join6(target.absolutePath, entry.name)).size; + } catch {} + return { name: entry.name, type: "file", size }; + }).sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1); + return { ok: true, kind: "directory", ...target, entries }; + } + if (!TEXT_EXTENSIONS.has(extname(target.absolutePath).toLowerCase())) { + throw new Error(`not a text KB file: ${target.relativePath}`); + } + const lines = readFileSync4(target.absolutePath, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, ` +`).split(` +`); + const start = Math.max(1, Math.floor(args.offset ?? 1)); + const limit = Math.min(Math.max(1, Math.floor(args.limit ?? READ_DEFAULT_LINES)), READ_MAX_LINES); + let slice = lines.slice(start - 1, start - 1 + limit); + let text = slice.join(` +`); + let truncated = start - 1 + slice.length < lines.length; + if (text.length > READ_MAX_CHARS) { + let used = 0; + let keep = 0; + for (const line of slice) { + if (used + line.length + 1 > READ_MAX_CHARS) + break; + used += line.length + 1; + keep += 1; + } + slice = slice.slice(0, Math.max(keep, 1)); + text = slice.join(` +`).slice(0, READ_MAX_CHARS); + truncated = true; + } return { - status: "unconfigured", - realRoot: "", - operatorName: "", - country: "", - version: "", - content: { ...EMPTY_CONTENT }, - paths: {}, - errorMessage: "" + ok: true, + kind: "file", + ...target, + totalLines: lines.length, + startLine: start, + endLine: start - 1 + slice.length, + truncated, + text }; } +var CACHE_KEY = Symbol.for("dsh-netxops.kb-search-cache"); +function searchCache() { + const root = globalThis; + root[CACHE_KEY] ??= { files: new Map, chars: 0 }; + return root[CACHE_KEY]; +} +function cachedText(path, mtimeMs, size) { + const cache = searchCache(); + const hit = cache.files.get(path); + if (hit && hit.mtimeMs === mtimeMs && hit.size === size) + return hit; + const text = readFileSync4(path, "utf8").replace(/^\uFEFF/, ""); + if (hit) + cache.chars -= hit.text.length; + if (cache.chars + text.length > CACHE_MAX_CHARS) { + cache.files.clear(); + cache.chars = 0; + } + const entry = { mtimeMs, size, text, lower: text.toLowerCase() }; + cache.files.set(path, entry); + cache.chars += text.length; + return entry; +} +function walkTextFiles(dir, out) { + let entries; + try { + entries = readdirSync4(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) + continue; + const full = join6(dir, entry.name); + if (entry.isDirectory()) { + walkTextFiles(full, out); + continue; + } + if (!entry.isFile() || !TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) + continue; + try { + const st = statSync5(full); + if (st.size <= SEARCH_MAX_FILE_BYTES) + out.push({ path: full, mtimeMs: st.mtimeMs, size: st.size }); + } catch {} + } +} +function searchKb(snapshot, args) { + const root = requireRoot(snapshot); + const query = args.query.trim(); + if (!query) + throw new Error("query is empty"); + const scopeArg = args.scope?.trim() || "all"; + const scopes = scopeArg.split(/[,\s]+/).filter(Boolean); + const dirs = scopes.includes("all") ? [root] : scopes.map((scope) => resolveKbReadPath(snapshot, scope).absolutePath); + let matchers; + if (args.regex) { + let re; + try { + re = new RegExp(query, "i"); + } catch (error) { + throw new Error(`invalid regex: ${error instanceof Error ? error.message : String(error)}`); + } + matchers = [{ term: query, test: (_lower, raw) => re.test(raw) }]; + } else { + const terms = [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))]; + matchers = terms.map((term) => ({ term, test: (lower) => lower.includes(term) })); + } + const files = []; + const seen = new Set; + for (const dir of dirs) { + const st = statSync5(dir); + const batch = []; + if (st.isFile()) + batch.push({ path: dir, mtimeMs: st.mtimeMs, size: st.size }); + else + walkTextFiles(dir, batch); + for (const file of batch) { + if (seen.has(file.path)) + continue; + seen.add(file.path); + files.push(file); + } + } + const hits = []; + for (const file of files) { + let cached; + try { + cached = cachedText(file.path, file.mtimeMs, file.size); + } catch { + continue; + } + const rel = toPosix(relative2(root, file.path)); + const matched = new Set; + let score = 0; + for (const m of matchers) { + if (m.test(rel.toLowerCase(), rel)) { + matched.add(m.term); + score += 3; + } + } + const inBody = matchers.filter((m) => m.test(cached.lower, cached.text)); + if (inBody.length === 0 && matched.size === 0) + continue; + const lines = []; + const rows = inBody.length > 0 ? cached.text.split(/\r\n?|\n/) : []; + let lineHits = 0; + for (let i = 0;i < rows.length; i += 1) { + const row = rows[i]; + const lower = row.toLowerCase(); + let lineHit = false; + for (const m of inBody) { + if (m.test(lower, row)) { + matched.add(m.term); + lineHit = true; + } + } + if (!lineHit) + continue; + lineHits += 1; + if (lines.length < SEARCH_HITS_PER_FILE) { + const trimmed = row.trim(); + lines.push({ line: i + 1, text: trimmed.length > 240 ? `${trimmed.slice(0, 240)}…` : trimmed }); + } + } + score += matched.size * 10 + Math.min(lineHits, 30) * 0.1; + hits.push({ + relativePath: rel, + absolutePath: file.path, + score: Math.round(score * 10) / 10, + matchedTerms: [...matched], + lines + }); + } + hits.sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath)); + const limit = Math.min(Math.max(Math.floor(args.limit ?? 20), 1), 100); + return { ok: true, scope: scopeArg, scannedFiles: files.length, totalMatches: hits.length, hits: hits.slice(0, limit) }; +} // src/netx/kb-runtime.ts var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); @@ -862,7 +1174,7 @@ function omitUndefined(record) { // src/netx/tool-mount-status.ts import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs"; import { homedir } from "node:os"; -import { dirname as dirname3, join as join4 } from "node:path"; +import { dirname as dirname4, join as join7 } from "node:path"; var KEY = Symbol.for("dsh-netxops.mount-inventory"); function store2() { const root = globalThis; @@ -886,8 +1198,8 @@ function getToolMountStatus() { } function persist() { try { - const path = join4(process.env.DSH_HOME?.trim() || join4(homedir(), ".dsh"), "netxops-mount-status.json"); - mkdirSync2(dirname3(path), { recursive: true }); + const path = join7(process.env.DSH_HOME?.trim() || join7(homedir(), ".dsh"), "netxops-mount-status.json"); + mkdirSync2(dirname4(path), { recursive: true }); writeFileSync2(path, `${JSON.stringify({ mount: getToolMountStatus() ?? null, mounts: getToolMountStatuses() }, null, 2)} `, "utf8"); } catch {} @@ -937,14 +1249,8 @@ function tool(name, description, parameters, execute) { } }); } -function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { - if (!kbLocalToolsEnabled(snapshot)) - return () => {}; - const localRoot = resolveKbLocalRoot(snapshot); - const toolsApi = ctx.tools; - if (!toolsApi || typeof toolsApi.register !== "function") - return () => {}; - const entries = [ +function writeTools(localRoot) { + return [ tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", { bucket: { type: "string", @@ -1024,7 +1330,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents with netx__kbRead using the listed paths.", { root: { type: "string", enum: ["memories", "drafts", "suggestions", "refs", "all"], @@ -1036,6 +1342,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { limit: typeof args.limit === "number" ? args.limit : undefined })) ]; +} +function readTools(snapshot) { + return [ + tool("netx__kbSearch", `Search the operator knowledge base at ${snapshot.realRoot} (read-only HQ pack + _local). ` + "Plain mode: space-separated keywords (NE host_name, alarm, protocol, symptom…), case-insensitive; " + "files ranked by how many distinct terms match (filename hits weigh more), up to 4 hit lines each. " + "Then open the best hits with netx__kbRead.", { + query: reqStr("Keywords (space-separated) or a regex when regex=true"), + scope: str("MANIFEST paths key(s) — e.g. rca, theory, theoryVendor, packet, commonPatterns, local, neIndex — or a relative dir; comma-separate several; default all"), + regex: bool("Treat query as one case-insensitive JS regex"), + limit: num("Max files 1–100 (default 20)") + }, (args) => searchKb(liveSnapshot(), { + query: String(args.query ?? ""), + scope: args.scope != null ? String(args.scope) : undefined, + regex: args.regex === true, + limit: typeof args.limit === "number" ? args.limit : undefined + })), + tool("netx__kbRead", `Read one file (or list one directory) inside the knowledge base at ${snapshot.realRoot}. ` + "path: relative to kbRoot, absolute inside it, or a MANIFEST paths key (rca, neIndex, cmdLib, patternMap, …). " + "Long files: page with offset/limit (1-based lines; default 400).", { + path: reqStr("KB-relative path, absolute path inside kbRoot, or a paths.* key"), + offset: num("First line (1-based, default 1)"), + limit: num("Max lines 1–2000 (default 400)") + }, (args) => readKbFile(liveSnapshot(), { + path: String(args.path ?? ""), + offset: typeof args.offset === "number" ? args.offset : undefined, + limit: typeof args.limit === "number" ? args.limit : undefined + })) + ]; +} +function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { + if (snapshot.status !== "configured") + return () => {}; + const localRoot = resolveKbLocalRoot(snapshot); + const toolsApi = ctx.tools; + if (!toolsApi || typeof toolsApi.register !== "function") + return () => {}; + const entries = [...readTools(snapshot), ...localRoot ? writeTools(localRoot) : []]; const disposers = []; const source = options.source ?? "netxops-kb-local"; const id = getToolMountId(ctx, source); @@ -1057,7 +1396,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { publishToolMountStatus({ ...status, names: [], count: 0, error: String(error) }); throw error; } - ctx.logger.info("netxops: kb local tools registered count=%s localRoot=%s", disposers.length, localRoot); + ctx.logger.info("netxops: kb tools registered count=%s kbRoot=%s localRoot=%s", disposers.length, snapshot.realRoot, localRoot ?? "(none — read-only)"); return () => { for (const dispose of disposers) dispose(); @@ -1067,19 +1406,35 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { // src/netx/kb-pack-skills.ts import { readdir as readdir2, stat as stat2 } from "node:fs/promises"; -import { join as join5 } from "node:path"; +import { join as join8 } from "node:path"; function kbPackSkillsEnabled(snapshot) { return snapshot.status === "configured" && snapshot.content.hasSkills === true; } function resolveKbSkillsRoot(snapshot) { const rel = snapshot.paths.skills?.trim() || "_skills"; - return join5(snapshot.realRoot, rel); + return join8(snapshot.realRoot, rel); +} +function kbPackSkillFooter(snapshot) { + return [ + "", + "", + `## Injected KB anchors (netxops · ${snapshot.operatorName} / ${snapshot.country} v${snapshot.version})`, + "", + `kbStatus=configured · kbRoot=\`${snapshot.realRoot}\``, + "", + renderKbPathTable(snapshot), + "", + "These are already resolved from `MANIFEST.json` `paths.*`; use them verbatim.", + "Read/search the pack with `netx__kbRead` / `netx__kbSearch` when present; write only via `netx__kbWrite*`.", + "" + ].join(` +`); } function resolveKbLocalSkillsRoot(snapshot) { const rel = snapshot.paths.localSkills?.trim(); if (!rel) return null; - return join5(snapshot.realRoot, rel); + return join8(snapshot.realRoot, rel); } async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; @@ -1098,7 +1453,7 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) for (const entry of entries) { if (options.signal?.aborted) break; - const full = join5(root, entry); + const full = join8(root, entry); let isDir = false; try { isDir = (await stat2(full)).isDirectory(); @@ -1120,15 +1475,24 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) } if (options.signal?.aborted) break; - disposers.push(skillsApi.register({ - name: skill.name, - description: skill.description, - content: skill.content, - path: skill.path, - resourceBase: { kind: "directory", path: skill.directory }, - provider, - source: "custom" - })); + if (options.seen.has(skill.name)) { + ctx.logger.warn("netxops: kb %s skill %s duplicates an already registered pack skill — skip %s", options.label, skill.name, full); + continue; + } + try { + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: `${skill.content.trimEnd()}${options.footer}`, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider, + source: "custom" + })); + options.seen.add(skill.name); + } catch (error) { + ctx.logger.warn("netxops: kb %s skill %s register failed: %s", options.label, skill.name, error instanceof Error ? error.message : String(error)); + } } } catch (error) { for (const dispose of disposers.splice(0)) @@ -1149,17 +1513,23 @@ async function registerKbPackSkills(ctx, snapshot, options) { const disposers = []; const skillsRoot = resolveKbSkillsRoot(snapshot); const localRoot = resolveKbLocalSkillsRoot(snapshot); + const footer = kbPackSkillFooter(snapshot); + const seen = new Set; try { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { logMissing: true, label: "pack", - signal: options.signal + signal: options.signal, + footer, + seen })); if (localRoot) { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, { logMissing: false, label: "local", - signal: options.signal + signal: options.signal, + footer, + seen })); } } catch (error) { @@ -1361,8 +1731,8 @@ function watchNetxConnection(listener) { } // src/netx/tools.ts -import { readFileSync as readFileSync2 } from "node:fs"; -import { dirname as dirname4, join as join6 } from "node:path"; +import { readFileSync as readFileSync5 } from "node:fs"; +import { dirname as dirname5, join as join9 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; @@ -2804,8 +3174,8 @@ async function layoutTopologyView(client, args, signal) { // src/netx/tools.ts function packageVersion() { try { - const here = dirname4(fileURLToPath2(import.meta.url)); - const pkg = JSON.parse(readFileSync2(join6(here, "..", "package.json"), "utf8")); + const here = dirname5(fileURLToPath2(import.meta.url)); + const pkg = JSON.parse(readFileSync5(join9(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; @@ -3276,8 +3646,8 @@ function registerNetxTools(ctx, connection, options) { // src/netx/group-tools-plugin.ts function packageVersion2() { try { - const here = dirname5(fileURLToPath3(import.meta.url)); - const pkg = JSON.parse(readFileSync3(join7(here, "..", "package.json"), "utf8")); + const here = dirname6(fileURLToPath3(import.meta.url)); + const pkg = JSON.parse(readFileSync6(join10(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; diff --git a/lib/index.js b/lib/index.js index 0815895..2a71a94 100644 --- a/lib/index.js +++ b/lib/index.js @@ -2,8 +2,8 @@ import { createRequire } from "node:module"; var __require = /* @__PURE__ */ createRequire(import.meta.url); // src/index.ts -import { readFileSync as readFileSync7 } from "node:fs"; -import { dirname as dirname8, join as join11 } from "node:path"; +import { readFileSync as readFileSync9 } from "node:fs"; +import { dirname as dirname8, join as join13 } from "node:path"; import { fileURLToPath as fileURLToPath5 } from "node:url"; import z from "@deepseek-ai/schemastery"; import { credentialRef } from "@deepseek-ai/dsh-credentials"; @@ -1700,264 +1700,13 @@ Use only tools present in this session. Native layout supports catalog and move_ }; } -// src/netx/kb-local-path.ts -import { existsSync as existsSync4, realpathSync, statSync as statSync2 } from "node:fs"; -import { join as join4, relative, resolve, sep } from "node:path"; -var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ - "memories", - "drafts", - "suggestions", - "refs" -]); -var REF_AREAS = Object.freeze([ - "inventory", - "devices", - "topology", - "business", - "commands", - "handbooks" -]); -var MEMORY_BUCKET_DIR = Object.freeze({ - note: "日常笔记", - rca_review: "排障复盘", - ai_trace: "AI思维链" -}); -function resolveKbLocalRoot(snapshot) { - if (snapshot.status !== "configured") - return null; - const rel = snapshot.paths.local?.trim(); - if (!rel) - return null; - return resolve(snapshot.realRoot, rel); -} -function kbLocalToolsEnabled(snapshot) { - return resolveKbLocalRoot(snapshot) !== null; -} -function sanitizeSlug(raw, fallback = "entry") { - const cleaned = raw.trim().replace(/[/\\]+/g, "-").replace(/\.\.+/g, "").replace(/[^\w.\u4e00-\u9fff-]+/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); - return cleaned || fallback; -} -function todayIsoDate(now = new Date) { - const y = now.getFullYear(); - const m = String(now.getMonth() + 1).padStart(2, "0"); - const d = String(now.getDate()).padStart(2, "0"); - return `${y}-${m}-${d}`; -} -function toCompactDate(isoOrEmpty, now = new Date) { - const iso = (isoOrEmpty?.trim() || todayIsoDate(now)).replace(/-/g, ""); - if (!/^\d{8}$/.test(iso)) { - throw new Error(`invalid date (want YYYY-MM-DD or YYYYMMDD): ${isoOrEmpty}`); - } - return iso; -} -function normalizeIsoDate(raw, now = new Date) { - const t = raw?.trim(); - if (!t) - return todayIsoDate(now); - if (/^\d{4}-\d{2}-\d{2}$/.test(t)) - return t; - if (/^\d{8}$/.test(t)) { - return `${t.slice(0, 4)}-${t.slice(4, 6)}-${t.slice(6, 8)}`; - } - throw new Error(`invalid date (want YYYY-MM-DD or YYYYMMDD): ${raw}`); -} -function memoryFileName(opts) { - const date = normalizeIsoDate(opts.date, opts.now); - return `${date}-${sanitizeSlug(opts.slug)}.md`; -} -function draftFileName(opts) { - const compact = toCompactDate(opts.date, opts.now); - const slug = sanitizeSlug(opts.slug); - const base = slug.toUpperCase().startsWith("DRAFT-") ? slug : `DRAFT-${compact}-${slug}`; - return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; -} -function suggestionFileName(opts) { - const date = normalizeIsoDate(opts.date, opts.now); - return `${date}-${sanitizeSlug(opts.slug)}.md`; -} -function refFileName(slug) { - const base = sanitizeSlug(slug, "note"); - return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; -} -function resolveWritableLocalPath(localRoot, ...segments) { - if (segments.some((s) => s.includes("\x00"))) { - throw new Error("path segment contains NUL"); - } - const absolutePath = resolve(localRoot, ...segments); - const rel = relative(localRoot, absolutePath); - if (!rel || rel.startsWith("..") || rel.split(/[/\\]/).includes("..")) { - throw new Error(`path escapes local root: ${absolutePath}`); - } - const top = rel.split(/[/\\]/)[0]; - if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) { - throw new Error(`writes only allowed under ${KB_LOCAL_WRITABLE_ROOTS.join("|")}/ (got ${top}/)`); - } - return { - absolutePath, - relativePath: rel.split(sep).join("/"), - writableRoot: top - }; -} -function resolveExistingWritableFile(localRoot, pathArg) { - const trimmed = pathArg.trim(); - if (!trimmed) - throw new Error("path is empty"); - const candidate = resolve(trimmed); - let absolutePath; - const localResolved = resolve(localRoot); - const underLocal = candidate === localResolved || candidate.startsWith(localResolved + sep); - if (underLocal) { - absolutePath = candidate; - } else { - absolutePath = resolve(localRoot, trimmed); - } - const checked = resolveWritableLocalPath(localRoot, ...relative(localRoot, absolutePath).split(/[/\\]/).filter(Boolean)); - try { - if (existsSync4(checked.absolutePath)) { - const realFile = realpathSync(checked.absolutePath); - const realLocal = realpathSync(localRoot); - const realRel = relative(realLocal, realFile); - if (!realRel || realRel.startsWith("..") || realRel.split(/[/\\]/).includes("..")) { - throw new Error(`path escapes local root after realpath: ${realFile}`); - } - const top = realRel.split(/[/\\]/)[0]; - if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) { - throw new Error(`path not under writable roots: ${realRel}`); - } - if (!statSync2(realFile).isFile()) { - throw new Error(`not a file: ${realFile}`); - } - return { - absolutePath: realFile, - relativePath: realRel.split(sep).join("/"), - writableRoot: top - }; - } - } catch (error) { - if (error instanceof Error && /escapes|writable|not a file/.test(error.message)) { - throw error; - } - } - return checked; -} -function memoryDir(bucket) { - return join4("memories", MEMORY_BUCKET_DIR[bucket]); -} -function suggestionDir(kind) { - return join4("suggestions", kind); -} -function draftDir(domain) { - const d = domain?.trim(); - if (!d) - return "drafts"; - return join4("drafts", sanitizeSlug(d, "misc")); -} -function refDir(area, device) { - if (!REF_AREAS.includes(area)) { - throw new Error(`invalid refs area: ${area}`); - } - if (area === "devices") { - const host = sanitizeSlug(device?.trim() || "", ""); - if (!host) { - throw new Error("refs/devices requires device (host_name)"); - } - return join4("refs", "devices", host); - } - return join4("refs", area); -} - // src/netx/kb-context-skill.ts -var SKILL_NAME = "kb-context"; -function skillBody(snapshot) { - if (snapshot.status === "configured") { - const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)"; - const localRoot = resolveKbLocalRoot(snapshot); - const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; - const localGuide = localRoot ? [ - "", - "### Dual plane (mandatory)", - "", - "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).", - `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, - "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):", - " `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /", - " `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.", - "", - "### refs — site product knowledge (evolve every task)", - "- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.", - "- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.", - "- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.", - "- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).", - "", - "### memories — write immediately when valuable", - "- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.", - "- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`." - ] : [ - "", - "No local write tools until `paths.local` is present in MANIFEST." - ]; - return { - description: "Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).", - content: [ - "## Knowledge base (configured)", - "", - "When troubleshooting with operator playbooks or docs, **compose paths from this root**.", - "Do not invent another operator or country.", - "", - `| Field | Value |`, - `| --- | --- |`, - `| kbStatus | configured |`, - `| kbRoot | \`${snapshot.realRoot}\` |`, - localRow, - `| kbOperator | ${snapshot.operatorName} |`, - `| kbCountry | ${snapshot.country} |`, - `| kbVersion | ${snapshot.version} |`, - `| kbContent (on) | ${flags} |`, - ...localGuide, - "", - "Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.", - "", - "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from", - `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, - "plus paths.localSkills when present — use those skills for KB triage;", - "keep netx-ops for live netx evidence only." - ].join(` -`) - }; - } - const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty"; - return { - description: "Knowledge-base context is unavailable — stay on pure netx evidence.", - content: [ - "## Knowledge base (unavailable)", - "", - `kbStatus=${snapshot.status}. ${reason}`, - "", - "**Do not invent an operator, country, or KB paths.**", - "Use only netx tools / live evidence (alarms, inventory, CLI, topology).", - "Operator-specific playbooks are out of scope until a valid MANIFEST is configured." - ].join(` -`) - }; -} -function registerKbContextSkill(ctx, snapshot) { - const skillsApi = ctx.skills; - if (!skillsApi || typeof skillsApi.register !== "function") { - return () => {}; - } - const body = skillBody(snapshot); - return skillsApi.register({ - name: SKILL_NAME, - description: body.description, - content: body.content, - provider: "netxops-kb", - source: "custom" - }); -} +import { readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync4 } from "node:fs"; +import { join as join6 } from "node:path"; // src/netx/kb-manifest.ts -import { existsSync as existsSync5, readdirSync, readFileSync as readFileSync3, statSync as statSync3 } from "node:fs"; -import { basename, dirname as dirname3, join as join5, resolve as resolve2 } from "node:path"; +import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync3, statSync as statSync2 } from "node:fs"; +import { basename, dirname as dirname3, join as join4, resolve } from "node:path"; var EMPTY_CONTENT = { hasRegions: false, hasTheory: false, @@ -1997,23 +1746,23 @@ function errorSnapshot(message) { }; } function findManifest(kbRoot, maxDepth = 3) { - const root = resolve2(kbRoot.trim()); + const root = resolve(kbRoot.trim()); if (!kbRoot.trim()) { return { paths: [], error: "kbRoot is empty" }; } let rootStat; try { - rootStat = statSync3(root); + rootStat = statSync2(root); } catch { return { paths: [], error: `kbRoot not found: ${root}` }; } if (!rootStat.isDirectory()) { return { paths: [], error: `kbRoot is not a directory: ${root}` }; } - const direct = join5(root, "MANIFEST.json"); - if (existsSync5(direct)) { + const direct = join4(root, "MANIFEST.json"); + if (existsSync4(direct)) { try { - if (statSync3(direct).isFile()) + if (statSync2(direct).isFile()) return { paths: [direct] }; } catch {} } @@ -2021,10 +1770,10 @@ function findManifest(kbRoot, maxDepth = 3) { const walk = (dir, depth) => { if (depth > maxDepth) return; - const candidate = join5(dir, "MANIFEST.json"); - if (existsSync5(candidate)) { + const candidate = join4(dir, "MANIFEST.json"); + if (existsSync4(candidate)) { try { - if (statSync3(candidate).isFile()) + if (statSync2(candidate).isFile()) found.push(candidate); } catch {} } @@ -2039,10 +1788,10 @@ function findManifest(kbRoot, maxDepth = 3) { for (const name of entries) { if (name === "node_modules" || name === ".git") continue; - const full = join5(dir, name); + const full = join4(dir, name); let st; try { - st = statSync3(full); + st = statSync2(full); } catch { continue; } @@ -2059,10 +1808,10 @@ function findManifest(kbRoot, maxDepth = 3) { for (const name of topEntries) { if (name === "node_modules" || name === ".git") continue; - const full = join5(root, name); + const full = join4(root, name); let st; try { - st = statSync3(full); + st = statSync2(full); } catch { continue; } @@ -2119,7 +1868,7 @@ function parsePaths(raw) { function parseManifest(raw) { let data; try { - data = JSON.parse(raw); + data = JSON.parse(raw.replace(/^\uFEFF/, "")); } catch (error) { throw new Error(`MANIFEST is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); } @@ -2150,9 +1899,9 @@ function resolveKbRoot(kbRoot, maxDepth = 3) { const trimmed = kbRoot.trim(); if (!trimmed) return unconfiguredKbSnapshot(); - let root = resolve2(trimmed); + let root = resolve(trimmed); try { - const st = statSync3(root); + const st = statSync2(root); if (st.isFile()) { if (basename(root).toLowerCase() === "manifest.json") { root = dirname3(root); @@ -2197,6 +1946,332 @@ function resolveKbRoot(kbRoot, maxDepth = 3) { return errorSnapshot(error instanceof Error ? error.message : String(error)); } } +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: existsSync4(absolute) }; + }); +} +function renderKbPathTable(snapshot) { + const rows = resolveKbPaths(snapshot); + if (rows.length === 0) + return "(MANIFEST has no paths — legacy package; ask HQ for a repack)"; + return [ + "| paths.* | Absolute path |", + "| --- | --- |", + ...rows.map((row) => `| ${row.key} | \`${row.absolute}\`${row.exists ? "" : " (missing)"} |`) + ].join(` +`); +} + +// src/netx/kb-local-path.ts +import { existsSync as existsSync5, realpathSync, statSync as statSync3 } from "node:fs"; +import { join as join5, relative, resolve as resolve2, sep } from "node:path"; +var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ + "memories", + "drafts", + "suggestions", + "refs" +]); +var REF_AREAS = Object.freeze([ + "inventory", + "devices", + "topology", + "business", + "commands", + "handbooks" +]); +var MEMORY_BUCKET_DIR = Object.freeze({ + note: "日常笔记", + rca_review: "排障复盘", + ai_trace: "AI思维链" +}); +function resolveKbLocalRoot(snapshot) { + if (snapshot.status !== "configured") + return null; + const rel = snapshot.paths.local?.trim(); + if (!rel) + return null; + return resolve2(snapshot.realRoot, rel); +} +function sanitizeSlug(raw, fallback = "entry") { + const cleaned = raw.trim().replace(/[/\\]+/g, "-").replace(/\.\.+/g, "").replace(/[^\w.\u4e00-\u9fff-]+/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); + return cleaned || fallback; +} +function todayIsoDate(now = new Date) { + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} +function toCompactDate(isoOrEmpty, now = new Date) { + const iso = (isoOrEmpty?.trim() || todayIsoDate(now)).replace(/-/g, ""); + if (!/^\d{8}$/.test(iso)) { + throw new Error(`invalid date (want YYYY-MM-DD or YYYYMMDD): ${isoOrEmpty}`); + } + return iso; +} +function normalizeIsoDate(raw, now = new Date) { + const t = raw?.trim(); + if (!t) + return todayIsoDate(now); + if (/^\d{4}-\d{2}-\d{2}$/.test(t)) + return t; + if (/^\d{8}$/.test(t)) { + return `${t.slice(0, 4)}-${t.slice(4, 6)}-${t.slice(6, 8)}`; + } + throw new Error(`invalid date (want YYYY-MM-DD or YYYYMMDD): ${raw}`); +} +function memoryFileName(opts) { + const date = normalizeIsoDate(opts.date, opts.now); + return `${date}-${sanitizeSlug(opts.slug)}.md`; +} +function draftFileName(opts) { + const compact = toCompactDate(opts.date, opts.now); + const slug = sanitizeSlug(opts.slug); + const base = slug.toUpperCase().startsWith("DRAFT-") ? slug : `DRAFT-${compact}-${slug}`; + return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; +} +function suggestionFileName(opts) { + const date = normalizeIsoDate(opts.date, opts.now); + return `${date}-${sanitizeSlug(opts.slug)}.md`; +} +function refFileName(slug) { + const base = sanitizeSlug(slug, "note"); + return base.toLowerCase().endsWith(".md") ? base : `${base}.md`; +} +function resolveWritableLocalPath(localRoot, ...segments) { + if (segments.some((s) => s.includes("\x00"))) { + throw new Error("path segment contains NUL"); + } + const absolutePath = resolve2(localRoot, ...segments); + const rel = relative(localRoot, absolutePath); + if (!rel || rel.startsWith("..") || rel.split(/[/\\]/).includes("..")) { + throw new Error(`path escapes local root: ${absolutePath}`); + } + const top = rel.split(/[/\\]/)[0]; + if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) { + throw new Error(`writes only allowed under ${KB_LOCAL_WRITABLE_ROOTS.join("|")}/ (got ${top}/)`); + } + return { + absolutePath, + relativePath: rel.split(sep).join("/"), + writableRoot: top + }; +} +function resolveExistingWritableFile(localRoot, pathArg) { + const trimmed = pathArg.trim(); + if (!trimmed) + throw new Error("path is empty"); + const candidate = resolve2(trimmed); + let absolutePath; + const localResolved = resolve2(localRoot); + const underLocal = candidate === localResolved || candidate.startsWith(localResolved + sep); + if (underLocal) { + absolutePath = candidate; + } else { + absolutePath = resolve2(localRoot, trimmed); + } + const checked = resolveWritableLocalPath(localRoot, ...relative(localRoot, absolutePath).split(/[/\\]/).filter(Boolean)); + try { + if (existsSync5(checked.absolutePath)) { + const realFile = realpathSync(checked.absolutePath); + const realLocal = realpathSync(localRoot); + const realRel = relative(realLocal, realFile); + if (!realRel || realRel.startsWith("..") || realRel.split(/[/\\]/).includes("..")) { + throw new Error(`path escapes local root after realpath: ${realFile}`); + } + const top = realRel.split(/[/\\]/)[0]; + if (!KB_LOCAL_WRITABLE_ROOTS.includes(top)) { + throw new Error(`path not under writable roots: ${realRel}`); + } + if (!statSync3(realFile).isFile()) { + throw new Error(`not a file: ${realFile}`); + } + return { + absolutePath: realFile, + relativePath: realRel.split(sep).join("/"), + writableRoot: top + }; + } + } catch (error) { + if (error instanceof Error && /escapes|writable|not a file/.test(error.message)) { + throw error; + } + } + return checked; +} +function memoryDir(bucket) { + return join5("memories", MEMORY_BUCKET_DIR[bucket]); +} +function suggestionDir(kind) { + return join5("suggestions", kind); +} +function draftDir(domain) { + const d = domain?.trim(); + if (!d) + return "drafts"; + return join5("drafts", sanitizeSlug(d, "misc")); +} +function refDir(area, device) { + if (!REF_AREAS.includes(area)) { + throw new Error(`invalid refs area: ${area}`); + } + if (area === "devices") { + const host = sanitizeSlug(device?.trim() || "", ""); + if (!host) { + throw new Error("refs/devices requires device (host_name)"); + } + return join5("refs", "devices", host); + } + return join5("refs", area); +} + +// src/netx/kb-context-skill.ts +var SKILL_NAME = "kb-context"; +var IDENTITY_FILE_MAX = 8000; +var IDENTITY_TOTAL_MAX = 24000; +function readKbIdentity(snapshot) { + const localRoot = resolveKbLocalRoot(snapshot); + if (!localRoot) + return []; + const dir = join6(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 = join6(dir, name); + let text; + try { + if (!statSync4(full).isFile()) + continue; + text = readFileSync4(full, "utf8").replace(/^\uFEFF/, "").trim(); + } catch { + continue; + } + if (!text) + continue; + const cap = Math.min(IDENTITY_FILE_MAX, budget); + if (text.length > cap) + text = `${text.slice(0, cap)} + +…(truncated — read \`${full}\` for the rest)`; + budget -= text.length; + out.push({ name, text }); + } + return out; +} +function skillBody(snapshot) { + if (snapshot.status === "configured") { + const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)"; + const localRoot = resolveKbLocalRoot(snapshot); + const localRow = localRoot ? `| kbLocal | \`${localRoot}\` |` : "| kbLocal | (MANIFEST paths.local missing) |"; + const identity = readKbIdentity(snapshot); + const identityBlock = identity.length > 0 ? [ + "", + "### Site identity (`identity/`, authoritative — follow over generic advice)", + ...identity.flatMap(({ name, text }) => ["", `#### ${name}`, "", text]) + ] : []; + const localGuide = localRoot ? [ + "", + "### Dual plane (mandatory)", + "", + "- **HQ pack = read-only** (formal RCA, theory, packet, `_common`, `_skills`, shared cmdLib).", + `- **Writable evolution core**: \`${localRoot}\` → \`refs/\` | \`memories/\` | \`drafts/\` | \`suggestions/\`.`, + "- **All KB writes via host tools** (workspace Write/bash cannot reach KB):", + " `netx__kbWriteRef` / `netx__kbWriteMemory` / `netx__kbWriteDraft` / `netx__kbWriteSuggestion` /", + " `netx__kbUpdateLocal` / `netx__kbDeleteLocal` / `netx__kbListLocal`.", + "", + "### refs — site product knowledge (evolve every task)", + "- On any NE: **read** `refs/devices//` first; if missing, prove with netx then `kbWriteRef` to create/update.", + "- `area=devices` requires `device=` (never UUID); primary file slug=`PROFILE`.", + "- Also: `inventory` (ledger), `topology`, `business`, `commands` (site-only), `handbooks`.", + "- Device facts → refs; episodic experience → memories (do not dump ledgers into diaries).", + "", + "### memories — write immediately when valuable", + "- `bucket=note` | `rca_review` | `ai_trace` — short beats lost.", + "- Open cases → `kbWriteDraft`. HQ pack gaps → `kbWriteSuggestion`." + ] : [ + "", + "No local write tools until `paths.local` is present in MANIFEST." + ]; + return { + description: `Load FIRST before any kb-* skill or KB file access: resolved absolute paths (rca / theory / packet / neIndex / cmdLib / local …) for operator ${snapshot.operatorName} (${snapshot.country}) KB v${snapshot.version}, site identity rules, and KB write rules.`, + content: [ + "## Knowledge base (configured)", + "", + "When troubleshooting with operator playbooks or docs, **compose paths from this root**.", + "Do not invent another operator or country.", + "", + `| Field | Value |`, + `| --- | --- |`, + `| kbStatus | configured |`, + `| kbRoot | \`${snapshot.realRoot}\` |`, + localRow, + `| kbOperator | ${snapshot.operatorName} |`, + `| kbCountry | ${snapshot.country} |`, + `| kbVersion | ${snapshot.version} |`, + `| kbContent (on) | ${flags} |`, + "", + "### Path anchors (resolved from MANIFEST `paths.*` — use verbatim, never compose `regions///…`)", + "", + renderKbPathTable(snapshot), + ...identityBlock, + ...localGuide, + "", + "### 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.", + "", + "Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_PATHS (JSON of the anchors above), KB_STATUS.", + "", + "Business playbooks (kb-troubleshoot, kb-retrieve, …) register from paths.skills", + "when hasSkills is true, plus paths.localSkills when present — use those skills for KB triage;", + "keep netx-ops for live netx evidence only." + ].join(` +`) + }; + } + const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty"; + return { + description: "Knowledge-base context is unavailable — stay on pure netx evidence.", + content: [ + "## Knowledge base (unavailable)", + "", + `kbStatus=${snapshot.status}. ${reason}`, + "", + "**Do not invent an operator, country, or KB paths.**", + "Use only netx tools / live evidence (alarms, inventory, CLI, topology).", + "Operator-specific playbooks are out of scope until a valid MANIFEST is configured." + ].join(` +`) + }; +} +function registerKbContextSkill(ctx, snapshot) { + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const body = skillBody(snapshot); + return skillsApi.register({ + name: SKILL_NAME, + description: body.description, + content: body.content, + provider: "netxops-kb", + source: "custom" + }); +} // src/netx/kb-local-tools.ts import { defineTool } from "@deepseek-ai/dsh-tools"; @@ -2205,13 +2280,13 @@ import { defineTool } from "@deepseek-ai/dsh-tools"; import { existsSync as existsSync6, mkdirSync as mkdirSync2, - readdirSync as readdirSync2, - readFileSync as readFileSync4, + readdirSync as readdirSync3, + readFileSync as readFileSync5, rmSync, - statSync as statSync4, + statSync as statSync5, writeFileSync as writeFileSync3 } from "node:fs"; -import { dirname as dirname4, join as join6 } from "node:path"; +import { dirname as dirname4, join as join7 } from "node:path"; function requireLocalRoot(snapshot) { const root = resolveKbLocalRoot(snapshot); if (!root) { @@ -2361,17 +2436,17 @@ function deleteLocalFile(snapshot, args) { function walkFiles(dir, baseLocal, out) { let entries; try { - entries = readdirSync2(dir); + entries = readdirSync3(dir); } catch { return; } for (const name of entries) { if (name === "." || name === "..") continue; - const full = join6(dir, name); + const full = join7(dir, name); let st; try { - st = statSync4(full); + st = statSync5(full); } catch { continue; } @@ -2393,7 +2468,7 @@ function listLocalFiles(snapshot, args = {}) { const roots = args.root && args.root !== "all" ? [args.root] : [...KB_LOCAL_WRITABLE_ROOTS]; const entries = []; for (const r of roots) { - walkFiles(join6(localRoot, r), localRoot, entries); + walkFiles(join7(localRoot, r), localRoot, entries); } entries.sort((a, b) => b.mtimeMs - a.mtimeMs); const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); @@ -2404,6 +2479,241 @@ function listLocalFiles(snapshot, args = {}) { }; } +// src/netx/kb-read-ops.ts +import { readdirSync as readdirSync4, readFileSync as readFileSync6, realpathSync as realpathSync2, statSync as statSync6 } from "node:fs"; +import { extname, join as join8, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path"; +var TEXT_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".json", ".yml", ".yaml", ".csv", ".ps1", ".sh", ".cfg", ".conf", ".log"]); +var SKIP_DIRS = new Set([".git", "node_modules"]); +var READ_DEFAULT_LINES = 400; +var READ_MAX_LINES = 2000; +var READ_MAX_CHARS = 80000; +var SEARCH_MAX_FILE_BYTES = 2 * 1024 * 1024; +var SEARCH_HITS_PER_FILE = 4; +var CACHE_MAX_CHARS = 64 * 1024 * 1024; +function requireRoot(snapshot) { + if (snapshot.status !== "configured" || !snapshot.realRoot) { + throw new Error("knowledge base not configured (set kbRoot to a folder with MANIFEST.json)"); + } + return snapshot.realRoot; +} +function toPosix(rel) { + return rel.split(sep2).join("/"); +} +function isInside(root, target) { + const rel = relative2(root, target); + return rel === "" || !rel.startsWith("..") && !rel.split(/[/\\]/).includes("..") && !/^[a-zA-Z]:/.test(rel); +} +function resolveKbReadPath(snapshot, pathArg) { + const root = requireRoot(snapshot); + const trimmed = pathArg.trim().replace(/^["']|["']$/g, ""); + if (trimmed.includes("\x00")) + throw new Error("path contains NUL"); + const anchor = resolveKbPaths(snapshot).find((row) => row.key === trimmed); + const candidate = anchor ? anchor.absolute : !trimmed || trimmed === "." || trimmed === "/" ? root : resolve3(root, trimmed); + if (!isInside(root, candidate)) + throw new Error(`path escapes kbRoot: ${candidate}`); + let real; + try { + real = realpathSync2(candidate); + } catch { + throw new Error(`not found in KB: ${toPosix(relative2(root, candidate)) || "."}`); + } + const realRoot = realpathSync2(root); + if (!isInside(realRoot, real)) + throw new Error(`path escapes kbRoot after realpath: ${real}`); + return { absolutePath: real, relativePath: toPosix(relative2(realRoot, real)) || "." }; +} +function readKbFile(snapshot, args) { + const target = resolveKbReadPath(snapshot, args.path); + const st = statSync6(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 = statSync6(join8(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 = readFileSync6(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 { + ok: true, + kind: "file", + ...target, + totalLines: lines.length, + startLine: start, + endLine: start - 1 + slice.length, + truncated, + text + }; +} +var CACHE_KEY = Symbol.for("dsh-netxops.kb-search-cache"); +function searchCache() { + const root = globalThis; + root[CACHE_KEY] ??= { files: new Map, chars: 0 }; + return root[CACHE_KEY]; +} +function cachedText(path, mtimeMs, size) { + const cache = searchCache(); + const hit = cache.files.get(path); + if (hit && hit.mtimeMs === mtimeMs && hit.size === size) + return hit; + const text = readFileSync6(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 = join8(dir, entry.name); + if (entry.isDirectory()) { + walkTextFiles(full, out); + continue; + } + if (!entry.isFile() || !TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) + continue; + try { + const st = statSync6(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 = statSync6(dir); + const batch = []; + if (st.isFile()) + batch.push({ path: dir, mtimeMs: st.mtimeMs, size: st.size }); + else + walkTextFiles(dir, batch); + for (const file of batch) { + if (seen.has(file.path)) + continue; + seen.add(file.path); + files.push(file); + } + } + const hits = []; + for (const file of files) { + let cached; + try { + cached = cachedText(file.path, file.mtimeMs, file.size); + } catch { + continue; + } + const rel = toPosix(relative2(root, file.path)); + const matched = new Set; + let score = 0; + for (const m of matchers) { + if (m.test(rel.toLowerCase(), rel)) { + matched.add(m.term); + score += 3; + } + } + const inBody = matchers.filter((m) => m.test(cached.lower, cached.text)); + if (inBody.length === 0 && matched.size === 0) + continue; + const lines = []; + const rows = inBody.length > 0 ? cached.text.split(/\r\n?|\n/) : []; + let lineHits = 0; + for (let i = 0;i < rows.length; i += 1) { + const row = rows[i]; + const lower = row.toLowerCase(); + let lineHit = false; + for (const m of inBody) { + if (m.test(lower, row)) { + matched.add(m.term); + lineHit = true; + } + } + if (!lineHit) + continue; + lineHits += 1; + if (lines.length < SEARCH_HITS_PER_FILE) { + const trimmed = row.trim(); + lines.push({ line: i + 1, text: trimmed.length > 240 ? `${trimmed.slice(0, 240)}…` : trimmed }); + } + } + score += matched.size * 10 + Math.min(lineHits, 30) * 0.1; + hits.push({ + relativePath: rel, + absolutePath: file.path, + score: Math.round(score * 10) / 10, + matchedTerms: [...matched], + lines + }); + } + hits.sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath)); + const limit = Math.min(Math.max(Math.floor(args.limit ?? 20), 1), 100); + return { ok: true, scope: scopeArg, scannedFiles: files.length, totalMatches: hits.length, hits: hits.slice(0, limit) }; +} + // src/netx/kb-runtime.ts var STORE_KEY2 = Symbol.for("dsh-netxops.kb-store"); var ENV_KEYS = [ @@ -2413,6 +2723,7 @@ var ENV_KEYS = [ "KB_COUNTRY", "KB_VERSION", "KB_CONTENT", + "KB_PATHS", "KB_STATUS" ]; function store2() { @@ -2432,9 +2743,9 @@ function getKbContext() { paths: { ...snap.paths } }; } -function publishKbContext(next) { +function publishKbContext(next, options = {}) { const state = store2(); - if (JSON.stringify(state.snapshot) === JSON.stringify(next)) + if (!options.force && JSON.stringify(state.snapshot) === JSON.stringify(next)) return; state.snapshot = { ...next, @@ -2471,6 +2782,7 @@ function applyKbEnv(snapshot) { process.env.KB_COUNTRY = snapshot.country; process.env.KB_VERSION = snapshot.version; process.env.KB_CONTENT = JSON.stringify(snapshot.content); + process.env.KB_PATHS = JSON.stringify(Object.fromEntries(resolveKbPaths(snapshot).map((row) => [row.key, row.absolute]))); } function resetKbContext() { publishKbContext(unconfiguredKbSnapshot()); @@ -2506,7 +2818,7 @@ function omitUndefined(record) { // src/netx/tool-mount-status.ts import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "node:fs"; import { homedir as homedir3 } from "node:os"; -import { dirname as dirname5, join as join7 } from "node:path"; +import { dirname as dirname5, join as join9 } from "node:path"; var KEY = Symbol.for("dsh-netxops.mount-inventory"); function store3() { const root = globalThis; @@ -2530,7 +2842,7 @@ function getToolMountStatus() { } function persist() { try { - const path = join7(process.env.DSH_HOME?.trim() || join7(homedir3(), ".dsh"), "netxops-mount-status.json"); + const path = join9(process.env.DSH_HOME?.trim() || join9(homedir3(), ".dsh"), "netxops-mount-status.json"); mkdirSync3(dirname5(path), { recursive: true }); writeFileSync4(path, `${JSON.stringify({ mount: getToolMountStatus() ?? null, mounts: getToolMountStatuses() }, null, 2)} `, "utf8"); @@ -2585,14 +2897,8 @@ function tool(name, description, parameters, execute) { } }); } -function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { - if (!kbLocalToolsEnabled(snapshot)) - return () => {}; - const localRoot = resolveKbLocalRoot(snapshot); - const toolsApi = ctx.tools; - if (!toolsApi || typeof toolsApi.register !== "function") - return () => {}; - const entries = [ +function writeTools(localRoot) { + return [ tool("netx__kbWriteMemory", `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` + "bucket: note→日常笔记, rca_review→排障复盘, ai_trace→AI思维链. " + "Default create-only; set overwrite=true to replace same filename. " + "Do not use workspace Write — KB is outside the sandbox.", { bucket: { type: "string", @@ -2672,7 +2978,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { }, (args) => deleteLocalFile(liveSnapshot(), { path: String(args.path ?? "") })), - tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents via absolute paths from the listing.", { + tool("netx__kbListLocal", `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` + "Read contents with netx__kbRead using the listed paths.", { root: { type: "string", enum: ["memories", "drafts", "suggestions", "refs", "all"], @@ -2684,6 +2990,39 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { limit: typeof args.limit === "number" ? args.limit : undefined })) ]; +} +function readTools(snapshot) { + return [ + tool("netx__kbSearch", `Search the operator knowledge base at ${snapshot.realRoot} (read-only HQ pack + _local). ` + "Plain mode: space-separated keywords (NE host_name, alarm, protocol, symptom…), case-insensitive; " + "files ranked by how many distinct terms match (filename hits weigh more), up to 4 hit lines each. " + "Then open the best hits with netx__kbRead.", { + query: reqStr("Keywords (space-separated) or a regex when regex=true"), + scope: str("MANIFEST paths key(s) — e.g. rca, theory, theoryVendor, packet, commonPatterns, local, neIndex — or a relative dir; comma-separate several; default all"), + regex: bool("Treat query as one case-insensitive JS regex"), + limit: num("Max files 1–100 (default 20)") + }, (args) => searchKb(liveSnapshot(), { + query: String(args.query ?? ""), + scope: args.scope != null ? String(args.scope) : undefined, + regex: args.regex === true, + limit: typeof args.limit === "number" ? args.limit : undefined + })), + tool("netx__kbRead", `Read one file (or list one directory) inside the knowledge base at ${snapshot.realRoot}. ` + "path: relative to kbRoot, absolute inside it, or a MANIFEST paths key (rca, neIndex, cmdLib, patternMap, …). " + "Long files: page with offset/limit (1-based lines; default 400).", { + path: reqStr("KB-relative path, absolute path inside kbRoot, or a paths.* key"), + offset: num("First line (1-based, default 1)"), + limit: num("Max lines 1–2000 (default 400)") + }, (args) => readKbFile(liveSnapshot(), { + path: String(args.path ?? ""), + offset: typeof args.offset === "number" ? args.offset : undefined, + limit: typeof args.limit === "number" ? args.limit : undefined + })) + ]; +} +function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { + if (snapshot.status !== "configured") + return () => {}; + const localRoot = resolveKbLocalRoot(snapshot); + const toolsApi = ctx.tools; + if (!toolsApi || typeof toolsApi.register !== "function") + return () => {}; + const entries = [...readTools(snapshot), ...localRoot ? writeTools(localRoot) : []]; const disposers = []; const source = options.source ?? "netxops-kb-local"; const id = getToolMountId(ctx, source); @@ -2705,7 +3044,7 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { publishToolMountStatus({ ...status, names: [], count: 0, error: String(error) }); throw error; } - ctx.logger.info("netxops: kb local tools registered count=%s localRoot=%s", disposers.length, localRoot); + ctx.logger.info("netxops: kb tools registered count=%s kbRoot=%s localRoot=%s", disposers.length, snapshot.realRoot, localRoot ?? "(none — read-only)"); return () => { for (const dispose of disposers) dispose(); @@ -2715,19 +3054,35 @@ function registerKbLocalTools(ctx, snapshot = getKbContext(), options = {}) { // src/netx/kb-pack-skills.ts import { readdir as readdir2, stat as stat2 } from "node:fs/promises"; -import { join as join8 } from "node:path"; +import { join as join10 } from "node:path"; function kbPackSkillsEnabled(snapshot) { return snapshot.status === "configured" && snapshot.content.hasSkills === true; } function resolveKbSkillsRoot(snapshot) { const rel = snapshot.paths.skills?.trim() || "_skills"; - return join8(snapshot.realRoot, rel); + return join10(snapshot.realRoot, rel); +} +function kbPackSkillFooter(snapshot) { + return [ + "", + "", + `## Injected KB anchors (netxops · ${snapshot.operatorName} / ${snapshot.country} v${snapshot.version})`, + "", + `kbStatus=configured · kbRoot=\`${snapshot.realRoot}\``, + "", + renderKbPathTable(snapshot), + "", + "These are already resolved from `MANIFEST.json` `paths.*`; use them verbatim.", + "Read/search the pack with `netx__kbRead` / `netx__kbSearch` when present; write only via `netx__kbWrite*`.", + "" + ].join(` +`); } function resolveKbLocalSkillsRoot(snapshot) { const rel = snapshot.paths.localSkills?.trim(); if (!rel) return null; - return join8(snapshot.realRoot, rel); + return join10(snapshot.realRoot, rel); } async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { let entries; @@ -2746,7 +3101,7 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) for (const entry of entries) { if (options.signal?.aborted) break; - const full = join8(root, entry); + const full = join10(root, entry); let isDir = false; try { isDir = (await stat2(full)).isDirectory(); @@ -2768,15 +3123,24 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) } if (options.signal?.aborted) break; - disposers.push(skillsApi.register({ - name: skill.name, - description: skill.description, - content: skill.content, - path: skill.path, - resourceBase: { kind: "directory", path: skill.directory }, - provider, - source: "custom" - })); + if (options.seen.has(skill.name)) { + ctx.logger.warn("netxops: kb %s skill %s duplicates an already registered pack skill — skip %s", options.label, skill.name, full); + continue; + } + try { + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: `${skill.content.trimEnd()}${options.footer}`, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider, + source: "custom" + })); + options.seen.add(skill.name); + } catch (error) { + ctx.logger.warn("netxops: kb %s skill %s register failed: %s", options.label, skill.name, error instanceof Error ? error.message : String(error)); + } } } catch (error) { for (const dispose of disposers.splice(0)) @@ -2797,17 +3161,23 @@ async function registerKbPackSkills(ctx, snapshot, options) { const disposers = []; const skillsRoot = resolveKbSkillsRoot(snapshot); const localRoot = resolveKbLocalSkillsRoot(snapshot); + const footer = kbPackSkillFooter(snapshot); + const seen = new Set; try { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { logMissing: true, label: "pack", - signal: options.signal + signal: options.signal, + footer, + seen })); if (localRoot) { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, { logMissing: false, label: "local", - signal: options.signal + signal: options.signal, + footer, + seen })); } } catch (error) { @@ -4207,11 +4577,11 @@ async function httpBridge(req, res, fetchHandler, maxBodyBytes) { } for await (const chunk of response.body) { if (!res.write(chunk)) { - await new Promise((resolve3) => { + await new Promise((resolve4) => { const done = () => { res.off("drain", done); res.off("close", done); - resolve3(); + resolve4(); }; res.once("drain", done); res.once("close", done); @@ -4272,8 +4642,8 @@ function mountNetxopsWebRoute(ctx, channel, handler) { } // src/netx/tools.ts -import { readFileSync as readFileSync5 } from "node:fs"; -import { dirname as dirname6, join as join9 } from "node:path"; +import { readFileSync as readFileSync7 } from "node:fs"; +import { dirname as dirname6, join as join11 } from "node:path"; import { fileURLToPath as fileURLToPath3 } from "node:url"; import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; @@ -5716,7 +6086,7 @@ async function layoutTopologyView(client, args, signal) { function packageVersion() { try { const here = dirname6(fileURLToPath3(import.meta.url)); - const pkg = JSON.parse(readFileSync5(join9(here, "..", "package.json"), "utf8")); + const pkg = JSON.parse(readFileSync7(join11(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; @@ -6188,13 +6558,13 @@ function registerNetxTools(ctx, connection, options) { import { createScope, scopeOf } from "@deepseek-ai/dsh-scope"; // src/netx/group-tools-plugin.ts -import { readFileSync as readFileSync6 } from "node:fs"; -import { dirname as dirname7, join as join10 } from "node:path"; +import { readFileSync as readFileSync8 } from "node:fs"; +import { dirname as dirname7, join as join12 } from "node:path"; import { fileURLToPath as fileURLToPath4 } from "node:url"; function packageVersion2() { try { const here = dirname7(fileURLToPath4(import.meta.url)); - const pkg = JSON.parse(readFileSync6(join10(here, "..", "package.json"), "utf8")); + const pkg = JSON.parse(readFileSync8(join12(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; @@ -6495,7 +6865,7 @@ function resolveApiUrlForConnection(value) { function packageVersion3() { try { const here = dirname8(fileURLToPath5(import.meta.url)); - const pkg = JSON.parse(readFileSync7(join11(here, "..", "package.json"), "utf8")); + const pkg = JSON.parse(readFileSync9(join13(here, "..", "package.json"), "utf8")); return typeof pkg.version === "string" ? pkg.version : "0"; } catch { return "0"; @@ -7058,7 +7428,7 @@ function apply(ctx, config = Config({})) { if (endpoint === "kb.reload") { const current = source(); const kb = resolveKbRoot(current.kbRoot ?? ""); - publishKbContext(kb); + publishKbContext(kb, { force: true }); applyKbEnv(kb); if (kb.status === "configured") { ctx.logger.info("netxops: kb.reload → %s (%s / %s v%s)", kb.realRoot, kb.operatorName, kb.country, kb.version); diff --git a/package.json b/package.json index cbd7575..5e5e298 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.70", + "version": "0.1.71", "description": "Network operations, topology and monitoring for DeepSeek Harness.", "license": "MIT", "type": "module", diff --git a/presets/netxops/PERSONA.md b/presets/netxops/PERSONA.md index 5e99b20..fe8af90 100644 --- a/presets/netxops/PERSONA.md +++ b/presets/netxops/PERSONA.md @@ -37,6 +37,7 @@ ## 知识库双平面(KB 已配置时 — 铁律) - **总部包只读**:正式 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/`(反哺总部)。 - **写文件只走宿主工具**:`netx__kbWriteRef` / `kbWriteMemory` / `kbWriteDraft` / `kbWriteSuggestion` / `kbUpdateLocal` / `kbDeleteLocal`。**禁止**对 KB 用 workspace `Write` / bash。 diff --git a/src/index.ts b/src/index.ts index 1072fc9..aefea25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1001,7 +1001,7 @@ export function apply(ctx: Context, config: ConfigInput = Config({} as C if (endpoint === 'kb.reload') { const current = source() const kb = resolveKbRoot(current.kbRoot ?? '') - publishKbContext(kb) + publishKbContext(kb, { force: true }) applyKbEnv(kb) if (kb.status === 'configured') { ctx.logger.info( diff --git a/src/netx/kb-context-skill.ts b/src/netx/kb-context-skill.ts index 1ae7c2c..09788ca 100644 --- a/src/netx/kb-context-skill.ts +++ b/src/netx/kb-context-skill.ts @@ -3,11 +3,51 @@ * 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 { KbSnapshot } from './kb-manifest.ts' +import { renderKbPathTable, type KbSnapshot } from './kb-manifest.ts' import { resolveKbLocalRoot } from './kb-local-path.ts' 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 } { if (snapshot.status === 'configured') { @@ -19,6 +59,14 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string 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 ? [ '', @@ -46,7 +94,7 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string ] return { description: - 'Operator knowledge-base context for this Host (HQ pack + local evolution under paths.local).', + `Load FIRST before any kb-* skill or KB file access: resolved absolute paths (rca / theory / packet / neIndex / cmdLib / local …) for operator ${snapshot.operatorName} (${snapshot.country}) KB v${snapshot.version}, site identity rules, and KB write rules.`, content: [ '## Knowledge base (configured)', '', @@ -62,13 +110,22 @@ function skillBody(snapshot: KbSnapshot): { description: string; content: string `| kbCountry | ${snapshot.country} |`, `| kbVersion | ${snapshot.version} |`, `| kbContent (on) | ${flags} |`, + '', + '### Path anchors (resolved from MANIFEST `paths.*` — use verbatim, never compose `regions///…`)', + '', + renderKbPathTable(snapshot), + ...identityBlock, ...localGuide, '', - 'Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_STATUS.', + '### Reading the HQ pack', + '- `netx__kbSearch` — keyword/regex search across rca / theory / packet / common / local (returns file + line hits).', + '- `netx__kbRead` — read one KB file (path relative to kbRoot or absolute inside it; optional line window).', + '- Start from the indexes: `neIndex` (NE → cases), `patternMap`, `classification`, then open the cited RCA.', '', - 'Business playbooks (kb-troubleshoot, kb-retrieve, …) register from', - `${snapshot.realRoot}/_skills/ (or MANIFEST paths.skills) when hasSkills is true,`, - 'plus paths.localSkills when present — use those skills for KB triage;', + 'Environment mirrors: KB_ROOT, KB_LOCAL, KB_OPERATOR, KB_COUNTRY, KB_VERSION, KB_CONTENT, KB_PATHS (JSON of the anchors above), KB_STATUS.', + '', + 'Business playbooks (kb-troubleshoot, kb-retrieve, …) register from paths.skills', + 'when hasSkills is true, plus paths.localSkills when present — use those skills for KB triage;', 'keep netx-ops for live netx evidence only.', ].join('\n'), } diff --git a/src/netx/kb-local-tools.ts b/src/netx/kb-local-tools.ts index d429d9f..b1333e6 100644 --- a/src/netx/kb-local-tools.ts +++ b/src/netx/kb-local-tools.ts @@ -1,6 +1,6 @@ /** - * Host-side tools for writing under MANIFEST `paths.local` - * (memories / drafts / suggestions / refs). Bypasses workspace sandbox. + * Host-side KB tools. Read/search the whole package when configured; write under MANIFEST + * `paths.local` (memories / drafts / suggestions / refs). Bypasses workspace sandbox. */ import type { Context } from '@deepseek-ai/cordis' @@ -15,7 +15,8 @@ import { listLocalFiles, updateLocalFile, } 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 { toLosslessJson } from './json-safe.ts' import { getToolMountId, getToolMountStatus, publishToolMountStatus, removeToolMountStatus } from './tool-mount-status.ts' @@ -66,22 +67,8 @@ function tool( }) } -/** - * Register KB local create/update/delete/list tools 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 (!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 = [ +function writeTools(localRoot: string) { + return [ tool( 'netx__kbWriteMemory', `Create (or overwrite) one diary-style memory markdown under ${localRoot}/memories/. ` @@ -206,7 +193,7 @@ export function registerKbLocalTools( tool( 'netx__kbListLocal', `List recent .md files under ${localRoot} memories|drafts|suggestions|refs (newest first). ` - + 'Read contents via absolute paths from the listing.', + + 'Read contents with netx__kbRead using the listed paths.', { root: { 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 source = options.source ?? 'netxops-kb-local' @@ -236,9 +282,10 @@ export function registerKbLocalTools( throw error } ctx.logger.info( - 'netxops: kb local tools registered count=%s localRoot=%s', + 'netxops: kb tools registered count=%s kbRoot=%s localRoot=%s', disposers.length, - localRoot, + snapshot.realRoot, + localRoot ?? '(none — read-only)', ) return () => { for (const dispose of disposers) dispose() diff --git a/src/netx/kb-manifest.ts b/src/netx/kb-manifest.ts index 4b64456..a28958f 100644 --- a/src/netx/kb-manifest.ts +++ b/src/netx/kb-manifest.ts @@ -240,7 +240,8 @@ export function parseManifest(raw: string): { } { let data: unknown 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) { throw new 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)) } } + +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///…`. + */ +export function resolveKbPaths(snapshot: KbSnapshot): KbResolvedPath[] { + if (snapshot.status !== 'configured') return [] + const paths: Record = { 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') +} diff --git a/src/netx/kb-pack-skills.ts b/src/netx/kb-pack-skills.ts index b88f1c3..c59b109 100644 --- a/src/netx/kb-pack-skills.ts +++ b/src/netx/kb-pack-skills.ts @@ -9,7 +9,7 @@ import { readdir, stat } from 'node:fs/promises' import { join } from 'node:path' 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' export type KbSkillPlane = 'preset' | 'public' @@ -35,6 +35,26 @@ export function resolveKbSkillsRoot(snapshot: KbSnapshot): string { 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. */ @@ -49,7 +69,14 @@ async function registerSkillsUnderRoot( skillsApi: { register: SkillsRegister }, root: 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 + }, ): Promise void>> { let entries: string[] try { @@ -91,15 +118,35 @@ async function registerSkillsUnderRoot( continue } if (options.signal?.aborted) break - disposers.push(skillsApi.register({ - name: skill.name, - description: skill.description, - content: skill.content, - path: skill.path, - resourceBase: { kind: 'directory', path: skill.directory }, - provider, - source: 'custom', - })) + if (options.seen.has(skill.name)) { + ctx.logger.warn( + 'netxops: kb %s skill %s duplicates an already registered pack skill — skip %s', + options.label, + skill.name, + full, + ) + continue + } + try { + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: `${skill.content.trimEnd()}${options.footer}`, + path: skill.path, + resourceBase: { kind: 'directory', path: skill.directory }, + provider, + source: 'custom', + })) + options.seen.add(skill.name) + } catch (error) { + // 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) { for (const dispose of disposers.splice(0)) dispose() @@ -137,11 +184,15 @@ export async function registerKbPackSkills( const skillsRoot = resolveKbSkillsRoot(snapshot) const localRoot = resolveKbLocalSkillsRoot(snapshot) + const footer = kbPackSkillFooter(snapshot) + const seen = new Set() try { disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, { logMissing: true, label: 'pack', signal: options.signal, + footer, + seen, })) if (localRoot) { @@ -149,6 +200,8 @@ export async function registerKbPackSkills( logMissing: false, label: 'local', signal: options.signal, + footer, + seen, })) } } catch (error) { diff --git a/src/netx/kb-read-ops.ts b/src/netx/kb-read-ops.ts new file mode 100644 index 0000000..9e27794 --- /dev/null +++ b/src/netx/kb-read-ops.ts @@ -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.` 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; chars: number } { + const root = globalThis as typeof globalThis & { [CACHE_KEY]?: { files: Map; 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() + 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() + 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) } +} diff --git a/src/netx/kb-runtime.ts b/src/netx/kb-runtime.ts index 317736e..195f7e1 100644 --- a/src/netx/kb-runtime.ts +++ b/src/netx/kb-runtime.ts @@ -7,7 +7,7 @@ */ 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' type Listener = () => void @@ -26,6 +26,7 @@ const ENV_KEYS = [ 'KB_COUNTRY', 'KB_VERSION', 'KB_CONTENT', + 'KB_PATHS', 'KB_STATUS', ] as const @@ -52,10 +53,12 @@ export function getKbContext(): KbSnapshot { /** * Publish the latest KB snapshot for UI / skill remounts. * @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() - if (JSON.stringify(state.snapshot) === JSON.stringify(next)) return + if (!options.force && JSON.stringify(state.snapshot) === JSON.stringify(next)) return state.snapshot = { ...next, content: { ...next.content }, @@ -94,6 +97,9 @@ export function applyKbEnv(snapshot: KbSnapshot): void { process.env.KB_COUNTRY = snapshot.country process.env.KB_VERSION = snapshot.version 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). */ diff --git a/test/kb-loading.test.mjs b/test/kb-loading.test.mjs new file mode 100644 index 0000000..8ef4a4e --- /dev/null +++ b/test/kb-loading.test.mjs @@ -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) +})) diff --git a/test/publication-lifecycle.test.mjs b/test/publication-lifecycle.test.mjs index ed09a06..d367a15 100644 --- a/test/publication-lifecycle.test.mjs +++ b/test/publication-lifecycle.test.mjs @@ -147,10 +147,11 @@ test('knowledge-base tools contribute to the mount inventory and disappear on di const mount = fakeContext() const snapshot = { status: 'configured', realRoot: process.env.DSH_HOME, paths: { local: '_local' } } 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() - assert.ok(statuses.some(row => row.source === 'kb-test' && row.count === 7 && row.groups.includes('kb'))) - assert.equal(asToolsMountView({ mounts: statuses }).count, 7) + assert.ok(statuses.some(row => row.source === 'kb-test' && row.count === 9 && row.groups.includes('kb'))) + assert.equal(asToolsMountView({ mounts: statuses }).count, 9) dispose() assert.equal(mount.tools.size, 0) assert.equal(getToolMountStatuses().some(row => row.source === 'kb-test'), false)