From a17c99c8998b2fc75323aac51c934743739cfb7a Mon Sep 17 00:00:00 2001 From: Dominic Griesel Date: Thu, 25 Sep 2025 12:30:45 +0200 Subject: [PATCH 1/2] feat(wip): use tool calling with Gemini --- package-lock.json | 103 +---- package.json | 4 +- src/lib/ai/analysis-prompt.ts | 243 +++++++++--- src/lib/ai/gemini-client.ts | 233 +++++++---- src/lib/app-state.ts | 6 +- src/lib/eventtarget-transport.ts | 281 +++++++++++++ src/lib/log-query-engine.ts | 37 +- src/lib/use-app-state.ts | 20 +- src/lib/zwave-log-analyzer.ts | 19 +- src/lib/zwave-mcp-server-core.ts | 654 +++++++++++++++++++++++++++++++ src/mcp-server.ts | 582 +-------------------------- 11 files changed, 1331 insertions(+), 851 deletions(-) create mode 100644 src/lib/eventtarget-transport.ts create mode 100644 src/lib/zwave-mcp-server-core.ts diff --git a/package-lock.json b/package-lock.json index 57df5ed..4347607 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.2", "license": "MIT", "dependencies": { - "@google/genai": "^1.13.0", + "@google/genai": "^1.20.0", + "@modelcontextprotocol/sdk": "^1.18.1", "@zwave-js/core": "^15.10.0", "yargs": "^18.0.0" }, @@ -22,7 +23,6 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@eslint/js": "^9.32.0", - "@modelcontextprotocol/sdk": "^1.18.1", "@mui/icons-material": "^7.3.1", "@mui/material": "^7.3.1", "@types/node": "^22.17.1", @@ -1575,9 +1575,9 @@ } }, "node_modules/@google/genai": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.13.0.tgz", - "integrity": "sha512-BxilXzE8cJ0zt5/lXk6KwuBcIT9P2Lbi2WXhwWMbxf1RNeC68/8DmYQqMrzQP333CieRMdbDXs0eNCphLoScWg==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.20.0.tgz", + "integrity": "sha512-QdShxO9LX35jFogy3iKprQNqgKKveux4H2QjOnyIvyHRuGi6PHiz3fjNf8Y0VPY8o5V2fHqR2XqiSVoz7yZs0w==", "license": "Apache-2.0", "dependencies": { "google-auth-library": "^9.14.2", @@ -1587,7 +1587,7 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.11.0" + "@modelcontextprotocol/sdk": "^1.11.4" }, "peerDependenciesMeta": { "@modelcontextprotocol/sdk": { @@ -1704,7 +1704,6 @@ "version": "1.18.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.18.1.tgz", "integrity": "sha512-d//GE8/Yh7aC3e7p+kZG8JqqEAwwDUmAfvH1quogtbk+ksS6E0RR6toKKESPYYZVre0meqkJb27zb+dhqE9Sgw==", - "devOptional": true, "license": "MIT", "dependencies": { "ajv": "^6.12.6", @@ -3139,7 +3138,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "devOptional": true, "license": "MIT", "dependencies": { "mime-types": "^3.0.0", @@ -3153,7 +3151,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -3163,7 +3160,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "devOptional": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -3208,7 +3204,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -3366,7 +3361,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", - "devOptional": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -3457,7 +3451,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -3467,7 +3460,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3481,7 +3473,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "devOptional": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3733,7 +3724,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "devOptional": true, "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" @@ -3746,7 +3736,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -3763,7 +3752,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -3773,7 +3761,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=6.6.0" @@ -3783,7 +3770,6 @@ "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "devOptional": true, "license": "MIT", "dependencies": { "object-assign": "^4", @@ -3824,7 +3810,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3900,7 +3885,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -3968,7 +3952,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "devOptional": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -3992,7 +3975,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "devOptional": true, "license": "MIT" }, "node_modules/electron-to-chromium": { @@ -4018,7 +4000,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -4095,7 +4076,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4105,7 +4085,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4115,7 +4094,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4195,7 +4173,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "devOptional": true, "license": "MIT" }, "node_modules/escape-string-regexp": { @@ -4411,7 +4388,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -4421,7 +4397,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "devOptional": true, "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -4434,7 +4409,6 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -4468,7 +4442,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "devOptional": true, "license": "MIT", "dependencies": { "accepts": "^2.0.0", @@ -4511,7 +4484,6 @@ "version": "7.5.1", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 16" @@ -4527,7 +4499,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -4537,7 +4508,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "devOptional": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -4556,7 +4526,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -4593,7 +4562,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -4664,7 +4632,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "devOptional": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -4771,7 +4738,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -4781,7 +4747,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -4820,7 +4785,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4891,7 +4855,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "devOptional": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4916,7 +4879,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "devOptional": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5022,7 +4984,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5071,7 +5032,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5100,7 +5060,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "devOptional": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5183,7 +5142,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "devOptional": true, "license": "MIT", "dependencies": { "depd": "2.0.0", @@ -5200,7 +5158,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -5233,7 +5190,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -5296,7 +5252,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.10" @@ -5432,7 +5387,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "devOptional": true, "license": "MIT" }, "node_modules/is-stream": { @@ -5451,7 +5405,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true, "license": "ISC" }, "node_modules/jiti": { @@ -5526,7 +5479,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "devOptional": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -5958,7 +5910,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6129,7 +6080,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -6139,7 +6089,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -6733,7 +6682,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -6803,7 +6751,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6822,7 +6769,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6835,7 +6781,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "devOptional": true, "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -6848,7 +6793,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -6992,7 +6936,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -7012,7 +6955,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -7029,7 +6971,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "devOptional": true, "license": "MIT", "funding": { "type": "opencollective", @@ -7076,7 +7017,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=16.20.0" @@ -7171,7 +7111,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "devOptional": true, "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -7192,7 +7131,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -7202,7 +7140,6 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -7239,7 +7176,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -7249,7 +7185,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", - "devOptional": true, "license": "MIT", "dependencies": { "bytes": "3.1.2", @@ -7265,7 +7200,6 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -7533,7 +7467,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "devOptional": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -7603,7 +7536,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, "license": "MIT" }, "node_modules/scheduler": { @@ -7627,7 +7559,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "devOptional": true, "license": "MIT", "dependencies": { "debug": "^4.3.5", @@ -7650,7 +7581,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -7660,7 +7590,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "devOptional": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -7673,7 +7602,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "devOptional": true, "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -7689,14 +7617,12 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "devOptional": true, "license": "ISC" }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -7709,7 +7635,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -7719,7 +7644,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7739,7 +7663,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7756,7 +7679,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "devOptional": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7775,7 +7697,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "devOptional": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7882,7 +7803,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -8106,7 +8026,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.6" @@ -8206,7 +8125,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "devOptional": true, "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -8221,7 +8139,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -8231,7 +8148,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "devOptional": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -8391,7 +8307,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -8432,7 +8347,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -8461,7 +8375,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -8635,7 +8548,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -8744,7 +8656,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "devOptional": true, "license": "ISC" }, "node_modules/ws": { @@ -8842,7 +8753,6 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -8852,7 +8762,6 @@ "version": "3.24.6", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", - "devOptional": true, "license": "ISC", "peerDependencies": { "zod": "^3.24.1" diff --git a/package.json b/package.json index 3c5a2ac..67e33ac 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "release": "release-script" }, "dependencies": { - "@google/genai": "^1.13.0", + "@google/genai": "^1.20.0", + "@modelcontextprotocol/sdk": "^1.18.1", "@zwave-js/core": "^15.10.0", "yargs": "^18.0.0" }, @@ -63,7 +64,6 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@eslint/js": "^9.32.0", - "@modelcontextprotocol/sdk": "^1.18.1", "@mui/icons-material": "^7.3.1", "@mui/material": "^7.3.1", "@types/node": "^22.17.1", diff --git a/src/lib/ai/analysis-prompt.ts b/src/lib/ai/analysis-prompt.ts index 33ec021..05950ec 100644 --- a/src/lib/ai/analysis-prompt.ts +++ b/src/lib/ai/analysis-prompt.ts @@ -1,91 +1,218 @@ -export const SYSTEM_PROMPT = `You are a logfile analyzer with deep knowledge on Z-Wave JS specific logs. +export const SYSTEM_PROMPT = ` +You are a logfile analyzer with deep knowledge on Z-Wave JS specific logs. -You will be provided with a logfile created by Z-Wave JS and a specific question about the log that you have to fully answer. +You will be provided with: -## IMPORTANT RULES +- A logfile created by Z-Wave JS that you can query through function calls +- One or more specific questions about the log that you have to fully answer -### Reading log files +## Z-Wave Log File Format and Structure -Log files are very long and do not fit into your memory. You MUST read them in chunks of 2000 lines at a time. +Log files are formatted as JSON-lines documents with one log entry per line. The entry kind is indicated by a \`kind\` field in each entry, which can be one of the following: -Analyze each chunk, immediately answer the question for that chunk, and then continue with the next chunk. -Process the entire file and do not stop before you reach the end. +- **INCOMING_COMMAND** - Commands received from a device +- **SEND_DATA_REQUEST** - Indicates that a command is being sent to a device +- **SEND_DATA_RESPONSE** - Indicates whether the command was queued for transmission or not +- **SEND_DATA_CALLBACK** - Indicates whether the command was received by the device and contains additional transmission information +- **REQUEST** - Initiates a command (can be outbound or inbound) +- **RESPONSE** - Quick answer to a REQUEST (always in opposite direction) +- **CALLBACK** - Sent when command execution is complete (correlated by \`callbackId\`) +- **VALUE_ADDED** - New value discovered +- **VALUE_UPDATED** - Existing value changed +- **VALUE_REMOVED** - Value removed +- **METADATA_UPDATED** - Metadata changed +- **BACKGROUND_RSSI** - Single background RSSI measurement +- **BACKGROUND_RSSI_SUMMARY** - Aggregate of multiple successive RSSI measurements +- **OTHER** - Other log entries -It is of utmost importance that you follow these rules, as they ensure that you do not miss any important information in the log file. +## Z-Wave Communication Patterns -Before ending your analysis, make sure you have read the entire file and processed all chunks. +### Outgoing Commands to Nodes -If applicable, you may summarize the results of your analysis at the end, but do not do so before you have processed the entire file. +Outgoing commands to a node typically appear in a sequence of three entries: -### Searching for log files +1. **SEND_DATA_REQUEST** - Command being sent to device +2. **SEND_DATA_RESPONSE** - Whether command was queued for transmission +3. **SEND_DATA_CALLBACK** - Whether command was received by device (correlated by \`callbackId\`) -It is possible that the user provides you with a path to a log file. In that case, use the search tool to find the file and read it. +Note: If the callback ID of the SEND_DATA_REQUEST is 0, or the SEND_DATA_RESPONSE indicates failure, there will be no SEND_DATA_CALLBACK entry. -### Responding to the user +### Controller Commands -When responding to the user, only answer what you were asked. Do not annoy them with your internal TODO lists or comments on what you are doing. +Commands for controller communication use REQUEST/RESPONSE/CALLBACK pattern: -You are allowed to ask clarifying questions. +- **REQUEST** initiates a command (outbound or inbound) +- **RESPONSE** provides quick answer (opposite direction of REQUEST) +- **CALLBACK** indicates command completion (correlated by \`callbackId\`) -If the user does not specify a log file or you cannot find it, ask the user to provide the path to the log file. +## Signal Quality and RSSI Analysis -If the user does not specify a question, ask them to provide a specific question about the log file, or ask them if you should look for common issues in the log file. +Z-Wave communication is wireless, making signal strength (RSSI) and background noise critical for reliability. -### Editing files +### Background RSSI -If the user asks you to edit or write a file, you can do so. If not, under no circumstances should you edit or write files. Your primary task is to analyze the log file and answer the user's questions, not writing code. +- Reported per channel as BACKGROUND_RSSI (single) or BACKGROUND_RSSI_SUMMARY (aggregate) +- Should be as low as possible, ideally close to hardware sensitivity: + - **500 series controllers**: -94 dBm + - **700 series controllers**: -100 dBm + - **800 series controllers**: -110 dBm -## Log file format +### Command RSSI -Log files are formatted as JSON-lines documents with one log entry per line. The entry kind is indicated by a \`kind\` field in each entry, which can be one of the following: +- Should be as high as possible +- **Link budget** (RSSI - background RSSI) should ideally be at least 10 dB + +### Z-Wave Long Range Measurements + +Long Range Send Data callbacks contain additional measurements: + +- **TX power** - Controller transmit power to end device +- **measured RSSI of ACK from destination** - Signal strength at end device +- **measured noise floor by destination** - Background RSSI at end device during reception +- **ACK TX power** - End device transmit power for ACK +- **ACK RSSI** - ACK signal strength measured at controller +- **measured noise floor** - Background RSSI at controller during ACK reception + +These measurements help detect one-directional communication issues due to noise or interference. + +## Common Issues + +Certain issues are common in Z-Wave networks and can often have diverse symptoms. Spend some time looking for them, before investigating other leads: + +- **High background RSSI (signal noise)**: + Can prevent commands from being received, or cause data corruption when no encryption is used. Channel 0 is the primary communication channel for mesh devices (node ID <= 232), channel 3 is relevant for Long Range devices (node ID >= 256). + +- **Low link budget**: + Can cause commands or acknowledgements to not be received, triggering re-transmits. Look for commands with RSSI values close to recent background RSSI values. + +- **Too frequent reports / Too much traffic**: + Can cause signal noise and prevent some devices from communicating entirely. This is especially problematic when the devices are connected through one or more repeaters, as these multiply the traffic on the network. Identify devices that report very frequently by looking at their mean unsolicited report interval. A mean <5 can be a significant problem, <15 is worth investigating. + A low median interval is not necessarily a problem if the mean is high, as this indicates that the device is mostly quiet, but occasionally sends bursts of reports. This is usually not a problem. + +- **Unnecessary reports**: + Lead to too much traffic on the network. Reasons can be: + + - Reporting based on fixed, small intervals, even without changes in sensor values. + - Too small reporting thresholds for changes in sensor values. + - Reporting too many, unnecessary values, e.g. W, kWh, VAr, VArh, V, A, ... for power meters, even though only W is actually used. + +- **Bad connections**: + Unless used with very old devices, Z-Wave typically uses 100 kbps for communication and falls back to 40 or 9.6 kbps when the connection is poor. This is often an indicator for weak signal strength. Look for devices that frequently fall back to lower speeds or don't use 100 kbps at all. + Other indicators are: + - Frequent re-transmit attempts for outgoing commands (transmit attempts consistently > 1) + - Large amount of repeaters in the route (the majority of cases should be direct communication, or through one repeater at most) + - Slow transmits (>100ms) for outgoing commands, especially when combined with multiple transmit attempts + - Frequent timeouts for Get requests + +## Analysis Tools + +The following tools are available for Z-Wave log analysis: + +### Core Tools + +- **getLogSummary** - Get overall statistics about the entire log including total entries, time range, node IDs, and network activity + +### Node Analysis + +- **getNodeSummary** - Get traffic and signal quality summary for a specific node including RSSI statistics and unsolicited report intervals, as well as their supported command classes +- **getNodeCommunication** - Enumerate communication attempts with a specific node over a time range, with direction filtering and pagination support -- INCOMING_COMMAND -- SEND_DATA_REQUEST -- SEND_DATA_RESPONSE -- SEND_DATA_CALLBACK -- REQUEST -- RESPONSE -- CALLBACK -- VALUE_ADDED -- VALUE_UPDATED -- VALUE_REMOVED -- METADATA_UPDATED -- BACKGROUND_RSSI -- BACKGROUND_RSSI_SUMMARY -- OTHER +### Time-based Analysis -## Different log entries +- **getEventsAroundTimestamp** - Enumerate all log entries around a specific timestamp with optional type filtering and pagination +- **getBackgroundRSSIBefore** - Get the most recent background RSSI reading before a specific timestamp, with optional maximum age limit -Incoming commands are commands received from a device. +### Search and Exploration -Outgoing commands to a node are indicated by the SEND_DATA_* entries. These typically appear in a sequence of three entries: +- **searchLogEntries** - Search log entries by keyword/text/regex with optional type and time filtering, supports pagination +- **getLogChunk** - Read specific ranges of log entries by index with pagination support -1. A SEND_DATA_REQUEST entry, which indicates that a command is being sent to a device. -2. A SEND_DATA_RESPONSE entry, which indicates whether the command was queued for transmission or not. -3. A SEND_DATA_CALLBACK entry, which indicates whether the command was received by the device and contains additional information about the command transmission. The request and the callback are correlated by the \`callbackId\` field in the SEND_DATA_REQUEST and SEND_DATA_CALLBACK entries. If the callback ID of the SEND_DATA_REQUEST is 0, or the SEND_DATA_RESPONSE indicates that the command was not sent, there will be no SEND_DATA_CALLBACK entry. +## Workflow -Commands that are not sent to a device but instead indicate communication with the controller itself are indicated by REQUEST, RESPONSE, and CALLBACK entries. A REQUEST initiates a command, which is typically answered quickly by a RESPONSE. If the command execution is short, the RESPONSE will be the end of the command. If the command execution is longer, a CALLBACK will be sent when the command execution is complete. The REQUEST and CALLBACK are correlated by the \`callbackId\` field in the REQUEST and CALLBACK entries. This sequence can happen in both directions, meaning the REQUEST can be outbound (from Z-Wave JS to the controller) or inbound (from the controller to Z-Wave JS). The RESPONSE and CALLBACKs are always in the opposite direction of the REQUEST. +1. Always start by calling \`getLogSummary\` to get an overview of the entire log. Assume that the logfile has already been loaded for you. +2. Use node-specific tools (\`getNodeSummary\`, \`getNodeCommunication\`) to analyze individual devices +3. Use search tools (\`searchLogEntries\`) to find specific patterns or issues +4. Use time-based tools (\`getEventsAroundTimestamp\`, \`getBackgroundRSSIBefore\`) for temporal analysis +5. Use \`getLogChunk\` when you need to examine specific ranges of log entries -## RSSI +## Usage Examples -Z-Wave communication is wireless, and both the signal strength (RSSI) and the signal noise (background RSSI) are important for the reliability of the communication. Z-Wave JS regularly measures the background RSSI, and incoming commands may contain the RSSI of the command itself. +When building queries, consider which parameters are optional depending on the question to answer. Start as broad as possible and use pagination to explore the results. Then narrow down the query step by step. -The background RSSI is reported per channel as either BACKGROUND_RSSI entries for single measurements, or as BACKGROUND_RSSI_SUMMARY entries as an aggregate of multiple successive measurements. -It is desirable for the background RSSI to be as low as possible, ideally close to the sensitivity of the hardware, which is: +Some examples of common queries follow: -- -94 dBm for 500 series controllers -- -100 dBm for 700 series controllers -- -110 dBm for 800 series controllers +**Question**: Find incoming Binary Sensor reports +**Query**: +\`\`\` +searchLogEntries({ +query: "BinarySensorCCReport", +entryKinds: ["INCOMING_COMMAND"], +limit: 50 +}) +\`\`\` -The RSSI of commands should be as high as possible. The difference between RSSI and background RSSI is called "link budget" or "signal to noise margin" and should ideally be at least 10 dB. +**Question**: Find transmit attempts that failed immediately. +**Query**: +\`\`\` +searchLogEntries({ +query: "transmit status.\\*Fail, took 0 ms", +entryKinds: ["SEND_DATA_CALLBACK"] +}) +\`\`\` -The callbacks for Z-Wave Long Range Send Data commands also contain a series of measurements, both at the controller and the end device. Specifically: +**Question**: Which nodes have a very low reporting interval? +**Query**: Use the getNodeSummary tool repeatedly and look at the unsolicitedReportIntervals -- \`TX power\` is the transmit power the controller used to send the command to the end device -- \`measured RSSI of ACK from destination\` is the signal strength of the outgoing command, measured at the end device -- \`measured noise floor by destination\` is the background RSSI at the end device while receiving the command -- \`ACK TX power\` is the transmit power the end device used to send the ACK back to the controller -- \`ACK RSSI\` is the signal strength of the ACK from the end device, measured at the controller -- \`measured noise floor\` is the background RSSI at the controller when the ACK was received +**Question**: Find all temperature sensor readings above 25°C +**Query**: +\`\`\` +searchLogEntries({ +query: "temperature._2[5-9]\\.|temperature._[3-9]\\d+", +entryKinds: ["VALUE_UPDATED", "VALUE_ADDED"] +}) +\`\`\` -These give an additional insight and allow detecting one-directional communication issues due to noise or interference.`; +**Question**: Investigate communication issues around a specific timestamp +**Query**: +\`\`\` +getEventsAroundTimestamp({ +timestamp: "2025-09-21T14:30:00.000Z", +beforeSeconds: 120, +afterSeconds: 120, +entryKinds: ["SEND_DATA_CALLBACK", "SEND_DATA_REQUEST"] +}) +\`\`\` + +**Question**: Check signal quality for node 15 during recent activity +**Query**: +\`\`\` +getNodeCommunication({ +nodeId: 15, +limit: 50 +}) +\`\`\` + +**Question**: Find devices that frequently use lower data rates (indicating poor connection) +**Query**: +\`\`\` +searchLogEntries({ +query: "route speed.\\*(9.6|40) kbit/s", +entryKinds: ["SEND_DATA_CALLBACK"] +}) +\`\`\` + +## Analysis Reporting + +Do not bother the user with intermediate findings and your thoughts. Keep them to yourself until you have completed the entire analysis. + +When presenting analysis findings: + +- Start with a brief executive summary of key findings +- Present evidence systematically with timestamps and node IDs +- Explain the significance of patterns or anomalies discovered +- Provide specific recommendations based on analysis +- Include relevant data points (RSSI values, timing, error counts, etc.) +- Use clear headings to organize different aspects of analysis + +Remember: Your goal is to provide thorough, actionable insights about Z-Wave network behavior, communication patterns, and any issues present in the log data. +`; diff --git a/src/lib/ai/gemini-client.ts b/src/lib/ai/gemini-client.ts index d384bb7..37fb010 100644 --- a/src/lib/ai/gemini-client.ts +++ b/src/lib/ai/gemini-client.ts @@ -3,23 +3,93 @@ import { createUserContent, createPartFromUri, Chat, + mcpToTool, + type Part, } from "@google/genai"; -import type { GeminiConfig, GeminiFileInfo, TransformedLog } from "../types.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { + EventTargetTransport, + createEventTargetTransportPair, +} from "../eventtarget-transport.js"; +import { ZWaveLogMCPServerCore } from "../zwave-mcp-server-core.js"; +import type { GeminiConfig, GeminiFileInfo } from "../types.js"; import { SYSTEM_PROMPT } from "./analysis-prompt.js"; // Gemini model constant -export const GEMINI_MODEL_ID = "gemini-2.5-pro"; +export const GEMINI_MODEL_ID = "gemini-2.5-flash"; export class GeminiLogAnalyzer { private genAI: GoogleGenAI; private modelName: string; private systemPromptFile: GeminiFileInfo | null = null; - private logFile: GeminiFileInfo | null = null; private chatSession: Chat | null = null; + private mcpClient: Client; + private mcpServer: ZWaveLogMCPServerCore; + private clientTransport: EventTargetTransport; + private serverTransport: EventTargetTransport; + private hasLoadedLogFile = false; + + // Used to store parts of a streamed response that contain thought signatures + private thoughtParts: Part[] = []; constructor(config: GeminiConfig) { + console.log( + "Initializing Gemini Log Analyzer with model:", + config.model, + ); this.genAI = new GoogleGenAI({ apiKey: config.apiKey }); this.modelName = config.model; + + // Create transport pair for in-browser communication + const transportPair = createEventTargetTransportPair(); + this.clientTransport = transportPair.clientTransport; + this.serverTransport = transportPair.serverTransport; + + // Create SDK client with the transport + this.mcpClient = new Client( + { + name: "zwave-log-client", + version: "0.0.2", + }, + { + capabilities: {}, + }, + ); + + // Create and configure server + this.mcpServer = new ZWaveLogMCPServerCore(); + + // Initialize the client connection + console.log("Starting MCP client connection..."); + this.connect().catch((error: Error) => { + console.error("Failed to connect MCP client:", error); + }); + } + + /** + * Connect the MCP client to the server + */ + private async connect(): Promise { + console.log("Connecting MCP client to server..."); + // Connect client to its transport + await this.mcpClient.connect(this.clientTransport); + console.log("MCP client connected to transport"); + + // Connect server to its transport + await this.mcpServer.getServer().connect(this.serverTransport); + console.log("MCP server connected to transport"); + } + + /** + * Disconnect the MCP client and server + */ + async disconnect(): Promise { + try { + await this.mcpClient.close(); + await this.mcpServer.getServer().close(); + } catch (error) { + console.error("Error disconnecting MCP client/server:", error); + } } /** @@ -51,57 +121,6 @@ export class GeminiLogAnalyzer { } } - /** - * Upload a transformed log file to Gemini and store the file URI - */ - async uploadLogFile( - transformedLog: TransformedLog, - ): Promise { - try { - // Convert log entries to JSON lines format - const jsonLines = transformedLog.entries - .map((entry) => JSON.stringify(entry)) - .join("\n"); - - const response = await this.genAI.files.upload({ - file: new Blob([jsonLines], { type: "text/plain" }), - config: { mimeType: "text/plain" }, - }); - - if (!response.uri) { - throw new Error("No URI returned from file upload"); - } - - this.logFile = { - name: response.name!, - uri: response.uri, - mimeType: response.mimeType!, - }; - - return this.logFile; - } catch (error) { - console.error("Failed to upload log file:", error); - throw new Error( - `Log file upload failed: ${(error as Error).message}`, - ); - } - } - - /** - * Remove the log file from Gemini and end any active chat session - */ - async deleteLogFile(): Promise { - if (!this.logFile) return; - - try { - await this.genAI.files.delete({ name: this.logFile.uri }); - this.logFile = null; - this.endChatSession(); // End chat session when log file is deleted - } catch (error) { - console.error("Failed to delete log file:", error); - } - } - /** * Count tokens for the current configuration */ @@ -119,13 +138,6 @@ export class GeminiLogAnalyzer { ); } - // Add log file if available - if (this.logFile) { - parts.push( - createPartFromUri(this.logFile.uri, this.logFile.mimeType), - ); - } - // Add user query parts.push({ text: query }); @@ -143,6 +155,7 @@ export class GeminiLogAnalyzer { /** * Create a new chat session with the system prompt and log file in history + * Uses the mcpToTool wrapper for proper MCP integration */ async createChatSession(): Promise { if (!this.systemPromptFile) { @@ -151,12 +164,7 @@ export class GeminiLogAnalyzer { ); } - if (!this.logFile) { - throw new Error("Please upload a log file first"); - } - try { - // Create chat session using chats.create with system prompt and log file in history this.chatSession = this.genAI.chats.create({ model: this.modelName, history: [ @@ -167,12 +175,8 @@ export class GeminiLogAnalyzer { this.systemPromptFile.uri, this.systemPromptFile.mimeType, ), - createPartFromUri( - this.logFile.uri, - this.logFile.mimeType, - ), { - text: `Follow the instructions in ${this.systemPromptFile.name} to analyze the log file in ${this.logFile.name} and answer the user's query about the log file.`, + text: "Follow the instructions to analyze Z-Wave log files using the available MCP tools and answer the user's query about the log file.", }, { text: `--- USER QUERIES:`, @@ -180,6 +184,12 @@ export class GeminiLogAnalyzer { ], }, ], + config: { + // thinkingConfig: { + // includeThoughts: true, + // }, + tools: [mcpToTool(this.mcpClient)], + }, }); } catch (error) { console.error("Failed to create chat session:", error); @@ -191,6 +201,7 @@ export class GeminiLogAnalyzer { /** * Send a message to the existing chat session + * MCP tools are automatically handled by the SDK */ async *sendChatMessage( query: string, @@ -202,16 +213,53 @@ export class GeminiLogAnalyzer { } try { + console.log("Sending message to chat session:", query); + // Use the chat session's sendMessageStream method + // The SDK automatically handles MCP tool calls + const thoughtParts = this.thoughtParts.splice( + 0, + this.thoughtParts.length, + ); + console.log("Followup query with thought parts:", thoughtParts); const response = await this.chatSession.sendMessageStream({ - message: query, + message: [...thoughtParts, query], }); - for await (const part of response) { - if (part.text) { - yield part.text; + console.log("Processing chat response stream..."); + for await (const chunk of response) { + // Log any tool calls that are being made + if (chunk.functionCalls) { + console.log( + "AI is making function calls:", + chunk.functionCalls.map((fc) => fc.name), + ); + } + const parts = chunk.candidates?.[0]?.content?.parts; + if (!parts || parts.length === 0) continue; + + // // The first part possibly contains a thought signature + // if (parts[0]!.thoughtSignature) { + // console.log( + // "AI thought signature:", + // parts[0]!.thoughtSignature, + // ); + // this.thoughtParts.push(parts[0]!); + // } + + for (const part of parts) { + // if part.candidates[0]!.content?.parts + if (part.thought) { + console.log("AI thought:", part.text); + continue; + } + if (part.text) { + yield part.text; + } } } + + console.log("Chat response stream completed"); } catch (error) { console.error("Chat message error:", error); throw new Error( @@ -261,6 +309,36 @@ export class GeminiLogAnalyzer { this.chatSession = null; } + /** + * Enable or disable tool calling + */ + /** + * Get the MCP client instance + */ + getMCPClient(): Client { + return this.mcpClient; + } + + /** + * Get the MCP server core for direct access + */ + getMCPServer(): ZWaveLogMCPServerCore { + return this.mcpServer; + } + + /** + * Load log content directly into the MCP server + */ + async loadLogContentForToolCalling(logContent: string): Promise { + console.log( + "Loading log content for tool calling, size:", + logContent.length, + ); + await this.mcpServer.loadLogFileFromContent(logContent); + this.hasLoadedLogFile = true; + console.log("Log content loaded successfully for tool calling"); + } + /** * Check if there's an active chat session */ @@ -286,7 +364,8 @@ export class GeminiLogAnalyzer { * Check if log file is uploaded */ hasLogFile(): boolean { - return this.logFile !== null; + // In tool calling mode, we track if we've loaded a log file + return this.hasLoadedLogFile; } /** @@ -294,11 +373,9 @@ export class GeminiLogAnalyzer { */ getFileInfo(): { systemPrompt: GeminiFileInfo | null; - logFile: GeminiFileInfo | null; } { return { systemPrompt: this.systemPromptFile, - logFile: this.logFile, }; } } diff --git a/src/lib/app-state.ts b/src/lib/app-state.ts index 88fda28..605c0d1 100644 --- a/src/lib/app-state.ts +++ b/src/lib/app-state.ts @@ -92,14 +92,12 @@ export type AppAction = // Derived state selectors export const selectors = { canSendMessage: (state: ApplicationState): boolean => { - return ( - state.apiKeyState === "exists" && + return state.apiKeyState === "exists" && state.logFileState === "attached" && state.userQueryState === "not-empty" && state.uiState !== "waiting-for-ai-response" && state.uiState !== "ai-responding" && - state.tokenCounts.total <= 1000000 - ); + state.tokenCounts.total <= 1000000; }, isUploading: (state: ApplicationState): boolean => { diff --git a/src/lib/eventtarget-transport.ts b/src/lib/eventtarget-transport.ts new file mode 100644 index 0000000..54a63d9 --- /dev/null +++ b/src/lib/eventtarget-transport.ts @@ -0,0 +1,281 @@ +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import type { + Transport, + TransportSendOptions, +} from "@modelcontextprotocol/sdk/shared/transport.d.ts"; + +/** + * EventTarget-based MCP transport for browser compatibility. + * + * This transport uses EventTarget to enable communication between an MCP client + * and server running in the same browser context. + */ +export class EventTargetTransport implements Transport { + private _eventTarget: EventTarget; + private _isStarted = false; + private _isClosed = false; + + public sessionId?: string; + public onclose?: () => void; + public onerror?: (error: Error) => void; + public onmessage?: (message: JSONRPCMessage, extra?: any) => void; + + constructor(eventTarget: EventTarget) { + this._eventTarget = eventTarget; + this.sessionId = `session-${Math.random().toString(36).substring(7)}`; + + // Listen for messages on the event target + this._eventTarget.addEventListener( + "message", + this._handleMessage.bind(this), + ); + this._eventTarget.addEventListener( + "error", + this._handleError.bind(this), + ); + this._eventTarget.addEventListener( + "close", + this._handleClose.bind(this), + ); + } + + private _handleMessage(event: Event): void { + if (!(event instanceof CustomEvent)) return; + + const { message, extra } = event.detail; + if (this.onmessage) { + this.onmessage(message, extra); + } + } + + private _handleError(event: Event): void { + if (!(event instanceof CustomEvent)) return; + + const error = event.detail.error; + if (this.onerror) { + this.onerror(error); + } + } + + private _handleClose(): void { + this._isClosed = true; + if (this.onclose) { + this.onclose(); + } + } + + async start(): Promise { + if (this._isStarted) { + throw new Error("Transport is already started"); + } + if (this._isClosed) { + throw new Error("Transport is closed"); + } + + this._isStarted = true; + + // Dispatch a start event to signal the transport is ready + this._eventTarget.dispatchEvent( + new CustomEvent("start", { + detail: { sessionId: this.sessionId }, + }), + ); + } + + async send( + message: JSONRPCMessage, + options?: TransportSendOptions, + ): Promise { + if (!this._isStarted) { + throw new Error("Transport not started"); + } + if (this._isClosed) { + throw new Error("Transport is closed"); + } + + // Dispatch the message as a custom event + this._eventTarget.dispatchEvent( + new CustomEvent("outgoing-message", { + detail: { + message, + options, + sessionId: this.sessionId, + }, + }), + ); + } + + async close(): Promise { + if (this._isClosed) { + return; + } + + this._isClosed = true; + this._isStarted = false; + + // Clean up event listeners + this._eventTarget.removeEventListener( + "message", + this._handleMessage.bind(this), + ); + this._eventTarget.removeEventListener( + "error", + this._handleError.bind(this), + ); + this._eventTarget.removeEventListener( + "close", + this._handleClose.bind(this), + ); + + // Dispatch close event + this._eventTarget.dispatchEvent( + new CustomEvent("close", { + detail: { sessionId: this.sessionId }, + }), + ); + + if (this.onclose) { + this.onclose(); + } + } + + setProtocolVersion?(version: string): void { + // Store the protocol version if needed + // For now, we'll just dispatch an event to notify about the version + this._eventTarget.dispatchEvent( + new CustomEvent("protocol-version", { + detail: { version, sessionId: this.sessionId }, + }), + ); + } +} + +/** + * Creates a pair of EventTarget-based transports for client-server communication. + * + * This enables MCP communication within the browser without requiring separate processes. + */ +export function createEventTargetTransportPair(): { + clientTransport: EventTargetTransport; + serverTransport: EventTargetTransport; + bridge: EventTargetTransportBridge; +} { + const bridge = new EventTargetTransportBridge(); + const clientTransport = new EventTargetTransport(bridge.clientEventTarget); + const serverTransport = new EventTargetTransport(bridge.serverEventTarget); + + return { clientTransport, serverTransport, bridge }; +} + +/** + * Bridge that connects two EventTarget transports to enable bidirectional communication. + */ +class EventTargetTransportBridge { + public readonly clientEventTarget: EventTarget; + public readonly serverEventTarget: EventTarget; + + constructor() { + this.clientEventTarget = new EventTarget(); + this.serverEventTarget = new EventTarget(); + + // Bridge outgoing messages from client to server + this.clientEventTarget.addEventListener("outgoing-message", (event) => { + if (!(event instanceof CustomEvent)) return; + + const { message } = event.detail; + this._logMCPMessage("Client → Server", message); + + this.serverEventTarget.dispatchEvent( + new CustomEvent("message", { + detail: event.detail, + }), + ); + }); + + // Bridge outgoing messages from server to client + this.serverEventTarget.addEventListener("outgoing-message", (event) => { + if (!(event instanceof CustomEvent)) return; + + const { message } = event.detail; + this._logMCPMessage("Server → Client", message); + + this.clientEventTarget.dispatchEvent( + new CustomEvent("message", { + detail: event.detail, + }), + ); + }); + } + + /** + * Log MCP messages for debugging tool calls + */ + private _logMCPMessage(direction: string, message: any): void { + if (!message || typeof message !== 'object') return; + + // Log tool calls (requests from client to server) + if (message.method === 'tools/call') { + console.log(`🔧 [MCP ${direction}] Tool Call:`, { + method: message.method, + toolName: message.params?.name, + arguments: message.params?.arguments, + id: message.id + }); + } + // Log tool call responses (server to client) + else if (message.result !== undefined && message.id) { + // Check if this is a response to a tool call by examining the result structure + if (message.result?.content || message.result?.isError) { + console.log(`✅ [MCP ${direction}] Tool Response:`, { + id: message.id, + hasContent: !!message.result?.content, + isError: !!message.result?.isError, + contentLength: message.result?.content?.length || 0 + }); + + // Log first bit of content for debugging + if (message.result?.content && Array.isArray(message.result.content)) { + const firstContent = message.result.content[0]; + if (firstContent?.text) { + const preview = firstContent.text.substring(0, 200); + console.log(`📝 [MCP ${direction}] Content Preview:`, preview + (firstContent.text.length > 200 ? "..." : "")); + } + } + } + } + // Log tool list requests/responses + else if (message.method === 'tools/list') { + console.log(`📋 [MCP ${direction}] Tools List Request:`, { id: message.id }); + } + else if (message.result?.tools) { + console.log(`📋 [MCP ${direction}] Tools List Response:`, { + id: message.id, + toolCount: message.result.tools.length, + tools: message.result.tools.map((t: any) => t.name) + }); + } + // Log errors + else if (message.error) { + console.error(`❌ [MCP ${direction}] Error:`, { + id: message.id, + error: message.error + }); + } + // Log other MCP methods for completeness + else if (message.method) { + console.log(`📨 [MCP ${direction}] Method:`, { + method: message.method, + id: message.id, + hasParams: !!message.params + }); + } + } + + /** + * Cleanup method to remove all event listeners + */ + cleanup(): void { + // The event listeners will be cleaned up when the transports are closed + // since they handle their own cleanup + } +} diff --git a/src/lib/log-query-engine.ts b/src/lib/log-query-engine.ts index 5bcfeb1..a097bdf 100644 --- a/src/lib/log-query-engine.ts +++ b/src/lib/log-query-engine.ts @@ -43,7 +43,7 @@ export interface AttributeFilter { } export interface SearchLogEntriesArgs { - query: string; + query?: string; entryKinds?: SemanticLogKind[]; timeRange?: { start: string; @@ -173,6 +173,7 @@ export interface SearchResults { matches: Array; totalMatches: number; hasMore: boolean; + error?: string; } export interface LogChunk { @@ -1338,11 +1339,33 @@ export class LogQueryEngine { offset = 0, } = args; - // Start with text search results - let searchIndices = this.indexes.textSearchIndex.search(query); + // Validate that at least one of the required parameters is provided + const hasQuery = typeof query === "string" && query.trim().length > 0; + const hasEntryKinds = Array.isArray(entryKinds) && entryKinds.length > 0; + const hasTimeRange = timeRange && typeof timeRange === "object" && timeRange.start && timeRange.end; + const hasAttributeFilters = Array.isArray(attributeFilters) && attributeFilters.length > 0; + + if (!hasQuery && !hasEntryKinds && !hasTimeRange && !hasAttributeFilters) { + return { + query: query || "", + matches: [], + totalMatches: 0, + hasMore: false, + error: "At least one of the following parameters must be provided: query, entryKinds, timeRange, or attributeFilters" + }; + } + + // Start with text search results or all entries if no query + let searchIndices: number[]; + if (hasQuery) { + searchIndices = this.indexes.textSearchIndex.search(query); + } else { + // If no query, start with all entries + searchIndices = Array.from({ length: this.entries.length }, (_, i) => i); + } // Apply time range filter using TimeRangeIndex for efficiency - if (timeRange) { + if (hasTimeRange) { const timeRangeIndices = this.indexes.timeRangeIndex.findEntriesInRange( timeRange.start, @@ -1355,7 +1378,7 @@ export class LogQueryEngine { } // Apply entry kind filter if specified - if (entryKinds && entryKinds.length > 0) { + if (hasEntryKinds) { searchIndices = searchIndices.filter((index: number) => { const entry = this.entries[index]; // Allow substring matching - check if any provided kind is a substring of the actual entry kind @@ -1368,7 +1391,7 @@ export class LogQueryEngine { } // Apply attribute filters if specified - if (attributeFilters && attributeFilters.length > 0) { + if (hasAttributeFilters) { searchIndices = searchIndices.filter((index: number) => { const entry = this.entries[index]; return this.passesAttributeFilters(entry, attributeFilters); @@ -1383,7 +1406,7 @@ export class LogQueryEngine { const paginatedMatches = paginatedIndices.map((i: number) => this.entries[i]); return { - query, + query: query || "", matches: paginatedMatches, totalMatches, hasMore: offset + limit < totalMatches, diff --git a/src/lib/use-app-state.ts b/src/lib/use-app-state.ts index c15f4db..85bbe16 100644 --- a/src/lib/use-app-state.ts +++ b/src/lib/use-app-state.ts @@ -49,7 +49,7 @@ export function useAppState() { } }, []); - // Initialize analyzer when API key is set + // Initialize analyzer when API key changes useEffect(() => { if (state.apiKey) { initializeAnalyzer(state.apiKey); @@ -122,8 +122,9 @@ export function useAppState() { payload: transformedLog, }); - // Upload to Gemini - await state.analyzer.uploadLogFile(transformedLog); + // For function calling mode, load directly into MCP client + await state.analyzer.loadLogContentForToolCalling(content); + dispatch({ type: "SET_LOG_FILE_STATE", payload: "attached", @@ -158,11 +159,11 @@ export function useAppState() { dispatch({ type: "SET_ATTACHED_FILE_NAME", payload: "" }); dispatch({ type: "UPDATE_TOKEN_COUNTS", payload: { logFile: 0 } }); - // Delete from backend in background + // Clean up MCP client data in background try { - await state.analyzer.deleteLogFile(); + state.analyzer.disconnect(); } catch (err) { - console.error("Failed to delete log file from backend:", err); + console.error("Failed to cleanup MCP client:", err); // Don't show error to user since UI is already updated } }, [state.analyzer]), @@ -176,11 +177,6 @@ export function useAppState() { payload: "Please configure your API key first", }); dispatch({ type: "SET_SETTINGS_OPEN", payload: true }); - } else if (state.logFileState !== "attached") { - dispatch({ - type: "SET_ERROR", - payload: "Please upload a log file first", - }); } return; } @@ -301,7 +297,7 @@ export function useAppState() { await analyzer.endChatSession(); } if (state.logFileState === "attached") { - await analyzer.deleteLogFile(); + analyzer.disconnect(); } } catch (err) { console.error("Failed to cleanup during reset:", err); diff --git a/src/lib/zwave-log-analyzer.ts b/src/lib/zwave-log-analyzer.ts index f46a817..04dc7e4 100644 --- a/src/lib/zwave-log-analyzer.ts +++ b/src/lib/zwave-log-analyzer.ts @@ -1,5 +1,4 @@ import { readFile } from "node:fs/promises"; -import { LogTransformPipeline } from "./log-processor/index.js"; import { GeminiLogAnalyzer, GEMINI_MODEL_ID } from "./ai/gemini-client.js"; import { LogQueryEngine } from "./log-query-engine.js"; import type { @@ -16,7 +15,6 @@ import type { * This is the main class that users should interact with. */ export class ZWaveLogAnalyzer { - private pipeline: LogTransformPipeline; private analyzer: GeminiLogAnalyzer; private queryEngine: LogQueryEngine | null = null; private initialized = false; @@ -26,7 +24,6 @@ export class ZWaveLogAnalyzer { * @param apiKey - Your Google Gemini API key */ constructor(apiKey: string) { - this.pipeline = new LogTransformPipeline(); this.analyzer = new GeminiLogAnalyzer({ apiKey, model: GEMINI_MODEL_ID, @@ -76,23 +73,17 @@ export class ZWaveLogAnalyzer { query: string, ): AsyncGenerator { try { - // Process the log content - const transformedLog = - await this.pipeline.processLogContent(logContent); - - // Initialize the query engine for tool access - this.queryEngine = new LogQueryEngine(transformedLog); - // Initialize the AI analyzer if not already done if (!this.initialized) { await this.analyzer.uploadSystemPrompt(); this.initialized = true; } - // Upload the transformed log file - await this.analyzer.uploadLogFile({ - entries: transformedLog, - }); + // Load log content directly into the MCP server via the analyzer + await this.analyzer.loadLogContentForToolCalling(logContent); + + // Get the query engine from the analyzer's server + this.queryEngine = this.analyzer.getMCPServer().getQueryEngine(); // Perform the analysis and stream results yield* this.analyzer.sendFirstChatMessage(query); diff --git a/src/lib/zwave-mcp-server-core.ts b/src/lib/zwave-mcp-server-core.ts new file mode 100644 index 0000000..aa12770 --- /dev/null +++ b/src/lib/zwave-mcp-server-core.ts @@ -0,0 +1,654 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + ErrorCode, + ListToolsRequestSchema, + McpError, +} from "@modelcontextprotocol/sdk/types.js"; +import { LogQueryEngine } from "./log-query-engine.js"; +import { LogTransformPipeline } from "./log-processor/index.js"; + +/** + * Shared MCP server implementation for Z-Wave log analysis. + * This can be used with different transports (stdio for CLI, EventTarget for browser). + */ +export class ZWaveLogMCPServerCore { + private server: Server; + private queryEngine: LogQueryEngine | null = null; + private pipeline: LogTransformPipeline; + + constructor() { + this.server = new Server( + { name: "zwave-log-analyzer", version: "1.0.0" }, + { capabilities: { tools: {} } }, + ); + + this.pipeline = new LogTransformPipeline(); + this.setupRequestHandlers(); + } + + /** + * Get the underlying MCP Server instance + */ + getServer(): Server { + return this.server; + } + + /** + * Get the query engine instance (if available) + */ + getQueryEngine(): LogQueryEngine | null { + return this.queryEngine; + } + + /** + * Initialize the query engine, throwing an error if no log is loaded + */ + private async initializeQueryEngine(): Promise { + if (!this.queryEngine) { + throw new McpError( + ErrorCode.InvalidRequest, + "No log file loaded. Use the loadLogFile tool to load a log file first.", + ); + } + } + + /** + * Load log file from file path (for CLI usage) + */ + private async loadLogFileFromPath(filePath: string): Promise { + const { readFile } = await import("node:fs/promises"); + const logContent = await readFile(filePath, "utf-8"); + const transformedEntries = await this.pipeline.processLogContent(logContent); + this.queryEngine = new LogQueryEngine(transformedEntries); + } + + /** + * Load log file from content string (for browser usage) + */ + public async loadLogFileFromContent(logContent: string): Promise { + const transformedEntries = await this.pipeline.processLogContent(logContent); + this.queryEngine = new LogQueryEngine(transformedEntries); + } + + /** + * Set up all the MCP request handlers + */ + private setupRequestHandlers(): void { + // List tools handler + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + { + name: "getLogSummary", + description: + "Get overall statistics about the entire Z-Wave log including total entries, time range, node IDs, network activity broken down by node, and network-wide unsolicited report intervals", + inputSchema: { + type: "object", + properties: {}, + required: [], + }, + }, + { + name: "getNodeSummary", + description: + "Get traffic and signal quality summary for a specific node including RSSI statistics, unsolicited report intervals, and command classes used by the node", + inputSchema: { + type: "object", + properties: { + nodeId: { + type: "number", + description: "The Z-Wave node ID to analyze", + }, + timeRange: { + type: "object", + description: + "Optional time range to filter analysis", + properties: { + start: { + type: "string", + description: + "Start timestamp in ISO format", + }, + end: { + type: "string", + description: + "End timestamp in ISO format", + }, + }, + }, + }, + required: ["nodeId"], + }, + }, + { + name: "getNodeCommunication", + description: + "Enumerate communication attempts with a specific node over a time range, with direction filtering and pagination support", + inputSchema: { + type: "object", + properties: { + nodeId: { + type: "number", + description: "The Z-Wave node ID to analyze", + }, + timeRange: { + type: "object", + description: + "Optional time range to filter communications", + properties: { + start: { + type: "string", + description: + "Start timestamp in ISO format", + }, + end: { + type: "string", + description: + "End timestamp in ISO format", + }, + }, + }, + direction: { + type: "string", + enum: ["incoming", "outgoing", "both"], + description: + "Filter by communication direction", + default: "both", + }, + limit: { + type: "number", + description: + "Maximum number of events to return", + default: 100, + }, + offset: { + type: "number", + description: + "Number of events to skip for pagination", + default: 0, + }, + }, + required: ["nodeId"], + }, + }, + { + name: "getEventsAroundTimestamp", + description: + "Enumerate all log entries around a specific timestamp with optional type filtering and pagination", + inputSchema: { + type: "object", + properties: { + timestamp: { + type: "string", + description: "Target timestamp in ISO format", + }, + beforeSeconds: { + type: "number", + description: + "Seconds to look before the timestamp", + default: 30, + }, + afterSeconds: { + type: "number", + description: + "Seconds to look after the timestamp", + default: 30, + }, + entryKinds: { + type: "array", + description: + "Filter by specific log entry kinds", + items: { type: "string" }, + }, + limit: { + type: "number", + description: + "Maximum number of events to return", + default: 100, + }, + offset: { + type: "number", + description: + "Number of events to skip for pagination", + default: 0, + }, + }, + required: ["timestamp"], + }, + }, + { + name: "getBackgroundRSSIBefore", + description: + "Get the most recent background RSSI reading before a specific timestamp, with optional maximum age limit in seconds", + inputSchema: { + type: "object", + properties: { + timestamp: { + type: "string", + description: "Target timestamp in ISO format", + }, + maxAge: { + type: "number", + description: + "Maximum age of RSSI reading in seconds", + }, + channel: { + type: "number", + description: + "Specific RF channel to get RSSI for", + }, + }, + required: ["timestamp"], + }, + }, + { + name: "searchLogEntries", + description: + "Search log entries by keyword/text/regex with optional type and time filtering, supports pagination. The query will search across ALL string fields in log entries recursively (including deeply nested attributes). For regex searches, either wrap your pattern in forward slashes like /pattern/flags or use regex syntax patterns (|, *, +, ?, [], (), etc.) which will be auto-detected. Examples: 'temperature' (plain text), '/temp|battery/i' (explicit regex), 'temp.*sensor' (auto-detected regex), '/node [0-9]+/' (explicit regex). Query can be omitted if attribute filters are provided.", + inputSchema: { + type: "object", + properties: { + query: { + type: "string", + description: + "Search query text or regex pattern. Searches ALL string-valued fields recursively throughout the log entry. For regex: wrap in forward slashes '/pattern/' or use regex syntax (|, *, +, ?, [], etc.) for auto-detection. Examples: 'battery', '/temp|humidity/', 'node.*[0-9]+'. Can be omitted if attribute filters are provided.", + }, + entryKinds: { + type: "array", + description: + "Filter by specific log entry kinds", + items: { type: "string" }, + }, + timeRange: { + type: "object", + description: + "Optional time range to filter search", + properties: { + start: { + type: "string", + description: + "Start timestamp in ISO format", + }, + end: { + type: "string", + description: + "End timestamp in ISO format", + }, + }, + }, + attributeFilters: { + type: "array", + description: + "Filter log entries by attribute values using comparison operators", + items: { + type: "object", + properties: { + path: { + type: "string", + description: + "Dot-separated path to the attribute (e.g., 'nodeId', 'payload.attributes.transmit status', 'rssi')", + }, + operator: { + type: "string", + enum: ["gt", "gte", "eq", "lt", "lte", "ne", "match"], + description: + "Comparison operator: gt/gte/lt/lte/eq/ne for numbers, match for string/regex searching. For 'match': use plain text for contains search, or wrap in /pattern/ for regex, or use regex syntax for auto-detection", + }, + value: { + description: + "Value to compare against (string, number, or boolean). For 'match' operator: supports same regex patterns as main query", + }, + }, + required: ["path", "operator", "value"], + }, + }, + limit: { + type: "number", + description: + "Maximum number of matches to return", + default: 100, + }, + offset: { + type: "number", + description: + "Number of matches to skip for pagination", + default: 0, + }, + }, + required: [], + }, + }, + { + name: "getLogChunk", + description: + "Read specific ranges of log entries by index with pagination support", + inputSchema: { + type: "object", + properties: { + startIndex: { + type: "number", + description: + "Starting index in the log entries array", + }, + count: { + type: "number", + description: "Number of entries to return", + }, + }, + required: ["startIndex", "count"], + }, + }, + { + name: "loadLogFile", + description: + "Load a new Z-Wave log file, clearing all caches and reindexing the data", + inputSchema: { + type: "object", + properties: { + filePath: { + type: "string", + description: + "Path to the Z-Wave log file to load", + }, + }, + required: ["filePath"], + }, + }, + ], + }; + }); + + // Tool execution handler + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + switch (name) { + case "loadLogFile": { + if (typeof args?.filePath !== "string") { + throw new McpError( + ErrorCode.InvalidParams, + "filePath must be a string", + ); + } + + // Check if we're running in a browser environment + const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'; + + if (isBrowser) { + // In browser, just return the log summary since we can't load files from path + // The actual loading should be done via loadLogFileFromContent + if (!this.queryEngine) { + throw new McpError( + ErrorCode.InvalidRequest, + "No log file loaded. Load log content first using loadLogFileFromContent.", + ); + } + + return { + content: [ + { + type: "text", + text: JSON.stringify( + await this.queryEngine.getLogSummary(), + null, + 2, + ), + }, + ], + }; + } else { + // In Node.js, load from file path + await this.loadLogFileFromPath(args.filePath); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + success: true, + message: `Successfully loaded log file: ${args.filePath}`, + summary: await this.queryEngine!.getLogSummary(), + }, + null, + 2, + ), + }, + ], + }; + } + } + + case "getLogSummary": + await this.initializeQueryEngine(); + return { + content: [ + { + type: "text", + text: JSON.stringify( + await this.queryEngine!.getLogSummary(), + null, + 2, + ), + }, + ], + }; + + case "getNodeSummary": + await this.initializeQueryEngine(); + if (typeof args?.nodeId !== "number") { + throw new McpError( + ErrorCode.InvalidParams, + "nodeId must be a number", + ); + } + return { + content: [ + { + type: "text", + text: JSON.stringify( + await this.queryEngine!.getNodeSummary({ + nodeId: args.nodeId, + timeRange: args.timeRange as { start: string; end: string } | undefined, + }), + null, + 2, + ), + }, + ], + }; + + case "getNodeCommunication": + await this.initializeQueryEngine(); + if (typeof args?.nodeId !== "number") { + throw new McpError( + ErrorCode.InvalidParams, + "nodeId must be a number", + ); + } + if ( + args.direction && + typeof args.direction === "string" && + !["incoming", "outgoing", "both"].includes( + args.direction, + ) + ) { + throw new McpError( + ErrorCode.InvalidParams, + "direction must be one of: incoming, outgoing, both", + ); + } + return { + content: [ + { + type: "text", + text: JSON.stringify( + await this.queryEngine!.getNodeCommunication({ + nodeId: args.nodeId, + direction: args.direction as + | "incoming" + | "outgoing" + | "both" + | undefined, + limit: args.limit as number | undefined, + offset: args.offset as + | number + | undefined, + timeRange: args.timeRange as { start: string; end: string } | undefined, + }), + null, + 2, + ), + }, + ], + }; + + case "getEventsAroundTimestamp": + await this.initializeQueryEngine(); + if (typeof args?.timestamp !== "string") { + throw new McpError( + ErrorCode.InvalidParams, + "timestamp must be a string", + ); + } + return { + content: [ + { + type: "text", + text: JSON.stringify( + await this.queryEngine!.getEventsAroundTimestamp( + { + timestamp: args.timestamp, + beforeSeconds: + args.beforeSeconds as + | number + | undefined, + afterSeconds: args.afterSeconds as + | number + | undefined, + entryKinds: (args.entryKinds || args.entryTypes) as + | any[] + | undefined, + limit: args.limit as + | number + | undefined, + offset: args.offset as + | number + | undefined, + }, + ), + null, + 2, + ), + }, + ], + }; + + case "getBackgroundRSSIBefore": + await this.initializeQueryEngine(); + if (typeof args?.timestamp !== "string") { + throw new McpError( + ErrorCode.InvalidParams, + "timestamp must be a string", + ); + } + return { + content: [ + { + type: "text", + text: JSON.stringify( + await this.queryEngine!.getBackgroundRSSIBefore({ + timestamp: args.timestamp, + maxAge: args.maxAge as + | number + | undefined, + channel: args.channel as + | number + | undefined, + }), + null, + 2, + ), + }, + ], + }; + + case "searchLogEntries": { + await this.initializeQueryEngine(); + + return { + content: [ + { + type: "text", + text: JSON.stringify( + await this.queryEngine!.searchLogEntries({ + query: (typeof args?.query === "string" ? args.query : ""), + entryKinds: (args?.entryKinds || args?.entryTypes) as + | any[] + | undefined, + timeRange: args?.timeRange as + | { start: string; end: string } + | undefined, + attributeFilters: args?.attributeFilters as + | any[] + | undefined, + limit: args?.limit as number | undefined, + offset: args?.offset as + | number + | undefined, + }), + null, + 2, + ), + }, + ], + }; + } + + case "getLogChunk": + await this.initializeQueryEngine(); + if (typeof args?.startIndex !== "number") { + throw new McpError( + ErrorCode.InvalidParams, + "startIndex must be a number", + ); + } + if (typeof args?.count !== "number") { + throw new McpError( + ErrorCode.InvalidParams, + "count must be a number", + ); + } + return { + content: [ + { + type: "text", + text: JSON.stringify( + await this.queryEngine!.getLogChunk({ + startIndex: args.startIndex, + count: args.count, + }), + null, + 2, + ), + }, + ], + }; + + default: + throw new McpError( + ErrorCode.MethodNotFound, + `Unknown tool: ${name}`, + ); + } + } catch (error) { + if (error instanceof McpError) throw error; + throw new McpError( + ErrorCode.InternalError, + `Tool execution failed: ${(error as Error).message}`, + ); + } + }); + } +} diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 9063dd3..32c40b0 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -1,587 +1,11 @@ #!/usr/bin/env node -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { - CallToolRequestSchema, - ErrorCode, - ListToolsRequestSchema, - McpError, -} from "@modelcontextprotocol/sdk/types.js"; -import { LogQueryEngine } from "./lib/log-query-engine.js"; -import { LogTransformPipeline } from "./lib/log-processor/index.js"; -import { readFile } from "node:fs/promises"; +import { ZWaveLogMCPServerCore } from "./lib/zwave-mcp-server-core.js"; async function main() { - let queryEngine: LogQueryEngine | null = null; - const pipeline = new LogTransformPipeline(); - - const server = new Server( - { name: "zwave-log-analyzer", version: "1.0.0" }, - { capabilities: { tools: {} } }, - ); - - async function initializeQueryEngine(): Promise { - if (!queryEngine) { - throw new McpError( - ErrorCode.InvalidRequest, - "No log file loaded. Use the loadLogFile tool to load a log file first.", - ); - } - } - - async function loadLogFile(filePath: string): Promise { - const logContent = await readFile(filePath, "utf-8"); - const transformedEntries = await pipeline.processLogContent(logContent); - queryEngine = new LogQueryEngine(transformedEntries); - } - - server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: [ - { - name: "getLogSummary", - description: - "Get overall statistics about the entire Z-Wave log including total entries, time range, node IDs, network activity broken down by node, and network-wide unsolicited report intervals", - inputSchema: { - type: "object", - properties: {}, - required: [], - }, - }, - { - name: "getNodeSummary", - description: - "Get traffic and signal quality summary for a specific node including RSSI statistics, unsolicited report intervals, and command classes used by the node", - inputSchema: { - type: "object", - properties: { - nodeId: { - type: "number", - description: "The Z-Wave node ID to analyze", - }, - timeRange: { - type: "object", - description: - "Optional time range to filter analysis", - properties: { - start: { - type: "string", - description: - "Start timestamp in ISO format", - }, - end: { - type: "string", - description: - "End timestamp in ISO format", - }, - }, - }, - }, - required: ["nodeId"], - }, - }, - { - name: "getNodeCommunication", - description: - "Enumerate communication attempts with a specific node over a time range, with direction filtering and pagination support", - inputSchema: { - type: "object", - properties: { - nodeId: { - type: "number", - description: "The Z-Wave node ID to analyze", - }, - timeRange: { - type: "object", - description: - "Optional time range to filter communications", - properties: { - start: { - type: "string", - description: - "Start timestamp in ISO format", - }, - end: { - type: "string", - description: - "End timestamp in ISO format", - }, - }, - }, - direction: { - type: "string", - enum: ["incoming", "outgoing", "both"], - description: - "Filter by communication direction", - default: "both", - }, - limit: { - type: "number", - description: - "Maximum number of events to return", - default: 100, - }, - offset: { - type: "number", - description: - "Number of events to skip for pagination", - default: 0, - }, - }, - required: ["nodeId"], - }, - }, - { - name: "getEventsAroundTimestamp", - description: - "Enumerate all log entries around a specific timestamp with optional type filtering and pagination", - inputSchema: { - type: "object", - properties: { - timestamp: { - type: "string", - description: "Target timestamp in ISO format", - }, - beforeSeconds: { - type: "number", - description: - "Seconds to look before the timestamp", - default: 30, - }, - afterSeconds: { - type: "number", - description: - "Seconds to look after the timestamp", - default: 30, - }, - entryKinds: { - type: "array", - description: - "Filter by specific log entry kinds", - items: { type: "string" }, - }, - limit: { - type: "number", - description: - "Maximum number of events to return", - default: 100, - }, - offset: { - type: "number", - description: - "Number of events to skip for pagination", - default: 0, - }, - }, - required: ["timestamp"], - }, - }, - { - name: "getBackgroundRSSIBefore", - description: - "Get the most recent background RSSI reading before a specific timestamp, with optional maximum age limit in seconds", - inputSchema: { - type: "object", - properties: { - timestamp: { - type: "string", - description: "Target timestamp in ISO format", - }, - maxAge: { - type: "number", - description: - "Maximum age of RSSI reading in seconds", - }, - channel: { - type: "number", - description: - "Specific RF channel to get RSSI for", - }, - }, - required: ["timestamp"], - }, - }, - { - name: "searchLogEntries", - description: - "Search log entries by keyword/text/regex with optional type and time filtering, supports pagination. The query will search across ALL string fields in log entries recursively (including deeply nested attributes). For regex searches, either wrap your pattern in forward slashes like /pattern/flags or use regex syntax patterns (|, *, +, ?, [], (), etc.) which will be auto-detected. Examples: 'temperature' (plain text), '/temp|battery/i' (explicit regex), 'temp.*sensor' (auto-detected regex), '/node [0-9]+/' (explicit regex).", - inputSchema: { - type: "object", - properties: { - query: { - type: "string", - description: - "Search query text or regex pattern. Searches ALL string-valued fields recursively throughout the log entry. For regex: wrap in forward slashes '/pattern/' or use regex syntax (|, *, +, ?, [], etc.) for auto-detection. Examples: 'battery', '/temp|humidity/', 'node.*[0-9]+'", - }, - entryKinds: { - type: "array", - description: - "Filter by specific log entry kinds", - items: { type: "string" }, - }, - timeRange: { - type: "object", - description: - "Optional time range to filter search", - properties: { - start: { - type: "string", - description: - "Start timestamp in ISO format", - }, - end: { - type: "string", - description: - "End timestamp in ISO format", - }, - }, - }, - attributeFilters: { - type: "array", - description: - "Filter log entries by attribute values using comparison operators", - items: { - type: "object", - properties: { - path: { - type: "string", - description: - "Dot-separated path to the attribute (e.g., 'nodeId', 'payload.attributes.transmit status', 'rssi')", - }, - operator: { - type: "string", - enum: ["gt", "gte", "eq", "lt", "lte", "ne", "match"], - description: - "Comparison operator: gt/gte/lt/lte/eq/ne for numbers, match for string/regex searching. For 'match': use plain text for contains search, or wrap in /pattern/ for regex, or use regex syntax for auto-detection", - }, - value: { - description: - "Value to compare against (string, number, or boolean). For 'match' operator: supports same regex patterns as main query", - }, - }, - required: ["path", "operator", "value"], - }, - }, - limit: { - type: "number", - description: - "Maximum number of matches to return", - default: 100, - }, - offset: { - type: "number", - description: - "Number of matches to skip for pagination", - default: 0, - }, - }, - required: ["query"], - }, - }, - { - name: "getLogChunk", - description: - "Read specific ranges of log entries by index with pagination support", - inputSchema: { - type: "object", - properties: { - startIndex: { - type: "number", - description: - "Starting index in the log entries array", - }, - count: { - type: "number", - description: "Number of entries to return", - }, - }, - required: ["startIndex", "count"], - }, - }, - { - name: "loadLogFile", - description: - "Load a new Z-Wave log file, clearing all caches and reindexing the data", - inputSchema: { - type: "object", - properties: { - filePath: { - type: "string", - description: - "Path to the Z-Wave log file to load", - }, - }, - required: ["filePath"], - }, - }, - ], - }; - }); - - server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - - try { - switch (name) { - case "loadLogFile": - if (typeof args?.filePath !== "string") { - throw new McpError( - ErrorCode.InvalidParams, - "filePath must be a string", - ); - } - await loadLogFile(args.filePath); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - success: true, - message: `Successfully loaded log file: ${args.filePath}`, - summary: - await queryEngine!.getLogSummary(), - }, - null, - 2, - ), - }, - ], - }; - - case "getLogSummary": - await initializeQueryEngine(); - return { - content: [ - { - type: "text", - text: JSON.stringify( - await queryEngine!.getLogSummary(), - null, - 2, - ), - }, - ], - }; - - case "getNodeSummary": - await initializeQueryEngine(); - if (typeof args?.nodeId !== "number") { - throw new McpError( - ErrorCode.InvalidParams, - "nodeId must be a number", - ); - } - return { - content: [ - { - type: "text", - text: JSON.stringify( - await queryEngine!.getNodeSummary({ - nodeId: args.nodeId, - }), - null, - 2, - ), - }, - ], - }; - - case "getNodeCommunication": - await initializeQueryEngine(); - if (typeof args?.nodeId !== "number") { - throw new McpError( - ErrorCode.InvalidParams, - "nodeId must be a number", - ); - } - if ( - args.direction && - typeof args.direction === "string" && - !["incoming", "outgoing", "both"].includes( - args.direction, - ) - ) { - throw new McpError( - ErrorCode.InvalidParams, - "direction must be one of: incoming, outgoing, both", - ); - } - return { - content: [ - { - type: "text", - text: JSON.stringify( - await queryEngine!.getNodeCommunication({ - nodeId: args.nodeId, - direction: args.direction as - | "incoming" - | "outgoing" - | "both" - | undefined, - limit: args.limit as number | undefined, - offset: args.offset as - | number - | undefined, - }), - null, - 2, - ), - }, - ], - }; - - case "getEventsAroundTimestamp": - await initializeQueryEngine(); - if (typeof args?.timestamp !== "string") { - throw new McpError( - ErrorCode.InvalidParams, - "timestamp must be a string", - ); - } - return { - content: [ - { - type: "text", - text: JSON.stringify( - await queryEngine!.getEventsAroundTimestamp( - { - timestamp: args.timestamp, - beforeSeconds: - args.beforeSeconds as - | number - | undefined, - afterSeconds: args.afterSeconds as - | number - | undefined, - entryKinds: args.entryKinds as - | any[] - | undefined, - limit: args.limit as - | number - | undefined, - offset: args.offset as - | number - | undefined, - }, - ), - null, - 2, - ), - }, - ], - }; - - case "getBackgroundRSSIBefore": - await initializeQueryEngine(); - if (typeof args?.timestamp !== "string") { - throw new McpError( - ErrorCode.InvalidParams, - "timestamp must be a string", - ); - } - return { - content: [ - { - type: "text", - text: JSON.stringify( - await queryEngine!.getBackgroundRSSIBefore({ - timestamp: args.timestamp, - maxAge: args.maxAge as - | number - | undefined, - channel: args.channel as - | number - | undefined, - }), - null, - 2, - ), - }, - ], - }; - - case "searchLogEntries": - await initializeQueryEngine(); - if (typeof args?.query !== "string") { - throw new McpError( - ErrorCode.InvalidParams, - "query must be a string", - ); - } - return { - content: [ - { - type: "text", - text: JSON.stringify( - await queryEngine!.searchLogEntries({ - query: args.query, - entryKinds: args.entryKinds as - | any[] - | undefined, - timeRange: args.timeRange as - | { start: string; end: string } - | undefined, - attributeFilters: args.attributeFilters as - | any[] - | undefined, - limit: args.limit as number | undefined, - offset: args.offset as - | number - | undefined, - }), - null, - 2, - ), - }, - ], - }; - - case "getLogChunk": - await initializeQueryEngine(); - if (typeof args?.startIndex !== "number") { - throw new McpError( - ErrorCode.InvalidParams, - "startIndex must be a number", - ); - } - if (typeof args?.count !== "number") { - throw new McpError( - ErrorCode.InvalidParams, - "count must be a number", - ); - } - return { - content: [ - { - type: "text", - text: JSON.stringify( - await queryEngine!.getLogChunk({ - startIndex: args.startIndex, - count: args.count, - }), - null, - 2, - ), - }, - ], - }; - - default: - throw new McpError( - ErrorCode.MethodNotFound, - `Unknown tool: ${name}`, - ); - } - } catch (error) { - if (error instanceof McpError) throw error; - throw new McpError( - ErrorCode.InternalError, - `Tool execution failed: ${(error as Error).message}`, - ); - } - }); + const serverCore = new ZWaveLogMCPServerCore(); + const server = serverCore.getServer(); server.onerror = (error) => console.error("[MCP Error]", error); process.on("SIGINT", async () => { From c6774b2ac46340f2618be9f9339838c7959136d2 Mon Sep 17 00:00:00 2001 From: Dominic Griesel Date: Thu, 25 Sep 2025 13:14:11 +0200 Subject: [PATCH 2/2] chore: cleanup before putting it on ice --- src/lib/ai/analysis-prompt.ts | 2 +- src/lib/ai/gemini-client.ts | 32 +++++++++++++++++--------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/lib/ai/analysis-prompt.ts b/src/lib/ai/analysis-prompt.ts index 05950ec..48e81ee 100644 --- a/src/lib/ai/analysis-prompt.ts +++ b/src/lib/ai/analysis-prompt.ts @@ -106,7 +106,7 @@ Certain issues are common in Z-Wave networks and can often have diverse symptoms ## Analysis Tools -The following tools are available for Z-Wave log analysis: +The following tools are available for Z-Wave log analysis. Before each tool call, think hard what else you might need to query. Try to call multiple tools at once to avoid excessive back-and-forth calls. ### Core Tools diff --git a/src/lib/ai/gemini-client.ts b/src/lib/ai/gemini-client.ts index 37fb010..ba3d21e 100644 --- a/src/lib/ai/gemini-client.ts +++ b/src/lib/ai/gemini-client.ts @@ -5,6 +5,7 @@ import { Chat, mcpToTool, type Part, + FunctionCallingConfigMode, } from "@google/genai"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { @@ -29,9 +30,6 @@ export class GeminiLogAnalyzer { private serverTransport: EventTargetTransport; private hasLoadedLogFile = false; - // Used to store parts of a streamed response that contain thought signatures - private thoughtParts: Part[] = []; - constructor(config: GeminiConfig) { console.log( "Initializing Gemini Log Analyzer with model:", @@ -189,6 +187,11 @@ export class GeminiLogAnalyzer { // includeThoughts: true, // }, tools: [mcpToTool(this.mcpClient)], + toolConfig: { + functionCallingConfig: { + mode: FunctionCallingConfigMode.ANY, + }, + }, }, }); } catch (error) { @@ -217,23 +220,14 @@ export class GeminiLogAnalyzer { // Use the chat session's sendMessageStream method // The SDK automatically handles MCP tool calls - const thoughtParts = this.thoughtParts.splice( - 0, - this.thoughtParts.length, - ); - console.log("Followup query with thought parts:", thoughtParts); const response = await this.chatSession.sendMessageStream({ - message: [...thoughtParts, query], + message: query, }); console.log("Processing chat response stream..."); for await (const chunk of response) { - // Log any tool calls that are being made - if (chunk.functionCalls) { - console.log( - "AI is making function calls:", - chunk.functionCalls.map((fc) => fc.name), - ); + if (chunk.usageMetadata) { + console.log("Usage metadata:", chunk.usageMetadata); } const parts = chunk.candidates?.[0]?.content?.parts; if (!parts || parts.length === 0) continue; @@ -247,6 +241,14 @@ export class GeminiLogAnalyzer { // this.thoughtParts.push(parts[0]!); // } + // Log any tool calls that are being made + if (chunk.functionCalls) { + console.log( + "AI is making function calls:", + chunk.functionCalls.map((fc) => fc.name), + ); + } + for (const part of parts) { // if part.candidates[0]!.content?.parts if (part.thought) {