diff --git a/.mcp.json b/.mcp.json index 5f68642aa1..9a08267772 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,6 +3,19 @@ "mcp-docs": { "type": "http", "url": "https://modelcontextprotocol.io/mcp" + }, + "sequential-thinking": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", "MAX_THOUGHT_LENGTH=5000", + "-e", "MAX_HISTORY_SIZE=100", + "-e", "ENABLE_METRICS=true", + "-e", "ENABLE_HEALTH_CHECKS=true", + "mcp/sequential-thinking" + ] } } } diff --git a/package-lock.json b/package-lock.json index 46db9cb702..aa2dede184 100644 --- a/package-lock.json +++ b/package-lock.json @@ -480,6 +480,117 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.9", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", @@ -492,6 +603,68 @@ "hono": "^4" } }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -707,6 +880,44 @@ "resolved": "src/sequentialthinking", "link": true }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1098,6 +1309,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -1133,6 +1351,13 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/send": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", @@ -1154,20 +1379,226 @@ "@types/node": "*" } }, - "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "dev": true, + "license": "MIT", "dependencies": { - "@types/yargs-parser": "*" + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true + "node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" }, "node_modules/@vitest/coverage-v8": { "version": "2.1.9", @@ -1358,15 +1789,38 @@ "node": ">= 0.6" } }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" }, "funding": { @@ -1391,6 +1845,22 @@ } } }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1399,6 +1869,13 @@ "node": ">=8" } }, + "node_modules/ansi-sequence-parser": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/ansi-sequence-parser/-/ansi-sequence-parser-1.1.3.tgz", + "integrity": "sha512-+fksAx9eG3Ab6LDnLs3ZqZa8KVJ/jYnX+D4Qe1azX+LFGFAXqynCQLOdLpNYN/l9e7l6hMWwZbrnctqr6eSQSw==", + "dev": true, + "license": "MIT" + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1413,6 +1890,23 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1461,6 +1955,19 @@ "balanced-match": "^1.0.0" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1508,6 +2015,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1526,9 +2043,11 @@ } }, "node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -1546,17 +2065,91 @@ "node": ">= 16" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" }, "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/color-convert": { @@ -1575,6 +2168,23 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1680,6 +2290,13 @@ "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1697,6 +2314,32 @@ "node": ">=0.3.1" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1735,6 +2378,19 @@ "node": ">= 0.8" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1811,109 +2467,374 @@ "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "engines": { - "node": ">=6" - } - }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "engines": { - "node": ">= 0.6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eventsource": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.5.tgz", - "integrity": "sha512-LT/5J605bx5SNyE+ITBDiM3FxffBiq9un7Vx0EwMDM3vg8sWKx/tO2zC+LMqZ+smAM0F2hblaDZUVZF0te2pSw==", + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, "license": "MIT", "dependencies": { - "eventsource-parser": "^3.0.0" + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" }, "engines": { - "node": ">=18.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/eventsource-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", - "integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==", + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, "engines": { - "node": ">=18.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=12.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "license": "MIT", "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.5.tgz", + "integrity": "sha512-LT/5J605bx5SNyE+ITBDiM3FxffBiq9un7Vx0EwMDM3vg8sWKx/tO2zC+LMqZ+smAM0F2hblaDZUVZF0te2pSw==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", + "integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express-rate-limit": { @@ -1940,6 +2861,50 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -1956,6 +2921,42 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -1977,6 +2978,45 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, "node_modules/foreground-child": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", @@ -2040,12 +3080,17 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "dev": true, + "license": "MIT", "engines": { - "node": "6.* || 8.* || >= 10.*" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/get-intrinsic": { @@ -2085,6 +3130,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -2105,6 +3163,56 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2117,6 +3225,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2181,6 +3296,32 @@ "node": ">= 0.8" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/husky": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", + "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "lib/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", @@ -2197,12 +3338,49 @@ "url": "https://opencollective.com/express" } }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -2261,6 +3439,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2269,12 +3457,58 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -2350,6 +3584,26 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -2362,6 +3616,20 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -2374,13 +3642,342 @@ "setimmediate": "^1.0.5" } }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lint-staged": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.2.tgz", + "integrity": "sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^13.1.0", + "debug": "^4.4.0", + "execa": "^8.0.1", + "lilconfig": "^3.1.3", + "listr2": "^8.2.5", + "micromatch": "^4.0.8", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.7.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, "license": "MIT", "dependencies": { - "immediate": "~3.0.5" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/loupe": { @@ -2396,6 +3993,13 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -2434,6 +4038,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2464,6 +4081,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2489,6 +4137,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -2547,6 +4221,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -2556,6 +4237,35 @@ "node": ">= 0.6" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2595,6 +4305,72 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -2607,6 +4383,19 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2615,6 +4404,16 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -2656,6 +4455,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -2679,6 +4488,32 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pkce-challenge": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", @@ -2717,6 +4552,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prettier": { "version": "2.8.8", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", @@ -2751,6 +4596,16 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -2766,6 +4621,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2851,14 +4727,6 @@ "node": ">= 0.10" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2868,21 +4736,145 @@ "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, + "license": "ISC", "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" + "glob": "^7.1.3" }, "bin": { - "resolve": "bin/resolve" + "rimraf": "bin.js" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, "node_modules/rollup": { @@ -2953,6 +4945,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3102,6 +5118,19 @@ "node": "*" } }, + "node_modules/shiki": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.14.7.tgz", + "integrity": "sha512-dNPAPrxSc87ua2sKJ3H5dQ/6ZaY8RNnaAqK+t0eG7p0Soi2ydiqbGOTaZCqaYvA/uZYfS1LJnemt3Q+mSfcPCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-sequence-parser": "^1.1.0", + "jsonc-parser": "^3.2.0", + "vscode-oniguruma": "^1.7.0", + "vscode-textmate": "^8.0.0" + } + }, "node_modules/shx": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/shx/-/shx-0.3.4.tgz", @@ -3209,6 +5238,59 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3256,6 +5338,16 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3308,6 +5400,32 @@ "node": ">=8" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3333,6 +5451,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3377,6 +5502,19 @@ "node": ">=14.0.0" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -3385,6 +5523,45 @@ "node": ">=0.6" } }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -3400,9 +5577,9 @@ } }, "node_modules/typescript": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3428,6 +5605,16 @@ "node": ">= 0.8" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3591,6 +5778,20 @@ } } }, + "node_modules/vscode-oniguruma": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz", + "integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-textmate": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-8.0.0.tgz", + "integrity": "sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3623,20 +5824,14 @@ "node": ">=8" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=0.10.0" } }, "node_modules/wrap-ansi-cjs": { @@ -3662,37 +5857,33 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" }, "engines": { - "node": ">=12" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/zod": { @@ -4007,20 +6198,80 @@ "version": "0.6.2", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0", - "chalk": "^5.3.0", - "yargs": "^17.7.2" + "zod": "^3.22.4" }, "bin": { - "mcp-server-sequential-thinking": "dist/index.js" + "mcp-server-sequential-thinking": "dist/index.js", + "mcp-server-sequential-thinking-simple": "dist-simple/index.js" }, "devDependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^22", - "@types/yargs": "^17.0.32", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", "@vitest/coverage-v8": "^2.1.8", + "eslint": "^8.0.0", + "eslint-config-prettier": "^9.0.0", + "husky": "^8.0.0", + "lint-staged": "^15.0.0", + "prettier": "^3.0.0", "shx": "^0.3.4", + "typedoc": "^0.25.0", "typescript": "^5.3.3", - "vitest": "^2.1.8" + "vitest": "^2.1.8", + "zod": "^3.22.4" + } + }, + "src/sequentialthinking/node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "src/sequentialthinking/node_modules/typedoc": { + "version": "0.25.13", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.13.tgz", + "integrity": "sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lunr": "^2.3.9", + "marked": "^4.3.0", + "minimatch": "^9.0.3", + "shiki": "^0.14.7" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x" + } + }, + "src/sequentialthinking/node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" } }, "src/slack": { diff --git a/src/sequentialthinking/.eslintrc.cjs b/src/sequentialthinking/.eslintrc.cjs new file mode 100644 index 0000000000..685d531f0a --- /dev/null +++ b/src/sequentialthinking/.eslintrc.cjs @@ -0,0 +1,179 @@ +module.exports = { + root: true, + env: { + node: true, + es2020: true, + jest: true + }, + extends: [ + 'eslint:recommended' + ], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + project: './tsconfig.json', + tsconfigRootDir: __dirname + }, + plugins: ['@typescript-eslint'], + rules: { + // Security Rules + 'no-eval': 'error', + 'no-implied-eval': 'error', + 'no-new-func': 'error', + 'no-script-url': 'error', + 'no-alert': 'error', + 'no-debugger': 'error', + + // Code Quality Rules + 'no-unused-vars': 'off', + 'no-console': ['warn', { 'allow': ['warn', 'error'] }], + 'no-undef': 'off', + 'prefer-const': 'error', + 'no-var': 'error', + + // Style Rules + 'semi': ['error', 'always'], + 'quotes': ['error', 'single', { 'avoidEscape': true }], + 'indent': ['error', 2], + 'object-curly-spacing': ['error', 'always'], + 'array-bracket-spacing': ['error', 'never'], + 'comma-dangle': ['error', 'always-multiline'], + 'brace-style': ['error', '1tbs'], + 'max-len': ['error', { + 'code': 100, + 'ignoreUrls': true, + 'ignoreStrings': true, + 'ignoreTemplateLiterals': true, + 'ignoreRegExpLiterals': true + }], + + // Best Practices + 'eqeqeq': ['error', 'always', { 'null': 'ignore' }], + 'no-sequences': 'error', + 'no-unused-expressions': 'error', + 'no-useless-call': 'error', + 'no-useless-concat': 'error', + 'no-useless-return': 'error', + 'radix': 'error', + 'no-iterator': 'error', + 'no-loop-func': 'error', + 'no-multi-str': 'error', + 'no-new': 'error', + 'no-new-wrappers': 'error', + 'no-proto': 'error', + 'no-redeclare': 'error', + 'no-return-assign': 'error', + 'no-return-await': 'error', + 'no-throw-literal': 'error', + 'no-unmodified-loop-condition': 'error', + 'no-useless-escape': 'error', + 'no-global-assign': 'error', + + // Complexity Rules + 'complexity': ['error', 10], + 'max-depth': ['error', 4], + 'max-nested-callbacks': ['error', 3], + 'max-params': ['error', 5], + 'max-statements': ['error', 25], + + // TypeScript-specific rules + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/prefer-as-const': 'error', + '@typescript-eslint/prefer-nullish-coalescing': 'error', + '@typescript-eslint/no-unused-vars': ['error', { + 'argsIgnorePattern': '^_', + 'varsIgnorePattern': '^_' + }], + '@typescript-eslint/explicit-function-return-type': 'error', + '@typescript-eslint/explicit-module-boundary-types': 'error', + '@typescript-eslint/prefer-readonly': 'error', + '@typescript-eslint/no-unnecessary-type-assertion': 'error', + '@typescript-eslint/no-empty-interface': 'error', + '@typescript-eslint/prefer-promise-reject-errors': 'error', + '@typescript-eslint/no-require-imports': 'error', + '@typescript-eslint/no-var-requires': 'error', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/no-for-in-array': 'error', + '@typescript-eslint/no-throw-literal': 'error', + '@typescript-eslint/prefer-string-starts-ends-with': 'error', + '@typescript-eslint/prefer-destructuring': 'error', + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/consistent-type-definitions': 'error', + + // Naming conventions + '@typescript-eslint/naming-convention': [ + 'error', + { + 'selector': 'class', + 'format': ['PascalCase'] + }, + { + 'selector': 'interface', + 'format': ['PascalCase'] + }, + { + 'selector': 'typeAlias', + 'format': ['PascalCase'] + }, + { + 'selector': 'enum', + 'format': ['PascalCase'] + }, + { + 'selector': 'enumMember', + 'format': ['UPPER_CASE'] + }, + { + 'selector': 'function', + 'format': ['camelCase'] + }, + { + 'selector': 'variable', + 'format': ['camelCase', 'UPPER_CASE', 'PascalCase'], + 'filter': { + 'regex': 'Schema$', + 'match': true + } + }, + { + 'selector': 'variable', + 'format': ['camelCase', 'UPPER_CASE'], + 'filter': { + 'regex': 'Schema$', + 'match': false + } + }, + { + 'selector': 'parameter', + 'format': ['camelCase'], + 'leadingUnderscore': 'allow' + } + ] + }, + ignorePatterns: [ + 'dist/**', + 'dist-simple/**', + 'node_modules/**', + '**/*.d.ts', + 'scripts/**', + 'coverage/**', + '*.config.js', + '*.config.ts' + ], + overrides: [ + { + files: ['**/*.test.ts', '**/__tests__/**/*.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + 'max-len': 'off', + 'max-statements': 'off' + } + } + ] +}; diff --git a/src/sequentialthinking/.prettierrc.json b/src/sequentialthinking/.prettierrc.json new file mode 100644 index 0000000000..340079d3fa --- /dev/null +++ b/src/sequentialthinking/.prettierrc.json @@ -0,0 +1,15 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "avoid", + "bracketSameLine": true, + "endOfLine": "lf", + "quoteProps": "as-needed", + "jsxSingleQuote": true, + "proseWrap": "preserve" +} \ No newline at end of file diff --git a/src/sequentialthinking/Dockerfile b/src/sequentialthinking/Dockerfile index f1a88195bc..2082f671ca 100644 --- a/src/sequentialthinking/Dockerfile +++ b/src/sequentialthinking/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /app RUN --mount=type=cache,target=/root/.npm npm install -RUN --mount=type=cache,target=/root/.npm-production npm ci --ignore-scripts --omit-dev +RUN npm run build FROM node:22-alpine AS release diff --git a/src/sequentialthinking/README.md b/src/sequentialthinking/README.md index 322ded2726..9cdd1977cd 100644 --- a/src/sequentialthinking/README.md +++ b/src/sequentialthinking/README.md @@ -1,155 +1,85 @@ # Sequential Thinking MCP Server -An MCP server implementation that provides a tool for dynamic and reflective problem-solving through a structured thinking process. +An MCP server for dynamic, reflective problem-solving through sequential thoughts. -## Features +## Overview -- Break down complex problems into manageable steps -- Revise and refine thoughts as understanding deepens -- Branch into alternative paths of reasoning -- Adjust the total number of thoughts dynamically -- Generate and verify solution hypotheses +This server provides structured, step-by-step thinking with support for revisions, branching, and session tracking. Thoughts are validated, sanitized, and stored in a bounded circular buffer. -## Tool +## Tools -### sequential_thinking +### `sequentialthinking` -Facilitates a detailed, step-by-step thinking process for problem-solving and analysis. +Process a single thought in a sequential chain. -**Inputs:** -- `thought` (string): The current thinking step -- `nextThoughtNeeded` (boolean): Whether another thought step is needed -- `thoughtNumber` (integer): Current thought number -- `totalThoughts` (integer): Estimated total thoughts needed -- `isRevision` (boolean, optional): Whether this revises previous thinking -- `revisesThought` (integer, optional): Which thought is being reconsidered -- `branchFromThought` (integer, optional): Branching point thought number -- `branchId` (string, optional): Branch identifier -- `needsMoreThoughts` (boolean, optional): If more thoughts are needed +**Parameters:** -## Usage +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `thought` | string | yes | The current thinking step | +| `nextThoughtNeeded` | boolean | yes | Whether another thought step is needed | +| `thoughtNumber` | number | yes | Current thought number (1-based) | +| `totalThoughts` | number | yes | Estimated total thoughts needed (adjusts automatically) | +| `isRevision` | boolean | no | Whether this revises previous thinking | +| `revisesThought` | number | no | Which thought number is being reconsidered | +| `branchFromThought` | number | no | Branching point thought number | +| `branchId` | string | no | Branch identifier | +| `needsMoreThoughts` | boolean | no | If more thoughts are needed beyond the estimate | +| `sessionId` | string | no | Session identifier for tracking | -The Sequential Thinking tool is designed for: -- Breaking down complex problems into steps -- Planning and design with room for revision -- Analysis that might need course correction -- Problems where the full scope might not be clear initially -- Tasks that need to maintain context over multiple steps -- Situations where irrelevant information needs to be filtered out +**Response fields:** `thoughtNumber`, `totalThoughts`, `nextThoughtNeeded`, `branches`, `thoughtHistoryLength`, `sessionId`, `timestamp` -## Configuration - -### Usage with Claude Desktop - -Add this to your `claude_desktop_config.json`: - -#### npx - -```json -{ - "mcpServers": { - "sequential-thinking": { - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-sequential-thinking" - ] - } - } -} -``` - -#### docker - -```json -{ - "mcpServers": { - "sequentialthinking": { - "command": "docker", - "args": [ - "run", - "--rm", - "-i", - "mcp/sequentialthinking" - ] - } - } -} -``` - -To disable logging of thought information set env var: `DISABLE_THOUGHT_LOGGING` to `true`. -Comment - -### Usage with VS Code - -For quick installation, click one of the installation buttons below... - -[![Install with NPX in VS Code](https://img.shields.io/badge/VS_Code-NPM-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=sequentialthinking&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40modelcontextprotocol%2Fserver-sequential-thinking%22%5D%7D) [![Install with NPX in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-NPM-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=sequentialthinking&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40modelcontextprotocol%2Fserver-sequential-thinking%22%5D%7D&quality=insiders) +### `health_check` -[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Docker-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=sequentialthinking&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22--rm%22%2C%22-i%22%2C%22mcp%2Fsequentialthinking%22%5D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Docker-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=sequentialthinking&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22--rm%22%2C%22-i%22%2C%22mcp%2Fsequentialthinking%22%5D%7D&quality=insiders) +Returns server health status including memory, response time, error rate, storage, and security checks. -For manual installation, you can configure the MCP server using one of these methods: +### `metrics` -**Method 1: User Configuration (Recommended)** -Add the configuration to your user-level MCP configuration file. Open the Command Palette (`Ctrl + Shift + P`) and run `MCP: Open User Configuration`. This will open your user `mcp.json` file where you can add the server configuration. +Returns request metrics (counts, response times), thought metrics (totals, branches), and system metrics. -**Method 2: Workspace Configuration** -Alternatively, you can add the configuration to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others. - -> For more details about MCP configuration in VS Code, see the [official VS Code MCP documentation](https://code.visualstudio.com/docs/copilot/customization/mcp-servers). - -For NPX installation: - -```json -{ - "servers": { - "sequential-thinking": { - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-sequential-thinking" - ] - } - } -} -``` - -For Docker installation: - -```json -{ - "servers": { - "sequential-thinking": { - "command": "docker", - "args": [ - "run", - "--rm", - "-i", - "mcp/sequentialthinking" - ] - } - } -} -``` - -### Usage with Codex CLI - -Run the following: +## Configuration -#### npx +All configuration is via environment variables with sensible defaults: + +| Variable | Default | Description | +|----------|---------|-------------| +| `SERVER_NAME` | `sequential-thinking-server` | Server name reported in MCP metadata | +| `SERVER_VERSION` | `1.0.0` | Server version reported in MCP metadata | +| `MAX_HISTORY_SIZE` | `1000` | Maximum thoughts stored in circular buffer | +| `MAX_THOUGHT_LENGTH` | `5000` | Maximum character length per thought | +| `MAX_THOUGHTS_PER_MIN` | `60` | Rate limit per minute per session | +| `MAX_THOUGHTS_PER_BRANCH` | `100` | Maximum thoughts stored per branch | +| `MAX_BRANCH_AGE` | `3600000` | Branch expiration time (ms) | +| `CLEANUP_INTERVAL` | `300000` | Periodic cleanup interval (ms) | +| `BLOCKED_PATTERNS` | *(built-in list)* | Comma-separated regex patterns to block | +| `DISABLE_THOUGHT_LOGGING` | `false` | Disable console thought formatting | +| `LOG_LEVEL` | `info` | Logging level (`debug`, `info`, `warn`, `error`) | +| `ENABLE_COLORS` | `true` | Enable colored console output | +| `ENABLE_METRICS` | `true` | Enable metrics collection | +| `ENABLE_HEALTH_CHECKS` | `true` | Enable health check endpoint | +| `HEALTH_MAX_MEMORY` | `90` | Memory usage % threshold for unhealthy status | +| `HEALTH_MAX_STORAGE` | `80` | Storage usage % threshold for unhealthy status | +| `HEALTH_MAX_RESPONSE_TIME` | `200` | Response time (ms) threshold for unhealthy status | +| `HEALTH_ERROR_RATE_DEGRADED` | `2` | Error rate % threshold for degraded status | +| `HEALTH_ERROR_RATE_UNHEALTHY` | `5` | Error rate % threshold for unhealthy status | + +## Development ```bash -codex mcp add sequential-thinking npx -y @modelcontextprotocol/server-sequential-thinking +npm install +npm run build +npm test ``` -## Building +### Scripts -Docker: - -```bash -docker build -t mcp/sequentialthinking -f src/sequentialthinking/Dockerfile . -``` +- `npm run build` — Compile TypeScript +- `npm run watch` — Compile in watch mode +- `npm test` — Run tests +- `npm run lint` — Run ESLint +- `npm run lint:fix` — Auto-fix lint issues +- `npm run type-check` — TypeScript type checking ## License -This MCP server is licensed under the MIT License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License. For more details, please see the LICENSE file in the project repository. +SEE LICENSE IN LICENSE diff --git a/src/sequentialthinking/__tests__/e2e/docker.test.ts b/src/sequentialthinking/__tests__/e2e/docker.test.ts new file mode 100644 index 0000000000..88ad5ad5e8 --- /dev/null +++ b/src/sequentialthinking/__tests__/e2e/docker.test.ts @@ -0,0 +1,547 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, ChildProcess } from 'child_process'; + +describe('Docker E2E Tests', () => { + let dockerProcess: ChildProcess | null = null; + const DOCKER_IMAGE = 'mcp/sequential-thinking'; + const TIMEOUT = 30000; + + // Helper to send JSON-RPC message to Docker container + async function sendMessage(message: unknown): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Response timeout')); + }, 5000); + + dockerProcess = spawn('docker', [ + 'run', + '--rm', + '-i', + '-e', 'MAX_THOUGHT_LENGTH=5000', + '-e', 'MAX_HISTORY_SIZE=100', + DOCKER_IMAGE, + ]); + + let stdout = ''; + let stderr = ''; + + dockerProcess.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + dockerProcess.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + dockerProcess.on('close', (code) => { + clearTimeout(timeout); + + if (code !== 0) { + reject(new Error(`Docker exited with code ${code}. stderr: ${stderr}`)); + return; + } + + // Parse the first JSON line (ignore console logs) + const lines = stdout.split('\n'); + for (const line of lines) { + if (line.trim().startsWith('{')) { + try { + const response = JSON.parse(line); + resolve(response); + return; + } catch (e) { + // Continue to next line + } + } + } + + reject(new Error(`No valid JSON response found. stdout: ${stdout}`)); + }); + + dockerProcess.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + + // Send the message + dockerProcess.stdin?.write(JSON.stringify(message) + '\n'); + dockerProcess.stdin?.end(); + }); + } + + beforeAll(async () => { + // Verify Docker image exists + const { execSync } = await import('child_process'); + try { + execSync(`docker image inspect ${DOCKER_IMAGE}`, { stdio: 'ignore' }); + } catch { + throw new Error(`Docker image ${DOCKER_IMAGE} not found. Run: docker build -t ${DOCKER_IMAGE} -f src/sequentialthinking/Dockerfile .`); + } + }, TIMEOUT); + + afterAll(() => { + if (dockerProcess && !dockerProcess.killed) { + dockerProcess.kill(); + } + }); + + describe('MCP Protocol', () => { + it('should respond to initialize request', async () => { + const response = await sendMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { + name: 'test-client', + version: '1.0.0', + }, + }, + }) as any; + + expect(response.jsonrpc).toBe('2.0'); + expect(response.id).toBe(1); + expect(response.result).toBeDefined(); + expect(response.result.protocolVersion).toBe('2024-11-05'); + expect(response.result.serverInfo.name).toBe('sequential-thinking-server'); + expect(response.result.capabilities.tools).toBeDefined(); + }, TIMEOUT); + + it('should list available tools', async () => { + const response = await sendMessage({ + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {}, + }) as any; + + expect(response.jsonrpc).toBe('2.0'); + expect(response.id).toBe(2); + expect(response.result).toBeDefined(); + expect(response.result.tools).toBeInstanceOf(Array); + expect(response.result.tools.length).toBeGreaterThan(0); + + const sequentialThinkingTool = response.result.tools.find( + (tool: any) => tool.name === 'sequentialthinking' + ); + expect(sequentialThinkingTool).toBeDefined(); + expect(sequentialThinkingTool.description).toBeDefined(); + expect(sequentialThinkingTool.inputSchema).toBeDefined(); + }, TIMEOUT); + }); + + describe('Sequential Thinking Tool', () => { + it('should process a single thought', async () => { + const response = await sendMessage({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'sequentialthinking', + arguments: { + thought: 'Test thought for Docker e2e', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }, + }, + }) as any; + + expect(response.jsonrpc).toBe('2.0'); + expect(response.id).toBe(3); + expect(response.result).toBeDefined(); + expect(response.result.content).toBeInstanceOf(Array); + expect(response.result.content.length).toBeGreaterThan(0); + + const textContent = response.result.content.find( + (c: any) => c.type === 'text' + ); + expect(textContent).toBeDefined(); + // Response is JSON structured data + const data = JSON.parse(textContent.text); + expect(data.thoughtNumber).toBe(1); + expect(data.totalThoughts).toBe(1); + expect(data.nextThoughtNeeded).toBe(false); + }, TIMEOUT); + + it('should handle multiple sequential thoughts', async () => { + // First thought + const response1 = await sendMessage({ + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'sequentialthinking', + arguments: { + thought: 'First thought in sequence', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + sessionId: 'docker-e2e-session', + }, + }, + }) as any; + + expect(response1.result.isError).toBeUndefined(); + + // Second thought + const response2 = await sendMessage({ + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'sequentialthinking', + arguments: { + thought: 'Second thought in sequence', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + sessionId: 'docker-e2e-session', + }, + }, + }) as any; + + expect(response2.result.isError).toBeUndefined(); + + // Final thought + const response3 = await sendMessage({ + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { + name: 'sequentialthinking', + arguments: { + thought: 'Final thought in sequence', + thoughtNumber: 3, + totalThoughts: 3, + nextThoughtNeeded: false, + sessionId: 'docker-e2e-session', + }, + }, + }) as any; + + expect(response3.result.isError).toBeUndefined(); + const data3 = JSON.parse(response3.result.content[0].text); + expect(data3.thoughtNumber).toBe(3); + expect(data3.totalThoughts).toBe(3); + expect(data3.nextThoughtNeeded).toBe(false); + }, TIMEOUT); + + it('should reject thoughts exceeding maximum length', async () => { + const longThought = 'x'.repeat(6000); // Exceeds MAX_THOUGHT_LENGTH=5000 + + const response = await sendMessage({ + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { + name: 'sequentialthinking', + arguments: { + thought: longThought, + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }, + }, + }) as any; + + expect(response.result.isError).toBe(true); + const errorData = JSON.parse(response.result.content[0].text); + expect(errorData.error).toBe('VALIDATION_ERROR'); + expect(errorData.message).toContain('exceeds maximum length'); + }, TIMEOUT); + + it('should handle revision thoughts', async () => { + const response = await sendMessage({ + jsonrpc: '2.0', + id: 8, + method: 'tools/call', + params: { + name: 'sequentialthinking', + arguments: { + thought: 'Revised thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + isRevision: true, + revisesThought: 1, + sessionId: 'revision-session', + }, + }, + }) as any; + + expect(response.result.isError).toBeUndefined(); + const revisionData = JSON.parse(response.result.content[0].text); + expect(revisionData.thoughtNumber).toBe(2); + expect(revisionData.totalThoughts).toBe(3); + }, TIMEOUT); + + it('should handle branch thoughts', async () => { + const response = await sendMessage({ + jsonrpc: '2.0', + id: 9, + method: 'tools/call', + params: { + name: 'sequentialthinking', + arguments: { + thought: 'Branch thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + branchFromThought: 1, + branchId: 'test-branch', + sessionId: 'branch-session', + }, + }, + }) as any; + + expect(response.result.isError).toBeUndefined(); + const branchData = JSON.parse(response.result.content[0].text); + expect(branchData.thoughtNumber).toBe(2); + expect(branchData.totalThoughts).toBe(3); + expect(branchData.branches).toContain('test-branch'); + }, TIMEOUT); + }); + + describe('Get Thought History Tool', () => { + it('should list get_thought_history in tools', async () => { + const response = await sendMessage({ + jsonrpc: '2.0', + id: 20, + method: 'tools/list', + params: {}, + }) as any; + + const historyTool = response.result.tools.find( + (tool: any) => tool.name === 'get_thought_history' + ); + expect(historyTool).toBeDefined(); + expect(historyTool.inputSchema).toBeDefined(); + }, TIMEOUT); + + it('should return empty history for unknown session', async () => { + const response = await sendMessage({ + jsonrpc: '2.0', + id: 21, + method: 'tools/call', + params: { + name: 'get_thought_history', + arguments: { + sessionId: 'nonexistent-session', + }, + }, + }) as any; + + expect(response.result.isError).toBeUndefined(); + const data = JSON.parse(response.result.content[0].text); + expect(data.sessionId).toBe('nonexistent-session'); + expect(data.count).toBe(0); + expect(data.thoughts).toEqual([]); + }, TIMEOUT); + }); + + describe('MCTS Tools', () => { + it('should list MCTS tools and set_thinking_mode in tools/list', async () => { + const response = await sendMessage({ + jsonrpc: '2.0', + id: 30, + method: 'tools/list', + params: {}, + }) as any; + + const toolNames = response.result.tools.map((t: any) => t.name); + expect(toolNames).toContain('backtrack'); + expect(toolNames).toContain('evaluate_thought'); + expect(toolNames).toContain('suggest_next_thought'); + expect(toolNames).toContain('get_thinking_summary'); + expect(toolNames).toContain('set_thinking_mode'); + }, TIMEOUT); + + it('should return tree error for backtrack with invalid session', async () => { + const response = await sendMessage({ + jsonrpc: '2.0', + id: 31, + method: 'tools/call', + params: { + name: 'backtrack', + arguments: { + sessionId: 'nonexistent-session', + nodeId: 'nonexistent-node', + }, + }, + }) as any; + + expect(response.result.isError).toBe(true); + const data = JSON.parse(response.result.content[0].text); + expect(data.error).toBe('TREE_ERROR'); + }, TIMEOUT); + + it('should return tree error for evaluate_thought with invalid session', async () => { + const response = await sendMessage({ + jsonrpc: '2.0', + id: 32, + method: 'tools/call', + params: { + name: 'evaluate_thought', + arguments: { + sessionId: 'nonexistent-session', + nodeId: 'nonexistent-node', + value: 0.5, + }, + }, + }) as any; + + expect(response.result.isError).toBe(true); + const data = JSON.parse(response.result.content[0].text); + expect(data.error).toBe('TREE_ERROR'); + }, TIMEOUT); + }); + + describe('Environment Configuration', () => { + it('should respect MAX_THOUGHT_LENGTH environment variable', async () => { + // The container is configured with MAX_THOUGHT_LENGTH=5000 + const response = await sendMessage({ + jsonrpc: '2.0', + id: 10, + method: 'tools/call', + params: { + name: 'sequentialthinking', + arguments: { + thought: 'x'.repeat(4999), // Just under limit + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }, + }, + }) as any; + + expect(response.result.isError).toBeUndefined(); + }, TIMEOUT); + }); + + describe('Error Handling', () => { + it('should return error for invalid method', async () => { + const response = await sendMessage({ + jsonrpc: '2.0', + id: 11, + method: 'invalid/method', + params: {}, + }) as any; + + expect(response.jsonrpc).toBe('2.0'); + expect(response.id).toBe(11); + expect(response.error).toBeDefined(); + }, TIMEOUT); + + it('should validate required parameters', async () => { + const response = await sendMessage({ + jsonrpc: '2.0', + id: 12, + method: 'tools/call', + params: { + name: 'sequentialthinking', + arguments: { + // Missing required fields + thoughtNumber: 1, + }, + }, + }) as any; + + expect(response.result.isError).toBe(true); + // Error text might be plain text or JSON depending on error type + const errorText = response.result.content[0].text; + expect(errorText).toContain('MCP error'); + }, TIMEOUT); + + it('should sanitize potentially harmful content', async () => { + const response = await sendMessage({ + jsonrpc: '2.0', + id: 13, + method: 'tools/call', + params: { + name: 'sequentialthinking', + arguments: { + thought: 'Visit javascript:alert(1) for more info', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }, + }, + }) as any; + + // Should succeed (sanitized, not blocked) + expect(response.result.isError).toBeUndefined(); + }, TIMEOUT); + }); + + describe('Health and Metrics', () => { + it('should respond to health check (if endpoint exists)', async () => { + // Note: This test assumes a health endpoint exists + // If not implemented, this test can be skipped + try { + const response = await sendMessage({ + jsonrpc: '2.0', + id: 14, + method: 'health/check', + params: {}, + }) as any; + + if (response.error?.code === -32601) { + // Method not found is acceptable + console.log('Health endpoint not implemented, skipping'); + } else { + expect(response.result).toBeDefined(); + } + } catch (e) { + // Health endpoint may not be exposed via MCP, that's OK + console.log('Health check not available via MCP'); + } + }, TIMEOUT); + }); + + describe('Session Management', () => { + it('should generate session ID when not provided', async () => { + const response = await sendMessage({ + jsonrpc: '2.0', + id: 15, + method: 'tools/call', + params: { + name: 'sequentialthinking', + arguments: { + thought: 'Thought without session ID', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }, + }, + }) as any; + + expect(response.result.isError).toBeUndefined(); + }, TIMEOUT); + + it('should reject invalid session IDs', async () => { + const response = await sendMessage({ + jsonrpc: '2.0', + id: 16, + method: 'tools/call', + params: { + name: 'sequentialthinking', + arguments: { + thought: 'Test thought', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: '', // Empty session ID + }, + }, + }) as any; + + expect(response.result.isError).toBe(true); + const errorData = JSON.parse(response.result.content[0].text); + // Empty session ID is caught by security validation + expect(errorData.error).toBe('SECURITY_ERROR'); + }, TIMEOUT); + }); +}); diff --git a/src/sequentialthinking/__tests__/helpers/factories.ts b/src/sequentialthinking/__tests__/helpers/factories.ts new file mode 100644 index 0000000000..ff964e52e2 --- /dev/null +++ b/src/sequentialthinking/__tests__/helpers/factories.ts @@ -0,0 +1,36 @@ +import { expect } from 'vitest'; +import type { ProcessThoughtRequest } from '../../lib.js'; + +export function createTestThought( + overrides?: Partial, +): ProcessThoughtRequest { + return { + thought: 'Test thought content', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + ...overrides, + }; +} + +export function createSessionThoughtSequence( + sessionId: string, + count: number, +): ProcessThoughtRequest[] { + return Array.from({ length: count }, (_, i) => ({ + thought: `Thought ${i + 1} for ${sessionId}`, + thoughtNumber: i + 1, + totalThoughts: count, + nextThoughtNeeded: i < count - 1, + sessionId, + })); +} + +export function expectErrorResponse( + result: { content: Array<{ type: string; text: string }>; isError?: boolean }, + errorCode: string, +): void { + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe(errorCode); +} diff --git a/src/sequentialthinking/__tests__/helpers/mocks.ts b/src/sequentialthinking/__tests__/helpers/mocks.ts new file mode 100644 index 0000000000..add2fcab8f --- /dev/null +++ b/src/sequentialthinking/__tests__/helpers/mocks.ts @@ -0,0 +1,16 @@ +import { vi } from 'vitest'; + +const identity = (str: string) => str; + +vi.mock('chalk', () => ({ + default: { + yellow: identity, + green: identity, + blue: identity, + gray: identity, + cyan: identity, + red: identity, + white: identity, + bold: identity, + }, +})); diff --git a/src/sequentialthinking/__tests__/integration/mcts-server.test.ts b/src/sequentialthinking/__tests__/integration/mcts-server.test.ts new file mode 100644 index 0000000000..5818308734 --- /dev/null +++ b/src/sequentialthinking/__tests__/integration/mcts-server.test.ts @@ -0,0 +1,663 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SequentialThinkingServer, ProcessThoughtRequest } from '../../lib.js'; + +describe('MCTS Server Integration', () => { + let server: SequentialThinkingServer; + + beforeEach(() => { + process.env.DISABLE_THOUGHT_LOGGING = 'true'; + server = new SequentialThinkingServer(); + }); + + afterEach(() => { + if (server && typeof server.destroy === 'function') { + server.destroy(); + } + }); + + describe('Tree Auto-Building', () => { + it('should include nodeId in processThought response', async () => { + const result = await server.processThought({ + thought: 'First thought', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + sessionId: 'mcts-test-1', + }); + + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0].text); + expect(data.nodeId).toBeDefined(); + expect(data.parentNodeId).toBeNull(); // First node has no parent + expect(data.treeStats).toBeDefined(); + expect(data.treeStats.totalNodes).toBe(1); + }); + + it('should build parent-child relationships', async () => { + const r1 = await server.processThought({ + thought: 'Root thought', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + sessionId: 'mcts-test-2', + }); + + const r2 = await server.processThought({ + thought: 'Child thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + sessionId: 'mcts-test-2', + }); + + const d1 = JSON.parse(r1.content[0].text); + const d2 = JSON.parse(r2.content[0].text); + + expect(d2.parentNodeId).toBe(d1.nodeId); + expect(d2.treeStats.totalNodes).toBe(2); + }); + + it('should handle branching in tree', async () => { + await server.processThought({ + thought: 'Root', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + sessionId: 'mcts-branch', + }); + + await server.processThought({ + thought: 'Main path', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + sessionId: 'mcts-branch', + }); + + const branchResult = await server.processThought({ + thought: 'Alternative path', + thoughtNumber: 3, + totalThoughts: 3, + nextThoughtNeeded: true, + branchFromThought: 1, + branchId: 'alt', + sessionId: 'mcts-branch', + }); + + const data = JSON.parse(branchResult.content[0].text); + expect(data.treeStats.totalNodes).toBe(3); + }); + }); + + describe('Backtrack Tool', () => { + it('should backtrack to a previous node', async () => { + const r1 = await server.processThought({ + thought: 'Root thought', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + sessionId: 'bt-test', + }); + const d1 = JSON.parse(r1.content[0].text); + + await server.processThought({ + thought: 'Second thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + sessionId: 'bt-test', + }); + + const btResult = await server.backtrack('bt-test', d1.nodeId); + expect(btResult.isError).toBeUndefined(); + + const btData = JSON.parse(btResult.content[0].text); + expect(btData.node.nodeId).toBe(d1.nodeId); + expect(btData.children).toHaveLength(1); + expect(btData.treeStats.totalNodes).toBe(2); + }); + + it('should return error for invalid session', async () => { + const result = await server.backtrack('nonexistent', 'node-1'); + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('TREE_ERROR'); + }); + }); + + describe('Evaluate Tool', () => { + it('should evaluate a thought node', async () => { + const r1 = await server.processThought({ + thought: 'Evaluate me', + thoughtNumber: 1, + totalThoughts: 2, + nextThoughtNeeded: true, + sessionId: 'eval-test', + }); + const d1 = JSON.parse(r1.content[0].text); + + const evalResult = await server.evaluateThought('eval-test', d1.nodeId, 0.85); + expect(evalResult.isError).toBeUndefined(); + + const evalData = JSON.parse(evalResult.content[0].text); + expect(evalData.nodeId).toBe(d1.nodeId); + expect(evalData.newVisitCount).toBe(1); + expect(evalData.newAverageValue).toBeCloseTo(0.85); + expect(evalData.nodesUpdated).toBe(1); + }); + + it('should reject value out of range', async () => { + const r1 = await server.processThought({ + thought: 'Test', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: 'eval-range-test', + }); + const d1 = JSON.parse(r1.content[0].text); + + const result = await server.evaluateThought('eval-range-test', d1.nodeId, 1.5); + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('VALIDATION_ERROR'); + }); + + it('should reject negative value', async () => { + const result = await server.evaluateThought('eval-range-test', 'node-1', -0.1); + expect(result.isError).toBe(true); + }); + }); + + describe('Suggest Tool', () => { + it('should suggest next thought to explore', async () => { + await server.processThought({ + thought: 'Root', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + sessionId: 'suggest-test', + }); + + await server.processThought({ + thought: 'Child', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + sessionId: 'suggest-test', + }); + + const result = await server.suggestNextThought('suggest-test', 'balanced'); + expect(result.isError).toBeUndefined(); + + const data = JSON.parse(result.content[0].text); + expect(data.suggestion).not.toBeNull(); + expect(data.suggestion.nodeId).toBeDefined(); + expect(data.suggestion.ucb1Score).toBeDefined(); + expect(data.treeStats).toBeDefined(); + }); + + it('should return null suggestion when all terminal', async () => { + await server.processThought({ + thought: 'Final', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: 'terminal-test', + }); + + const result = await server.suggestNextThought('terminal-test'); + expect(result.isError).toBeUndefined(); + + const data = JSON.parse(result.content[0].text); + expect(data.suggestion).toBeNull(); + }); + + it('should return error for invalid session', async () => { + const result = await server.suggestNextThought('nonexistent'); + expect(result.isError).toBe(true); + }); + }); + + describe('Summary Tool', () => { + it('should return thinking summary with best path', async () => { + const r1 = await server.processThought({ + thought: 'Start here', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + sessionId: 'summary-test', + }); + const d1 = JSON.parse(r1.content[0].text); + + const r2 = await server.processThought({ + thought: 'Good path', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: false, + sessionId: 'summary-test', + }); + const d2 = JSON.parse(r2.content[0].text); + + // Evaluate the good path + await server.evaluateThought('summary-test', d2.nodeId, 0.9); + + const result = await server.getThinkingSummary('summary-test'); + expect(result.isError).toBeUndefined(); + + const data = JSON.parse(result.content[0].text); + expect(data.bestPath).toBeDefined(); + expect(data.bestPath.length).toBeGreaterThanOrEqual(1); + expect(data.treeStructure).not.toBeNull(); + expect(data.treeStats.totalNodes).toBe(2); + }); + + it('should return error for invalid session', async () => { + const result = await server.getThinkingSummary('nonexistent'); + expect(result.isError).toBe(true); + }); + }); + + describe('End-to-End MCTS Cycle', () => { + it('should complete a full MCTS exploration cycle', async () => { + const sessionId = 'e2e-mcts'; + + // Step 1: Submit initial thoughts + const t1 = await server.processThought({ + thought: 'Problem: Find the optimal sorting algorithm', + thoughtNumber: 1, + totalThoughts: 5, + nextThoughtNeeded: true, + sessionId, + }); + const d1 = JSON.parse(t1.content[0].text); + + const t2 = await server.processThought({ + thought: 'Approach 1: QuickSort — average O(n log n)', + thoughtNumber: 2, + totalThoughts: 5, + nextThoughtNeeded: true, + sessionId, + }); + const d2 = JSON.parse(t2.content[0].text); + + // Step 2: Evaluate the first approach + await server.evaluateThought(sessionId, d2.nodeId, 0.7); + + // Step 3: Backtrack to root and try alternative + await server.backtrack(sessionId, d1.nodeId); + + const t3 = await server.processThought({ + thought: 'Approach 2: MergeSort — guaranteed O(n log n)', + thoughtNumber: 3, + totalThoughts: 5, + nextThoughtNeeded: true, + branchFromThought: 1, + branchId: 'mergesort', + sessionId, + }); + const d3 = JSON.parse(t3.content[0].text); + + // Step 4: Evaluate the second approach higher + await server.evaluateThought(sessionId, d3.nodeId, 0.9); + + // Step 5: Get suggestion — should favor under-explored areas + const suggestion = await server.suggestNextThought(sessionId, 'balanced'); + const suggestData = JSON.parse(suggestion.content[0].text); + expect(suggestData.suggestion).not.toBeNull(); + + // Step 6: Verify best path follows higher-rated approach + const summary = await server.getThinkingSummary(sessionId); + const summaryData = JSON.parse(summary.content[0].text); + + expect(summaryData.bestPath.length).toBeGreaterThanOrEqual(2); + expect(summaryData.treeStats.totalNodes).toBe(3); + + // The best path should include the root and the mergesort branch (higher value) + const bestPathThoughts = summaryData.bestPath.map((n: any) => n.thought); + expect(bestPathThoughts[0]).toContain('sorting'); + expect(bestPathThoughts[1]).toContain('MergeSort'); + }); + }); + + describe('set_thinking_mode Tool', () => { + it('should set thinking mode and return config', async () => { + const result = await server.setThinkingMode('mode-test-1', 'fast'); + expect(result.isError).toBeUndefined(); + + const data = JSON.parse(result.content[0].text); + expect(data.sessionId).toBe('mode-test-1'); + expect(data.mode).toBe('fast'); + expect(data.config).toBeDefined(); + expect(data.config.explorationConstant).toBe(0.5); + expect(data.config.suggestStrategy).toBe('exploit'); + expect(data.config.maxBranchingFactor).toBe(1); + expect(data.config.autoEvaluate).toBe(true); + expect(data.config.enableBacktracking).toBe(false); + }); + + it('should reject invalid mode', async () => { + const result = await server.setThinkingMode('mode-test-2', 'invalid'); + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('VALIDATION_ERROR'); + }); + }); + + describe('Fast Mode E2E', () => { + it('should include modeGuidance and auto-evaluate', async () => { + const sessionId = 'fast-e2e'; + await server.setThinkingMode(sessionId, 'fast'); + + // Submit 3 thoughts + for (let i = 1; i <= 3; i++) { + const result = await server.processThought({ + thought: `Fast thought ${i}`, + thoughtNumber: i, + totalThoughts: 5, + nextThoughtNeeded: true, + sessionId, + }); + const data = JSON.parse(result.content[0].text); + expect(data.modeGuidance).toBeDefined(); + expect(data.modeGuidance.mode).toBe('fast'); + + // Auto-eval: node should be evaluated (unexploredCount decreasing) + expect(data.treeStats.unexploredCount).toBe(0); + } + }); + + it('should recommend conclude at target depth', async () => { + const sessionId = 'fast-conclude'; + await server.setThinkingMode(sessionId, 'fast'); + + // Submit 6 thoughts (depth reaches 5 = targetDepthMax) + let lastGuidance: any; + for (let i = 1; i <= 6; i++) { + const result = await server.processThought({ + thought: `Thought ${i}`, + thoughtNumber: i, + totalThoughts: 10, + nextThoughtNeeded: true, + sessionId, + }); + const data = JSON.parse(result.content[0].text); + lastGuidance = data.modeGuidance; + } + + expect(lastGuidance.recommendedAction).toBe('conclude'); + expect(lastGuidance.currentPhase).toBe('concluded'); + }); + }); + + describe('Expert Mode E2E', () => { + it('should provide branching suggestions', async () => { + const sessionId = 'expert-e2e'; + await server.setThinkingMode(sessionId, 'expert'); + + // Submit 3 thoughts (depth = 2, triggers branching) + let lastGuidance: any; + for (let i = 1; i <= 3; i++) { + const result = await server.processThought({ + thought: `Expert thought ${i}`, + thoughtNumber: i, + totalThoughts: 10, + nextThoughtNeeded: true, + sessionId, + }); + const data = JSON.parse(result.content[0].text); + lastGuidance = data.modeGuidance; + } + + expect(lastGuidance.recommendedAction).toBe('branch'); + expect(lastGuidance.branchingSuggestion).not.toBeNull(); + expect(lastGuidance.branchingSuggestion.shouldBranch).toBe(true); + }); + + it('should converge with enough high evaluations', async () => { + const sessionId = 'expert-converge'; + await server.setThinkingMode(sessionId, 'expert'); + + // Build some thoughts + const nodeIds: string[] = []; + for (let i = 1; i <= 4; i++) { + const result = await server.processThought({ + thought: `Convergence thought ${i}`, + thoughtNumber: i, + totalThoughts: 10, + nextThoughtNeeded: true, + sessionId, + }); + const data = JSON.parse(result.content[0].text); + nodeIds.push(data.nodeId); + } + + // Evaluate leaf with high values 3 times + const leafNodeId = nodeIds[nodeIds.length - 1]; + for (let i = 0; i < 3; i++) { + await server.evaluateThought(sessionId, leafNodeId, 0.9); + } + + // Submit another thought to get updated guidance + const result = await server.processThought({ + thought: 'Check convergence', + thoughtNumber: 5, + totalThoughts: 10, + nextThoughtNeeded: true, + sessionId, + }); + const data = JSON.parse(result.content[0].text); + + expect(data.modeGuidance.convergenceStatus).not.toBeNull(); + // With high evals on the path, should converge + expect(data.modeGuidance.convergenceStatus.score).toBeGreaterThan(0); + }); + }); + + describe('Deep Mode E2E', () => { + it('should provide explore-heavy guidance', async () => { + const sessionId = 'deep-e2e'; + await server.setThinkingMode(sessionId, 'deep'); + + const result = await server.processThought({ + thought: 'Deep exploration start', + thoughtNumber: 1, + totalThoughts: 20, + nextThoughtNeeded: true, + sessionId, + }); + const data = JSON.parse(result.content[0].text); + + expect(data.modeGuidance).toBeDefined(); + expect(data.modeGuidance.mode).toBe('deep'); + // Deep mode should recommend branching aggressively + expect(data.modeGuidance.recommendedAction).toBe('branch'); + expect(data.modeGuidance.branchingSuggestion).not.toBeNull(); + expect(data.modeGuidance.targetTotalThoughts).toBe(20); + }); + }); + + describe('thinkingMode parameter on sequentialthinking', () => { + it('should auto-set mode when thinkingMode provided on first thought', async () => { + const result = await server.processThought({ + thought: 'Inline mode test', + thoughtNumber: 1, + totalThoughts: 5, + nextThoughtNeeded: true, + sessionId: 'inline-mode', + thinkingMode: 'fast', + } as any); + + const data = JSON.parse(result.content[0].text); + expect(data.modeGuidance).toBeDefined(); + expect(data.modeGuidance.mode).toBe('fast'); + }); + }); + + describe('thoughtPrompt in responses', () => { + it('should include thoughtPrompt in processThought response when mode is set', async () => { + const sessionId = 'tp-present'; + await server.setThinkingMode(sessionId, 'fast'); + + const result = await server.processThought({ + thought: 'First thought', + thoughtNumber: 1, + totalThoughts: 5, + nextThoughtNeeded: true, + sessionId, + }); + + const data = JSON.parse(result.content[0].text); + expect(data.modeGuidance).toBeDefined(); + expect(data.modeGuidance.thoughtPrompt).toBeDefined(); + expect(typeof data.modeGuidance.thoughtPrompt).toBe('string'); + expect(data.modeGuidance.thoughtPrompt.length).toBeGreaterThan(0); + }); + + it('should change thoughtPrompt as depth/phase progresses (continue -> conclude in fast mode)', async () => { + const sessionId = 'tp-progress'; + await server.setThinkingMode(sessionId, 'fast'); + + // Submit first thought — should be "continue" + const r1 = await server.processThought({ + thought: 'Step one', + thoughtNumber: 1, + totalThoughts: 10, + nextThoughtNeeded: true, + sessionId, + }); + const d1 = JSON.parse(r1.content[0].text); + expect(d1.modeGuidance.recommendedAction).toBe('continue'); + const promptContinue = d1.modeGuidance.thoughtPrompt; + + // Submit enough thoughts to reach targetDepthMax (5) for fast mode + for (let i = 2; i <= 6; i++) { + await server.processThought({ + thought: `Step ${i}`, + thoughtNumber: i, + totalThoughts: 10, + nextThoughtNeeded: true, + sessionId, + }); + } + + // The 6th thought brings depth to 5 — should conclude + const rLast = await server.processThought({ + thought: 'Final step', + thoughtNumber: 7, + totalThoughts: 10, + nextThoughtNeeded: true, + sessionId, + }); + const dLast = JSON.parse(rLast.content[0].text); + expect(dLast.modeGuidance.recommendedAction).toBe('conclude'); + const promptConclude = dLast.modeGuidance.thoughtPrompt; + + // The two prompts should be different + expect(promptContinue).not.toBe(promptConclude); + expect(promptConclude).toContain('Synthesize'); + }); + }); + + describe('progressOverview and critique in modeGuidance', () => { + it('should include progressOverview and critique fields in modeGuidance response', async () => { + const sessionId = 'guidance-fields'; + await server.setThinkingMode(sessionId, 'expert'); + + const result = await server.processThought({ + thought: 'First thought', + thoughtNumber: 1, + totalThoughts: 10, + nextThoughtNeeded: true, + sessionId, + }); + + const data = JSON.parse(result.content[0].text); + expect(data.modeGuidance).toBeDefined(); + expect('progressOverview' in data.modeGuidance).toBe(true); + expect('critique' in data.modeGuidance).toBe(true); + }); + + it('fast mode: critique always null, progressOverview appears at interval 3', async () => { + const sessionId = 'fast-guidance'; + await server.setThinkingMode(sessionId, 'fast'); + + // Submit 3 thoughts (interval = 3) + let lastData: any; + for (let i = 1; i <= 3; i++) { + const result = await server.processThought({ + thought: `Fast thought ${i}`, + thoughtNumber: i, + totalThoughts: 5, + nextThoughtNeeded: true, + sessionId, + }); + lastData = JSON.parse(result.content[0].text); + // Critique always null for fast mode + expect(lastData.modeGuidance.critique).toBeNull(); + } + + // At 3 nodes, progressOverview should be non-null + expect(lastData.modeGuidance.progressOverview).not.toBeNull(); + expect(lastData.modeGuidance.progressOverview).toContain('PROGRESS'); + }); + + it('expert mode: both fields populate with sufficient data', async () => { + const sessionId = 'expert-guidance'; + await server.setThinkingMode(sessionId, 'expert'); + + // Submit 4 thoughts (expert interval = 4, critique needs bestPath >= 2) + for (let i = 1; i <= 4; i++) { + await server.processThought({ + thought: `Expert thought ${i}`, + thoughtNumber: i, + totalThoughts: 10, + nextThoughtNeeded: true, + sessionId, + }); + } + + // 4 nodes = interval for expert, bestPath >= 2 with enableCritique + // Need to check the last response + const result = await server.processThought({ + thought: 'Expert thought 5', + thoughtNumber: 5, + totalThoughts: 10, + nextThoughtNeeded: true, + sessionId, + }); + const data = JSON.parse(result.content[0].text); + + // critique should be non-null (expert mode, bestPath >= 2) + expect(data.modeGuidance.critique).not.toBeNull(); + expect(data.modeGuidance.critique).toContain('CRITIQUE'); + }); + }); + + describe('Backward Compatibility', () => { + it('should not break existing processThought response structure', async () => { + const result = await server.processThought({ + thought: 'Backward compat test', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: 'compat-test', + }); + + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0].text); + + // Existing fields still present + expect(data.thoughtNumber).toBe(1); + expect(data.totalThoughts).toBe(1); + expect(data.nextThoughtNeeded).toBe(false); + expect(data.sessionId).toBe('compat-test'); + expect(typeof data.timestamp).toBe('number'); + expect(typeof data.thoughtHistoryLength).toBe('number'); + expect(Array.isArray(data.branches)).toBe(true); + + // New MCTS fields are additive + expect(data.nodeId).toBeDefined(); + expect(data.treeStats).toBeDefined(); + }); + }); +}); diff --git a/src/sequentialthinking/__tests__/integration/performance.test.ts b/src/sequentialthinking/__tests__/integration/performance.test.ts new file mode 100644 index 0000000000..b73cad3e20 --- /dev/null +++ b/src/sequentialthinking/__tests__/integration/performance.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SequentialThinkingServer } from '../../lib.js'; + +describe('SequentialThinkingServer - Performance Tests', () => { + let server: SequentialThinkingServer; + + beforeEach(() => { + process.env.DISABLE_THOUGHT_LOGGING = 'true'; + server = new SequentialThinkingServer(); + }); + + afterEach(() => { + if (server && typeof server.destroy === 'function') { + server.destroy(); + } + }); + + describe('Memory Efficiency', () => { + it('should handle large thoughts efficiently', async () => { + const largeThought = 'a'.repeat(4000); // Within default 5000 limit + + const startTime = Date.now(); + + for (let i = 0; i < 100; i++) { + await server.processThought({ + thought: largeThought, + thoughtNumber: i + 1, + totalThoughts: 100, + nextThoughtNeeded: i < 99, + }); + } + + const duration = Date.now() - startTime; + + // Should process 100 large thoughts quickly + expect(duration).toBeLessThan(5000); + + const history = server.getThoughtHistory(); + expect(history.length).toBe(100); + }); + + it('should maintain performance with history at capacity', async () => { + // Fill history with many thoughts + for (let i = 0; i < 200; i++) { + await server.processThought({ + thought: `Thought ${i}`, + thoughtNumber: i + 1, + totalThoughts: 200, + nextThoughtNeeded: true, + }); + } + + const startTime = Date.now(); + + for (let i = 0; i < 50; i++) { + await server.processThought({ + thought: `Capacity test ${i}`, + thoughtNumber: i + 1, + totalThoughts: 50, + nextThoughtNeeded: true, + }); + } + + const duration = Date.now() - startTime; + + // Should still be performant + expect(duration).toBeLessThan(5000); + }); + }); + + describe('Concurrent Operations', () => { + it('should handle concurrent processing without conflicts', async () => { + const concurrentRequests = 20; + const promises = Array.from({ length: concurrentRequests }, (_, i) => + server.processThought({ + thought: `Concurrent ${i}`, + thoughtNumber: i + 1, + totalThoughts: concurrentRequests, + nextThoughtNeeded: i < concurrentRequests - 1, + }), + ); + + const startTime = Date.now(); + const results = await Promise.all(promises); + const duration = Date.now() - startTime; + + expect(results.every(r => !r.isError)).toBe(true); + expect(duration).toBeLessThan(5000); + + const history = server.getThoughtHistory(); + expect(history).toHaveLength(concurrentRequests); + }); + + it('should maintain consistency under high load', async () => { + const batchSize = 50; + const batches = 3; + + for (let batch = 0; batch < batches; batch++) { + const promises = Array.from({ length: batchSize }, (_, i) => + server.processThought({ + thought: `Batch ${batch}-${i}`, + thoughtNumber: i + 1, + totalThoughts: batchSize, + nextThoughtNeeded: i < batchSize - 1, + }), + ); + + await Promise.all(promises); + } + + const history = server.getThoughtHistory(); + expect(history.length).toBe(batches * batchSize); + }); + }); + + describe('Memory Management', () => { + it('should not leak memory during extended operation', async () => { + const initialMemory = process.memoryUsage().heapUsed; + + for (let i = 0; i < 500; i++) { + await server.processThought({ + thought: `Memory test ${i}`, + thoughtNumber: i % 100 + 1, + totalThoughts: 100, + nextThoughtNeeded: true, + }); + } + + const finalMemory = process.memoryUsage().heapUsed; + const memoryIncrease = finalMemory - initialMemory; + + // Memory increase should be reasonable (less than 50MB for 500 operations) + expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024); + }); + }); + + describe('Response Time Consistency', () => { + it('should maintain consistent response times', async () => { + const responseTimes: number[] = []; + + for (let i = 0; i < 100; i++) { + const startTime = Date.now(); + + await server.processThought({ + thought: `Timing test ${i}`, + thoughtNumber: i + 1, + totalThoughts: 100, + nextThoughtNeeded: i < 99, + }); + + const responseTime = Date.now() - startTime; + responseTimes.push(responseTime); + } + + const avgResponseTime = + responseTimes.reduce((sum, time) => sum + time, 0) / + responseTimes.length; + const maxResponseTime = Math.max(...responseTimes); + + expect(avgResponseTime).toBeLessThan(50); + expect(maxResponseTime).toBeLessThan(200); + }); + }); +}); diff --git a/src/sequentialthinking/__tests__/integration/server.test.ts b/src/sequentialthinking/__tests__/integration/server.test.ts new file mode 100644 index 0000000000..3cfe792ea6 --- /dev/null +++ b/src/sequentialthinking/__tests__/integration/server.test.ts @@ -0,0 +1,1062 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SequentialThinkingServer, ProcessThoughtRequest } from '../../lib.js'; + +describe('SequentialThinkingServer', () => { + let server: SequentialThinkingServer; + + beforeEach(() => { + process.env.DISABLE_THOUGHT_LOGGING = 'true'; + server = new SequentialThinkingServer(); + }); + + afterEach(() => { + if (server && typeof server.destroy === 'function') { + server.destroy(); + } + }); + + describe('Basic Functionality', () => { + it('should process a valid thought successfully', async () => { + const input: ProcessThoughtRequest = { + thought: 'This is my first thought', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + }; + + const result = await server.processThought(input); + + expect(result.isError).toBeUndefined(); + expect(result.content).toHaveLength(1); + + const data = JSON.parse(result.content[0].text); + expect(data.thoughtNumber).toBe(1); + expect(data.totalThoughts).toBe(3); + expect(data.nextThoughtNeeded).toBe(true); + expect(data.thoughtHistoryLength).toBe(1); + expect(typeof data.sessionId).toBe('string'); + expect(data.sessionId.length).toBeGreaterThan(0); + expect(typeof data.timestamp).toBe('number'); + expect(data.timestamp).toBeGreaterThan(0); + }); + + it('should accept thought with optional fields', async () => { + const input: ProcessThoughtRequest = { + thought: 'Revising my earlier idea', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + isRevision: true, + revisesThought: 1, + needsMoreThoughts: false, + }; + + const result = await server.processThought(input); + expect(result.isError).toBeUndefined(); + + const data = JSON.parse(result.content[0].text); + expect(data.thoughtNumber).toBe(2); + expect(data.thoughtHistoryLength).toBe(1); + }); + + it('should track multiple thoughts in history', async () => { + await server.processThought({ + thought: 'First thought', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + }); + await server.processThought({ + thought: 'Second thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + }); + const result = await server.processThought({ + thought: 'Final thought', + thoughtNumber: 3, + totalThoughts: 3, + nextThoughtNeeded: false, + }); + + const data = JSON.parse(result.content[0].text); + expect(data.thoughtHistoryLength).toBe(3); + expect(data.nextThoughtNeeded).toBe(false); + }); + + it('should auto-adjust totalThoughts if thoughtNumber exceeds it', async () => { + const result = await server.processThought({ + thought: 'Thought 5', + thoughtNumber: 5, + totalThoughts: 3, + nextThoughtNeeded: true, + }); + const data = JSON.parse(result.content[0].text); + expect(data.totalThoughts).toBe(5); + }); + }); + + describe('Input Validation', () => { + it('should reject empty thought', async () => { + const result = await server.processThought({ + thought: '', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + } as ProcessThoughtRequest); + + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('VALIDATION_ERROR'); + expect(data.message).toContain('Thought is required'); + }); + + it('should reject invalid thoughtNumber', async () => { + const result = await server.processThought({ + thought: 'Valid thought', + thoughtNumber: 0, + totalThoughts: 3, + nextThoughtNeeded: true, + } as ProcessThoughtRequest); + + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('VALIDATION_ERROR'); + expect(data.message).toContain('thoughtNumber must be a positive integer'); + }); + + it('should reject invalid totalThoughts', async () => { + const result = await server.processThought({ + thought: 'Valid thought', + thoughtNumber: 1, + totalThoughts: -1, + nextThoughtNeeded: true, + } as ProcessThoughtRequest); + + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('VALIDATION_ERROR'); + expect(data.message).toContain('totalThoughts must be a positive integer'); + }); + + it('should reject invalid nextThoughtNeeded', async () => { + const result = await server.processThought({ + thought: 'Valid thought', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: 'true' as any, + } as ProcessThoughtRequest); + + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('VALIDATION_ERROR'); + expect(data.message).toContain('nextThoughtNeeded must be a boolean'); + }); + + it('should handle malformed input gracefully', async () => { + const result = await server.processThought({ + thought: null, + thoughtNumber: 'invalid', + totalThoughts: 'invalid', + nextThoughtNeeded: 'invalid', + } as any); + + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBeDefined(); + expect(data.timestamp).toBeDefined(); + }); + }); + + describe('Business Logic', () => { + it('should reject revision without revisesThought', async () => { + const result = await server.processThought({ + thought: 'This is a revision', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + isRevision: true, + }); + + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('BUSINESS_LOGIC_ERROR'); + expect(data.message).toContain('isRevision requires revisesThought'); + }); + + it('should reject branch without branchId', async () => { + const result = await server.processThought({ + thought: 'This is a branch', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + branchFromThought: 1, + }); + + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('BUSINESS_LOGIC_ERROR'); + expect(data.message).toContain('branchFromThought requires branchId'); + }); + + it('should accept valid revision', async () => { + const result = await server.processThought({ + thought: 'This is a valid revision', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + isRevision: true, + revisesThought: 1, + }); + + expect(result.isError).toBeUndefined(); + }); + + it('should accept valid branch', async () => { + const result = await server.processThought({ + thought: 'This is a valid branch', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + branchFromThought: 1, + branchId: 'branch-1', + }); + + expect(result.isError).toBeUndefined(); + }); + }); + + describe('Security', () => { + it('should reject overly long thoughts', async () => { + const result = await server.processThought({ + thought: 'a'.repeat(6000), + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + }); + + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('VALIDATION_ERROR'); + expect(data.message).toContain('exceeds maximum length'); + }); + + it('should sanitize and accept previously blocked patterns', async () => { + // javascript: gets sanitized away before validation + const result = await server.processThought({ + thought: 'Visit javascript: void(0) for info', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + }); + + expect(result.isError).toBeUndefined(); // Success = undefined, not false + // Content was sanitized (javascript: removed) + }); + + it('should sanitize and accept normal content', async () => { + const result = await server.processThought({ + thought: 'Normal text with some test content', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + }); + + expect(result.isError).toBeUndefined(); + }); + }); + + describe('Session Management', () => { + it('should generate and track session IDs', async () => { + const result1 = await server.processThought({ + thought: 'First thought', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + }); + const result2 = await server.processThought({ + thought: 'Second thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: false, + }); + + const parsed1 = JSON.parse(result1.content[0].text); + const parsed2 = JSON.parse(result2.content[0].text); + + expect(typeof parsed1.sessionId).toBe('string'); + expect(parsed1.sessionId.length).toBeGreaterThan(0); + expect(typeof parsed2.sessionId).toBe('string'); + expect(parsed2.sessionId.length).toBeGreaterThan(0); + // Auto-generated session IDs differ between calls (no session persistence) + expect(parsed1.sessionId).not.toBe(parsed2.sessionId); + }); + + it('should accept provided session ID', async () => { + const sessionId = 'test-session-123'; + const result = await server.processThought({ + thought: 'Thought with session', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + sessionId, + }); + const data = JSON.parse(result.content[0].text); + expect(data.sessionId).toBe(sessionId); + }); + + it('should reject invalid session ID', async () => { + const result = await server.processThought({ + thought: 'Thought with invalid session', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + sessionId: '', + }); + + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.message).toContain('Invalid session ID'); + }); + }); + + describe('Branching', () => { + it('should track multiple branches correctly', async () => { + await server.processThought({ + thought: 'Main thought', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + }); + await server.processThought({ + thought: 'Branch A thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + branchFromThought: 1, + branchId: 'branch-a', + }); + const result = await server.processThought({ + thought: 'Branch B thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: false, + branchFromThought: 1, + branchId: 'branch-b', + }); + + const data = JSON.parse(result.content[0].text); + expect(data.branches).toContain('branch-a'); + expect(data.branches).toContain('branch-b'); + expect(data.branches.length).toBe(2); + expect(data.thoughtHistoryLength).toBe(3); + }); + + it('should allow multiple thoughts in same branch', async () => { + await server.processThought({ + thought: 'Branch thought 1', + thoughtNumber: 1, + totalThoughts: 2, + nextThoughtNeeded: true, + branchFromThought: 1, + branchId: 'branch-a', + }); + const result = await server.processThought({ + thought: 'Branch thought 2', + thoughtNumber: 2, + totalThoughts: 2, + nextThoughtNeeded: false, + branchFromThought: 1, + branchId: 'branch-a', + }); + + const data = JSON.parse(result.content[0].text); + expect(data.branches).toContain('branch-a'); + expect(data.branches.length).toBe(1); + }); + }); + + describe('Response Format', () => { + it('should return correct response structure on success', async () => { + const result = await server.processThought({ + thought: 'Test thought', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }); + + expect(result).toHaveProperty('content'); + expect(Array.isArray(result.content)).toBe(true); + expect(result.content.length).toBe(1); + expect(result.content[0]).toHaveProperty('type', 'text'); + expect(result.content[0]).toHaveProperty('text'); + }); + + it('should return valid JSON in response', async () => { + const result = await server.processThought({ + thought: 'Test thought', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }); + + expect(() => JSON.parse(result.content[0].text)).not.toThrow(); + }); + }); + + describe('Edge Cases', () => { + it('should handle thought strings within limits', async () => { + const result = await server.processThought({ + thought: 'a'.repeat(4000), + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }); + expect(result.isError).toBeUndefined(); + }); + + it('should handle thoughtNumber = 1, totalThoughts = 1', async () => { + const result = await server.processThought({ + thought: 'Only thought', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }); + expect(result.isError).toBeUndefined(); + + const data = JSON.parse(result.content[0].text); + expect(data.thoughtNumber).toBe(1); + expect(data.totalThoughts).toBe(1); + expect(data.nextThoughtNeeded).toBe(false); + }); + + it('should handle nextThoughtNeeded = false', async () => { + const result = await server.processThought({ + thought: 'Final thought', + thoughtNumber: 3, + totalThoughts: 3, + nextThoughtNeeded: false, + }); + const data = JSON.parse(result.content[0].text); + expect(data.nextThoughtNeeded).toBe(false); + }); + }); + + describe('Logging', () => { + let serverWithLogging: SequentialThinkingServer; + + beforeEach(() => { + delete process.env.DISABLE_THOUGHT_LOGGING; + serverWithLogging = new SequentialThinkingServer(); + }); + + afterEach(() => { + process.env.DISABLE_THOUGHT_LOGGING = 'true'; + if (serverWithLogging && typeof serverWithLogging.destroy === 'function') { + serverWithLogging.destroy(); + } + }); + + it('should format and log regular thoughts', async () => { + const result = await serverWithLogging.processThought({ + thought: 'Test thought with logging', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + }); + expect(result.isError).toBeUndefined(); + }); + + it('should format and log revision thoughts', async () => { + const result = await serverWithLogging.processThought({ + thought: 'Revised thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + isRevision: true, + revisesThought: 1, + }); + expect(result.isError).toBeUndefined(); + }); + + it('should format and log branch thoughts', async () => { + const result = await serverWithLogging.processThought({ + thought: 'Branch thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: false, + branchFromThought: 1, + branchId: 'branch-a', + }); + expect(result.isError).toBeUndefined(); + }); + }); + + describe('Health & Metrics', () => { + it('should return health status with all checks', async () => { + await server.processThought({ + thought: 'Health check test thought', + thoughtNumber: 1, + totalThoughts: 2, + nextThoughtNeeded: false, + }); + + const health = await server.getHealthStatus(); + + expect(health).toHaveProperty('status'); + expect(health).toHaveProperty('checks'); + expect(health).toHaveProperty('summary'); + expect(health).toHaveProperty('uptime'); + expect(health).toHaveProperty('timestamp'); + expect(['healthy', 'unhealthy', 'degraded']).toContain(health.status); + + const checks = health.checks as Record; + expect(checks).toHaveProperty('memory'); + expect(checks).toHaveProperty('responseTime'); + expect(checks).toHaveProperty('errorRate'); + expect(checks).toHaveProperty('storage'); + expect(checks).toHaveProperty('security'); + }); + + it('should return metrics structure', () => { + const metrics = server.getMetrics() as Record; + + expect(metrics).toHaveProperty('requests'); + expect(metrics).toHaveProperty('thoughts'); + expect(metrics).toHaveProperty('system'); + expect(metrics.requests).toHaveProperty('totalRequests'); + expect(metrics.requests).toHaveProperty('successfulRequests'); + expect(metrics.requests).toHaveProperty('failedRequests'); + }); + + it('should track metrics across operations', async () => { + await server.processThought({ + thought: 'Valid thought 1', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + }); + await server.processThought({ + thought: 'Valid thought 2', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + }); + // Send one invalid request + await server.processThought({ + thought: '', + thoughtNumber: 3, + totalThoughts: 3, + nextThoughtNeeded: false, + } as any); + + const metrics = server.getMetrics() as Record; + + // Validation errors happen before processWithServices, so only 2 successful recorded + expect(metrics.requests.totalRequests).toBe(2); + expect(metrics.requests.successfulRequests).toBe(2); + expect(metrics.thoughts.totalThoughts).toBe(2); + }); + }); + + describe('End-to-End Workflows', () => { + it('should handle complete thinking session', async () => { + const sessionId = 'integration-test-session'; + + const thought1 = await server.processThought({ + thought: 'I need to solve a complex problem step by step', + thoughtNumber: 1, + totalThoughts: 4, + nextThoughtNeeded: true, + sessionId, + }); + expect(thought1.isError).toBeUndefined(); + const parsed1 = JSON.parse(thought1.content[0].text); + expect(parsed1.thoughtNumber).toBe(1); + expect(parsed1.thoughtHistoryLength).toBe(1); + + const thought2 = await server.processThought({ + thought: 'First, I should understand the problem requirements', + thoughtNumber: 2, + totalThoughts: 4, + nextThoughtNeeded: true, + sessionId, + }); + expect(thought2.isError).toBeUndefined(); + + const thought3 = await server.processThought({ + thought: 'Alternative approach: Consider using a different algorithm', + thoughtNumber: 3, + totalThoughts: 4, + nextThoughtNeeded: true, + branchFromThought: 2, + branchId: 'alternative-approach', + sessionId, + }); + const parsed3 = JSON.parse(thought3.content[0].text); + expect(parsed3.branches).toContain('alternative-approach'); + + const thought4 = await server.processThought({ + thought: 'Revising approach 1: The original method is actually better', + thoughtNumber: 4, + totalThoughts: 4, + nextThoughtNeeded: false, + isRevision: true, + revisesThought: 2, + sessionId, + }); + const parsed4 = JSON.parse(thought4.content[0].text); + expect(parsed4.nextThoughtNeeded).toBe(false); + + const history = server.getThoughtHistory(); + expect(history).toHaveLength(4); + + const branches = server.getBranches(); + expect(branches).toContain('alternative-approach'); + }); + + it('should handle and recover from invalid input', async () => { + const invalidResult = await server.processThought({ + thought: '', + thoughtNumber: -1, + totalThoughts: -1, + nextThoughtNeeded: 'invalid' as any, + } as any); + expect(invalidResult.isError).toBe(true); + + const validResult = await server.processThought({ + thought: 'Now this is valid', + thoughtNumber: 1, + totalThoughts: 2, + nextThoughtNeeded: true, + sessionId: 'error-recovery-test', + }); + expect(validResult.isError).toBeUndefined(); + + const parsed = JSON.parse(validResult.content[0].text); + expect(parsed.thoughtNumber).toBe(1); + expect(parsed.sessionId).toBe('error-recovery-test'); + }); + + it('should handle large number of thoughts without memory issues', async () => { + const sessionId = 'memory-test'; + const initialMemory = process.memoryUsage().heapUsed; + + for (let i = 0; i < 200; i++) { + await server.processThought({ + thought: `Memory test thought ${i} with some content to make it realistic`, + thoughtNumber: i + 1, + totalThoughts: 250, + nextThoughtNeeded: i < 199, + sessionId, + }); + } + + const finalMemory = process.memoryUsage().heapUsed; + const memoryIncrease = finalMemory - initialMemory; + + expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024); + + const history = server.getThoughtHistory(); + expect(history.length).toBeLessThanOrEqual(1000); + }); + }); + + describe('Configuration', () => { + it('should respect environment configuration', async () => { + const original = process.env.MAX_THOUGHT_LENGTH; + process.env.MAX_THOUGHT_LENGTH = '500'; + + try { + const configuredServer = new SequentialThinkingServer(); + + const result = await configuredServer.processThought({ + thought: 'a'.repeat(501), + thoughtNumber: 1, + totalThoughts: 2, + nextThoughtNeeded: false, + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('exceeds maximum length'); + + configuredServer.destroy(); + } finally { + if (original === undefined) { + delete process.env.MAX_THOUGHT_LENGTH; + } else { + process.env.MAX_THOUGHT_LENGTH = original; + } + } + }); + }); + + describe('Lifecycle', () => { + it('should clean up resources properly on shutdown', async () => { + await server.processThought({ + thought: 'Shutdown test', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }); + + expect(() => { + server.destroy(); + }).not.toThrow(); + }); + + it('should provide legacy compatibility methods', () => { + const history = server.getThoughtHistory(); + expect(Array.isArray(history)).toBe(true); + + const branches = server.getBranches(); + expect(Array.isArray(branches)).toBe(true); + }); + }); + + describe('Boundary Tests', () => { + it('should accept thought at exactly 5000 chars', async () => { + const result = await server.processThought({ + thought: 'a'.repeat(5000), + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }); + expect(result.isError).toBeUndefined(); + }); + + it('should reject thought at 5001 chars', async () => { + const result = await server.processThought({ + thought: 'a'.repeat(5001), + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }); + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('VALIDATION_ERROR'); + }); + + it('should accept session ID at 100 chars', async () => { + const result = await server.processThought({ + thought: 'Boundary test', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: 'a'.repeat(100), + }); + expect(result.isError).toBeUndefined(); + }); + + it('should reject session ID at 101 chars', async () => { + const result = await server.processThought({ + thought: 'Boundary test', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: 'a'.repeat(101), + }); + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.message).toContain('Invalid session ID'); + }); + }); + + describe('Health Status Error Fallback', () => { + it('should return unhealthy fallback after destroy', async () => { + server.destroy(); + const health = await server.getHealthStatus(); + expect(health.status).toBe('unhealthy'); + expect(health.checks.memory.status).toBe('unhealthy'); + expect(health.checks.responseTime.status).toBe('unhealthy'); + expect(health.checks.errorRate.status).toBe('unhealthy'); + expect(health.checks.storage.status).toBe('unhealthy'); + expect(health.checks.security.status).toBe('unhealthy'); + }); + }); + + describe('Legacy Methods After Destroy', () => { + it('should return empty array from getThoughtHistory after destroy and log a warning', () => { + server.destroy(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = server.getThoughtHistory(); + expect(result).toEqual([]); + expect(errorSpy).toHaveBeenCalled(); + const loggedMessage = errorSpy.mock.calls.find( + call => typeof call[0] === 'string' && call[0].includes('Warning'), + ); + expect(loggedMessage).toBeDefined(); + }); + + it('should return empty array from getBranches after destroy and log a warning', () => { + server.destroy(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = server.getBranches(); + expect(result).toEqual([]); + expect(errorSpy).toHaveBeenCalled(); + const loggedMessage = errorSpy.mock.calls.find( + call => typeof call[0] === 'string' && call[0].includes('Warning'), + ); + expect(loggedMessage).toBeDefined(); + }); + }); + + describe('processThought after destroy', () => { + it('should return well-formed error response after destroy', async () => { + server.destroy(); + const result = await server.processThought({ + thought: 'After destroy', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }); + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(1); + // Should be parseable JSON + expect(() => JSON.parse(result.content[0].text)).not.toThrow(); + }); + }); + + describe('Enriched Response Context', () => { + it('should include revisionContext when revising an existing thought', async () => { + const sessionId = 'revision-context-test'; + await server.processThought({ + thought: 'Original idea about sorting', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + sessionId, + }); + + const result = await server.processThought({ + thought: 'Actually, merge sort is better', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + isRevision: true, + revisesThought: 1, + sessionId, + }); + + const data = JSON.parse(result.content[0].text); + expect(data.revisionContext).toBeDefined(); + expect(data.revisionContext.originalThoughtNumber).toBe(1); + expect(data.revisionContext.originalThought).toContain('sorting'); + }); + + it('should not include revisionContext for non-revision thoughts', async () => { + const result = await server.processThought({ + thought: 'Regular thought', + thoughtNumber: 1, + totalThoughts: 2, + nextThoughtNeeded: true, + }); + + const data = JSON.parse(result.content[0].text); + expect(data.revisionContext).toBeUndefined(); + }); + + it('should include branchContext when branch has prior thoughts', async () => { + const sessionId = 'branch-context-test'; + await server.processThought({ + thought: 'First branch thought', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + branchFromThought: 1, + branchId: 'ctx-branch', + sessionId, + }); + + const result = await server.processThought({ + thought: 'Second branch thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + branchFromThought: 1, + branchId: 'ctx-branch', + sessionId, + }); + + const data = JSON.parse(result.content[0].text); + expect(data.branchContext).toBeDefined(); + expect(data.branchContext.branchId).toBe('ctx-branch'); + expect(data.branchContext.existingThoughts.length).toBeGreaterThanOrEqual(1); + expect(data.branchContext.existingThoughts[0].thought).toContain('First branch'); + }); + + it('should not include branchContext for first thought in a branch', async () => { + const result = await server.processThought({ + thought: 'First and only branch thought', + thoughtNumber: 1, + totalThoughts: 2, + nextThoughtNeeded: true, + branchFromThought: 1, + branchId: 'solo-branch', + }); + + const data = JSON.parse(result.content[0].text); + expect(data.branchContext).toBeUndefined(); + }); + }); + + describe('getFilteredHistory', () => { + it('should return thoughts for a specific session', async () => { + const sessionId = 'filter-test'; + await server.processThought({ + thought: 'Thought A', + thoughtNumber: 1, + totalThoughts: 2, + nextThoughtNeeded: true, + sessionId, + }); + await server.processThought({ + thought: 'Thought B', + thoughtNumber: 2, + totalThoughts: 2, + nextThoughtNeeded: false, + sessionId, + }); + // Different session + await server.processThought({ + thought: 'Other session', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: 'other-session', + }); + + const history = server.getFilteredHistory({ sessionId }); + expect(history).toHaveLength(2); + expect(history.every((t) => t.sessionId === sessionId)).toBe(true); + }); + + it('should filter by branchId', async () => { + const sessionId = 'branch-filter-test'; + await server.processThought({ + thought: 'Branch thought', + thoughtNumber: 1, + totalThoughts: 2, + nextThoughtNeeded: true, + branchFromThought: 1, + branchId: 'filter-branch', + sessionId, + }); + await server.processThought({ + thought: 'Main thought', + thoughtNumber: 2, + totalThoughts: 2, + nextThoughtNeeded: false, + sessionId, + }); + + const branchHistory = server.getFilteredHistory({ sessionId, branchId: 'filter-branch' }); + expect(branchHistory).toHaveLength(1); + expect(branchHistory[0].thought).toContain('Branch thought'); + }); + + it('should respect limit parameter', async () => { + const sessionId = 'limit-test'; + for (let i = 1; i <= 5; i++) { + await server.processThought({ + thought: `Thought ${i}`, + thoughtNumber: i, + totalThoughts: 5, + nextThoughtNeeded: i < 5, + sessionId, + }); + } + + const limited = server.getFilteredHistory({ sessionId, limit: 2 }); + expect(limited).toHaveLength(2); + // Should return the most recent + expect(limited[0].thoughtNumber).toBe(4); + expect(limited[1].thoughtNumber).toBe(5); + }); + + it('should return empty array for unknown session', () => { + const history = server.getFilteredHistory({ sessionId: 'nonexistent' }); + expect(history).toEqual([]); + }); + }); + + describe('Whitespace-only thought rejection', () => { + it('should reject whitespace-only thought', async () => { + const result = await server.processThought({ + thought: ' \t\n ', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }); + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('VALIDATION_ERROR'); + }); + }); + + describe('Non-integer validation', () => { + it('should reject non-integer thoughtNumber', async () => { + const result = await server.processThought({ + thought: 'Valid thought', + thoughtNumber: 1.5, + totalThoughts: 3, + nextThoughtNeeded: true, + }); + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('VALIDATION_ERROR'); + expect(data.message).toContain('positive integer'); + }); + + it('should reject non-integer totalThoughts', async () => { + const result = await server.processThought({ + thought: 'Valid thought', + thoughtNumber: 1, + totalThoughts: 2.5, + nextThoughtNeeded: true, + }); + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('VALIDATION_ERROR'); + expect(data.message).toContain('positive integer'); + }); + }); + + describe('Regex-Based Blocked Pattern Matching', () => { + it('should sanitize eval( before validation', async () => { + // eval( is now sanitized away before regex validation happens + const result = await server.processThought({ + thought: 'use eval(code) here', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }); + // Should succeed because eval( was sanitized away + expect(result.isError).toBeUndefined(); + }); + + it('should block document.cookie via regex', async () => { + const result = await server.processThought({ + thought: 'steal document.cookie from user', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }); + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('SECURITY_ERROR'); + }); + + it('should block file.exe via regex', async () => { + const result = await server.processThought({ + thought: 'download malware.exe from site', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }); + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('SECURITY_ERROR'); + }); + }); +}); diff --git a/src/sequentialthinking/__tests__/lib.test.ts b/src/sequentialthinking/__tests__/lib.test.ts deleted file mode 100644 index 2114c5ec18..0000000000 --- a/src/sequentialthinking/__tests__/lib.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { SequentialThinkingServer, ThoughtData } from '../lib.js'; - -// Mock chalk to avoid ESM issues -vi.mock('chalk', () => { - const chalkMock = { - yellow: (str: string) => str, - green: (str: string) => str, - blue: (str: string) => str, - }; - return { - default: chalkMock, - }; -}); - -describe('SequentialThinkingServer', () => { - let server: SequentialThinkingServer; - - beforeEach(() => { - // Disable thought logging for tests - process.env.DISABLE_THOUGHT_LOGGING = 'true'; - server = new SequentialThinkingServer(); - }); - - // Note: Input validation tests removed - validation now happens at the tool - // registration layer via Zod schemas before processThought is called - - describe('processThought - valid inputs', () => { - it('should accept valid basic thought', () => { - const input = { - thought: 'This is my first thought', - thoughtNumber: 1, - totalThoughts: 3, - nextThoughtNeeded: true - }; - - const result = server.processThought(input); - expect(result.isError).toBeUndefined(); - - const data = JSON.parse(result.content[0].text); - expect(data.thoughtNumber).toBe(1); - expect(data.totalThoughts).toBe(3); - expect(data.nextThoughtNeeded).toBe(true); - expect(data.thoughtHistoryLength).toBe(1); - }); - - it('should accept thought with optional fields', () => { - const input = { - thought: 'Revising my earlier idea', - thoughtNumber: 2, - totalThoughts: 3, - nextThoughtNeeded: true, - isRevision: true, - revisesThought: 1, - needsMoreThoughts: false - }; - - const result = server.processThought(input); - expect(result.isError).toBeUndefined(); - - const data = JSON.parse(result.content[0].text); - expect(data.thoughtNumber).toBe(2); - expect(data.thoughtHistoryLength).toBe(1); - }); - - it('should track multiple thoughts in history', () => { - const input1 = { - thought: 'First thought', - thoughtNumber: 1, - totalThoughts: 3, - nextThoughtNeeded: true - }; - - const input2 = { - thought: 'Second thought', - thoughtNumber: 2, - totalThoughts: 3, - nextThoughtNeeded: true - }; - - const input3 = { - thought: 'Final thought', - thoughtNumber: 3, - totalThoughts: 3, - nextThoughtNeeded: false - }; - - server.processThought(input1); - server.processThought(input2); - const result = server.processThought(input3); - - const data = JSON.parse(result.content[0].text); - expect(data.thoughtHistoryLength).toBe(3); - expect(data.nextThoughtNeeded).toBe(false); - }); - - it('should auto-adjust totalThoughts if thoughtNumber exceeds it', () => { - const input = { - thought: 'Thought 5', - thoughtNumber: 5, - totalThoughts: 3, - nextThoughtNeeded: true - }; - - const result = server.processThought(input); - const data = JSON.parse(result.content[0].text); - - expect(data.totalThoughts).toBe(5); - }); - }); - - describe('processThought - branching', () => { - it('should track branches correctly', () => { - const input1 = { - thought: 'Main thought', - thoughtNumber: 1, - totalThoughts: 3, - nextThoughtNeeded: true - }; - - const input2 = { - thought: 'Branch A thought', - thoughtNumber: 2, - totalThoughts: 3, - nextThoughtNeeded: true, - branchFromThought: 1, - branchId: 'branch-a' - }; - - const input3 = { - thought: 'Branch B thought', - thoughtNumber: 2, - totalThoughts: 3, - nextThoughtNeeded: false, - branchFromThought: 1, - branchId: 'branch-b' - }; - - server.processThought(input1); - server.processThought(input2); - const result = server.processThought(input3); - - const data = JSON.parse(result.content[0].text); - expect(data.branches).toContain('branch-a'); - expect(data.branches).toContain('branch-b'); - expect(data.branches.length).toBe(2); - expect(data.thoughtHistoryLength).toBe(3); - }); - - it('should allow multiple thoughts in same branch', () => { - const input1 = { - thought: 'Branch thought 1', - thoughtNumber: 1, - totalThoughts: 2, - nextThoughtNeeded: true, - branchFromThought: 1, - branchId: 'branch-a' - }; - - const input2 = { - thought: 'Branch thought 2', - thoughtNumber: 2, - totalThoughts: 2, - nextThoughtNeeded: false, - branchFromThought: 1, - branchId: 'branch-a' - }; - - server.processThought(input1); - const result = server.processThought(input2); - - const data = JSON.parse(result.content[0].text); - expect(data.branches).toContain('branch-a'); - expect(data.branches.length).toBe(1); - }); - }); - - describe('processThought - edge cases', () => { - it('should handle very long thought strings', () => { - const input = { - thought: 'a'.repeat(10000), - thoughtNumber: 1, - totalThoughts: 1, - nextThoughtNeeded: false - }; - - const result = server.processThought(input); - expect(result.isError).toBeUndefined(); - }); - - it('should handle thoughtNumber = 1, totalThoughts = 1', () => { - const input = { - thought: 'Only thought', - thoughtNumber: 1, - totalThoughts: 1, - nextThoughtNeeded: false - }; - - const result = server.processThought(input); - expect(result.isError).toBeUndefined(); - - const data = JSON.parse(result.content[0].text); - expect(data.thoughtNumber).toBe(1); - expect(data.totalThoughts).toBe(1); - }); - - it('should handle nextThoughtNeeded = false', () => { - const input = { - thought: 'Final thought', - thoughtNumber: 3, - totalThoughts: 3, - nextThoughtNeeded: false - }; - - const result = server.processThought(input); - const data = JSON.parse(result.content[0].text); - - expect(data.nextThoughtNeeded).toBe(false); - }); - }); - - describe('processThought - response format', () => { - it('should return correct response structure on success', () => { - const input = { - thought: 'Test thought', - thoughtNumber: 1, - totalThoughts: 1, - nextThoughtNeeded: false - }; - - const result = server.processThought(input); - - expect(result).toHaveProperty('content'); - expect(Array.isArray(result.content)).toBe(true); - expect(result.content.length).toBe(1); - expect(result.content[0]).toHaveProperty('type', 'text'); - expect(result.content[0]).toHaveProperty('text'); - }); - - it('should return valid JSON in response', () => { - const input = { - thought: 'Test thought', - thoughtNumber: 1, - totalThoughts: 1, - nextThoughtNeeded: false - }; - - const result = server.processThought(input); - - expect(() => JSON.parse(result.content[0].text)).not.toThrow(); - }); - }); - - describe('processThought - with logging enabled', () => { - let serverWithLogging: SequentialThinkingServer; - - beforeEach(() => { - // Enable thought logging for these tests - delete process.env.DISABLE_THOUGHT_LOGGING; - serverWithLogging = new SequentialThinkingServer(); - }); - - afterEach(() => { - // Reset to disabled for other tests - process.env.DISABLE_THOUGHT_LOGGING = 'true'; - }); - - it('should format and log regular thoughts', () => { - const input = { - thought: 'Test thought with logging', - thoughtNumber: 1, - totalThoughts: 3, - nextThoughtNeeded: true - }; - - const result = serverWithLogging.processThought(input); - expect(result.isError).toBeUndefined(); - }); - - it('should format and log revision thoughts', () => { - const input = { - thought: 'Revised thought', - thoughtNumber: 2, - totalThoughts: 3, - nextThoughtNeeded: true, - isRevision: true, - revisesThought: 1 - }; - - const result = serverWithLogging.processThought(input); - expect(result.isError).toBeUndefined(); - }); - - it('should format and log branch thoughts', () => { - const input = { - thought: 'Branch thought', - thoughtNumber: 2, - totalThoughts: 3, - nextThoughtNeeded: false, - branchFromThought: 1, - branchId: 'branch-a' - }; - - const result = serverWithLogging.processThought(input); - expect(result.isError).toBeUndefined(); - }); - }); -}); diff --git a/src/sequentialthinking/__tests__/unit/branch-tracking.test.ts b/src/sequentialthinking/__tests__/unit/branch-tracking.test.ts new file mode 100644 index 0000000000..c86b2d67d0 --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/branch-tracking.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { BasicMetricsCollector } from '../../metrics.js'; +import { BoundedThoughtManager } from '../../state-manager.js'; +import { SessionTracker } from '../../session-tracker.js'; +import { createTestThought as makeThought } from '../helpers/factories.js'; + +describe('Branch Tracking Consistency', () => { + let metrics: BasicMetricsCollector; + let storage: BoundedThoughtManager; + let sessionTracker: SessionTracker; + + beforeEach(() => { + sessionTracker = new SessionTracker(0); + storage = new BoundedThoughtManager({ + maxHistorySize: 100, + maxBranchAge: 3600000, + maxThoughtLength: 5000, + maxThoughtsPerBranch: 50, + cleanupInterval: 0, + }, sessionTracker); + metrics = new BasicMetricsCollector(sessionTracker, storage); + }); + + afterEach(() => { + storage.destroy(); + sessionTracker.destroy(); + }); + + it('should reflect actual branch count from storage', () => { + // Add thoughts to different branches + storage.addThought(makeThought({ branchId: 'branch-a' })); + metrics.recordThoughtProcessed(makeThought({ branchId: 'branch-a' })); + + storage.addThought(makeThought({ branchId: 'branch-b' })); + metrics.recordThoughtProcessed(makeThought({ branchId: 'branch-b' })); + + storage.addThought(makeThought({ branchId: 'branch-c' })); + metrics.recordThoughtProcessed(makeThought({ branchId: 'branch-c' })); + + // Metrics should show 3 branches + const m = metrics.getMetrics(); + expect(m.thoughts.branchCount).toBe(3); + + // Verify storage agrees + expect(storage.getBranches()).toHaveLength(3); + }); + + it('should update when branches expire in storage', () => { + vi.useFakeTimers(); + try { + // Create storage with short branch expiry + const shortStorage = new BoundedThoughtManager({ + maxHistorySize: 100, + maxBranchAge: 1000, // 1 second + maxThoughtLength: 5000, + maxThoughtsPerBranch: 50, + cleanupInterval: 0, + }, sessionTracker); + + const shortMetrics = new BasicMetricsCollector(sessionTracker, shortStorage); + + // Add branch + shortStorage.addThought(makeThought({ branchId: 'expiring-branch' })); + shortMetrics.recordThoughtProcessed(makeThought({ branchId: 'expiring-branch' })); + + expect(shortMetrics.getMetrics().thoughts.branchCount).toBe(1); + + // Advance time past expiry + vi.advanceTimersByTime(2000); + + // Trigger cleanup + shortStorage.cleanup(); + + // Record a new thought to trigger metrics update + shortMetrics.recordThoughtProcessed(makeThought()); + + // Branch should be gone from both storage and metrics + expect(shortStorage.getBranches()).toHaveLength(0); + expect(shortMetrics.getMetrics().thoughts.branchCount).toBe(0); + + shortStorage.destroy(); + } finally { + vi.useRealTimers(); + } + }); + + it('should handle duplicate branch IDs correctly', () => { + // Add multiple thoughts to same branch + storage.addThought(makeThought({ branchId: 'duplicate-branch', thoughtNumber: 1 })); + metrics.recordThoughtProcessed(makeThought({ branchId: 'duplicate-branch', thoughtNumber: 1 })); + + storage.addThought(makeThought({ branchId: 'duplicate-branch', thoughtNumber: 2 })); + metrics.recordThoughtProcessed(makeThought({ branchId: 'duplicate-branch', thoughtNumber: 2 })); + + storage.addThought(makeThought({ branchId: 'duplicate-branch', thoughtNumber: 3 })); + metrics.recordThoughtProcessed(makeThought({ branchId: 'duplicate-branch', thoughtNumber: 3 })); + + // Should only count as 1 branch + expect(metrics.getMetrics().thoughts.branchCount).toBe(1); + expect(storage.getBranches()).toHaveLength(1); + }); + + it('should handle mixed branch and non-branch thoughts', () => { + // Add non-branch thought + storage.addThought(makeThought({ thoughtNumber: 1 })); + metrics.recordThoughtProcessed(makeThought({ thoughtNumber: 1 })); + + // Branch count should be 0 + expect(metrics.getMetrics().thoughts.branchCount).toBe(0); + + // Add branch thought + storage.addThought(makeThought({ branchId: 'new-branch', thoughtNumber: 2 })); + metrics.recordThoughtProcessed(makeThought({ branchId: 'new-branch', thoughtNumber: 2 })); + + // Branch count should be 1 + expect(metrics.getMetrics().thoughts.branchCount).toBe(1); + + // Add more non-branch thoughts + storage.addThought(makeThought({ thoughtNumber: 3 })); + metrics.recordThoughtProcessed(makeThought({ thoughtNumber: 3 })); + + // Branch count should still be 1 + expect(metrics.getMetrics().thoughts.branchCount).toBe(1); + }); + + it('should maintain consistency after storage clear', () => { + // Add several branches + for (let i = 0; i < 5; i++) { + storage.addThought(makeThought({ branchId: `branch-${i}` })); + metrics.recordThoughtProcessed(makeThought({ branchId: `branch-${i}` })); + } + + expect(metrics.getMetrics().thoughts.branchCount).toBe(5); + + // Clear storage + storage.clearHistory(); + + // Record a new thought to trigger metrics refresh + metrics.recordThoughtProcessed(makeThought()); + + // Metrics should reflect empty storage + expect(metrics.getMetrics().thoughts.branchCount).toBe(0); + expect(storage.getBranches()).toHaveLength(0); + }); + + it('should handle rapid branch creation correctly', () => { + // Create many branches rapidly + const branchCount = 100; + for (let i = 0; i < branchCount; i++) { + storage.addThought(makeThought({ branchId: `rapid-${i}` })); + metrics.recordThoughtProcessed(makeThought({ branchId: `rapid-${i}` })); + } + + // Should count all branches + expect(metrics.getMetrics().thoughts.branchCount).toBe(branchCount); + expect(storage.getBranches()).toHaveLength(branchCount); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/circular-buffer.test.ts b/src/sequentialthinking/__tests__/unit/circular-buffer.test.ts new file mode 100644 index 0000000000..2d64a9848b --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/circular-buffer.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { CircularBuffer } from '../../circular-buffer.js'; + +describe('CircularBuffer', () => { + let buffer: CircularBuffer; + + beforeEach(() => { + buffer = new CircularBuffer(3); + }); + + describe('Basic Operations', () => { + it('should initialize with correct capacity', () => { + expect(buffer.currentSize).toBe(0); + expect(buffer.isFull).toBe(false); + }); + + it('should add items correctly', () => { + buffer.add('item1'); + expect(buffer.currentSize).toBe(1); + + buffer.add('item2'); + expect(buffer.currentSize).toBe(2); + + buffer.add('item3'); + expect(buffer.currentSize).toBe(3); + expect(buffer.isFull).toBe(true); + }); + + it('should overwrite old items when full', () => { + buffer.add('item1'); + buffer.add('item2'); + buffer.add('item3'); + buffer.add('item4'); // Should overwrite item1 + + expect(buffer.currentSize).toBe(3); + expect(buffer.isFull).toBe(true); + + const items = buffer.getAll(); + expect(items).toEqual(['item2', 'item3', 'item4']); + }); + }); + + describe('Retrieval Operations', () => { + beforeEach(() => { + buffer.add('first'); + buffer.add('second'); + buffer.add('third'); + }); + + it('should retrieve all items', () => { + const items = buffer.getAll(); + expect(items).toEqual(['first', 'second', 'third']); + }); + + it('should retrieve limited number of items', () => { + const items = buffer.getAll(2); + expect(items).toEqual(['second', 'third']); // Most recent 2 + }); + + it('should retrieve specific range', () => { + const items = buffer.getRange(1, 2); + expect(items).toEqual(['second', 'third']); + }); + + it('should get oldest item', () => { + const oldest = buffer.getOldest(); + expect(oldest).toBe('first'); + }); + + it('should get newest item', () => { + const newest = buffer.getNewest(); + expect(newest).toBe('third'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty buffer', () => { + expect(buffer.getAll()).toEqual([]); + expect(buffer.getOldest()).toBeUndefined(); + expect(buffer.getNewest()).toBeUndefined(); + }); + + it('should handle limit larger than size', () => { + buffer.add('item1'); + buffer.add('item2'); + + const items = buffer.getAll(10); + expect(items).toEqual(['item1', 'item2']); + }); + + it('should clear buffer correctly', () => { + buffer.add('item1'); + buffer.add('item2'); + + expect(buffer.currentSize).toBe(2); + + buffer.clear(); + + expect(buffer.currentSize).toBe(0); + expect(buffer.isFull).toBe(false); + expect(buffer.getAll()).toEqual([]); + }); + }); + + describe('Wrap-around Behavior', () => { + it('should handle multiple wrap-arounds correctly', () => { + const items = ['a', 'b', 'c', 'd', 'e', 'f', 'g']; + + items.forEach(item => buffer.add(item)); + + // Buffer size should be 3 (capacity) + expect(buffer.currentSize).toBe(3); + expect(buffer.isFull).toBe(true); + + // Should contain last 3 items + const result = buffer.getAll(); + expect(result).toEqual(['e', 'f', 'g']); + }); + + it('should maintain order after wrap-around', () => { + buffer.add('1'); + buffer.add('2'); + buffer.add('3'); + buffer.add('4'); + buffer.add('5'); + + const items = buffer.getAll(); + expect(items).toEqual(['3', '4', '5']); + }); + }); + + describe('Capacity Edge Cases', () => { + it('should handle capacity of 1', () => { + const buf = new CircularBuffer(1); + + buf.add('first'); + expect(buf.currentSize).toBe(1); + expect(buf.isFull).toBe(true); + expect(buf.getAll()).toEqual(['first']); + + buf.add('second'); + expect(buf.currentSize).toBe(1); + expect(buf.getAll()).toEqual(['second']); + expect(buf.getOldest()).toBe('second'); + expect(buf.getNewest()).toBe('second'); + }); + + it('should handle large capacity', () => { + const buf = new CircularBuffer(10000); + + for (let i = 0; i < 100; i++) { + buf.add(i); + } + + expect(buf.currentSize).toBe(100); + expect(buf.isFull).toBe(false); + expect(buf.getOldest()).toBe(0); + expect(buf.getNewest()).toBe(99); + }); + }); + + describe('Performance', () => { + it('should handle large number of operations efficiently', () => { + const start = Date.now(); + + // Add many items + for (let i = 0; i < 10000; i++) { + buffer.add(`item-${i}`); + } + + const duration = Date.now() - start; + + // Should be very fast + expect(duration).toBeLessThan(100); // Less than 100ms + expect(buffer.currentSize).toBe(3); // Still at capacity + }); + }); + + describe('getAll(0) returns empty', () => { + it('should return empty array when limit is 0', () => { + buffer.add('item1'); + buffer.add('item2'); + buffer.add('item3'); + expect(buffer.getAll(0)).toEqual([]); + }); + }); + + describe('Constructor validation', () => { + it('should throw on capacity 0', () => { + expect(() => new CircularBuffer(0)).toThrow('capacity must be a positive integer'); + }); + + it('should throw on negative capacity', () => { + expect(() => new CircularBuffer(-1)).toThrow('capacity must be a positive integer'); + }); + + it('should throw on non-integer capacity', () => { + expect(() => new CircularBuffer(1.5)).toThrow('capacity must be a positive integer'); + }); + }); +}); \ No newline at end of file diff --git a/src/sequentialthinking/__tests__/unit/config.test.ts b/src/sequentialthinking/__tests__/unit/config.test.ts new file mode 100644 index 0000000000..f073f4b554 --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/config.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ConfigManager } from '../../config.js'; + +describe('ConfigManager', () => { + const savedEnv: Record = {}; + + beforeEach(() => { + // Save env vars we'll modify + for (const key of [ + 'MAX_HISTORY_SIZE', 'MAX_THOUGHT_LENGTH', 'MAX_THOUGHTS_PER_MIN', + 'SERVER_NAME', 'SERVER_VERSION', 'BLOCKED_PATTERNS', + 'LOG_LEVEL', 'ENABLE_COLORS', 'ENABLE_METRICS', 'ENABLE_HEALTH_CHECKS', + 'MAX_BRANCH_AGE', 'MAX_THOUGHTS_PER_BRANCH', 'CLEANUP_INTERVAL', + 'DISABLE_THOUGHT_LOGGING', + 'HEALTH_MAX_MEMORY', 'HEALTH_MAX_STORAGE', 'HEALTH_MAX_RESPONSE_TIME', + 'HEALTH_ERROR_RATE_DEGRADED', 'HEALTH_ERROR_RATE_UNHEALTHY', + ]) { + savedEnv[key] = process.env[key]; + } + }); + + afterEach(() => { + // Restore env vars + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + describe('load()', () => { + it('should return default config when no env vars set', () => { + // Clear env vars + delete process.env.MAX_HISTORY_SIZE; + delete process.env.SERVER_NAME; + delete process.env.DISABLE_THOUGHT_LOGGING; + + const config = ConfigManager.load(); + + expect(config.server.name).toBe('sequential-thinking-server'); + expect(config.server.version).toBe('1.0.0'); + expect(config.state.maxHistorySize).toBe(1000); + expect(config.state.maxThoughtLength).toBe(5000); + expect(config.state.maxBranchAge).toBe(3600000); + expect(config.state.maxThoughtsPerBranch).toBe(100); + expect(config.state.cleanupInterval).toBe(300000); + expect(config.security.maxThoughtsPerMinute).toBe(60); + expect(config.logging.level).toBe('info'); + expect(config.logging.enableColors).toBe(true); + expect(config.logging.enableThoughtLogging).toBe(true); + expect(config.monitoring.enableMetrics).toBe(true); + expect(config.monitoring.enableHealthChecks).toBe(true); + expect(config.monitoring.healthThresholds.maxMemoryPercent).toBe(90); + expect(config.monitoring.healthThresholds.maxStoragePercent).toBe(80); + expect(config.monitoring.healthThresholds.maxResponseTimeMs).toBe(200); + expect(config.monitoring.healthThresholds.errorRateDegraded).toBe(2); + expect(config.monitoring.healthThresholds.errorRateUnhealthy).toBe(5); + }); + + it('should respect env var overrides', () => { + process.env.MAX_HISTORY_SIZE = '500'; + process.env.SERVER_NAME = 'custom-server'; + + const config = ConfigManager.load(); + + expect(config.state.maxHistorySize).toBe(500); + expect(config.server.name).toBe('custom-server'); + }); + + it('should use defaults for NaN env values', () => { + process.env.MAX_HISTORY_SIZE = 'not-a-number'; + + const config = ConfigManager.load(); + + expect(config.state.maxHistorySize).toBe(1000); + }); + + it('should use defaults for undefined env values', () => { + delete process.env.MAX_HISTORY_SIZE; + + const config = ConfigManager.load(); + + expect(config.state.maxHistorySize).toBe(1000); + }); + }); + + describe('enableThoughtLogging', () => { + it('should default to true when DISABLE_THOUGHT_LOGGING is not set', () => { + delete process.env.DISABLE_THOUGHT_LOGGING; + const config = ConfigManager.load(); + expect(config.logging.enableThoughtLogging).toBe(true); + }); + + it('should be false when DISABLE_THOUGHT_LOGGING is true', () => { + process.env.DISABLE_THOUGHT_LOGGING = 'true'; + const config = ConfigManager.load(); + expect(config.logging.enableThoughtLogging).toBe(false); + }); + + it('should remain true for non-true values of DISABLE_THOUGHT_LOGGING', () => { + process.env.DISABLE_THOUGHT_LOGGING = 'false'; + const config = ConfigManager.load(); + expect(config.logging.enableThoughtLogging).toBe(true); + }); + }); + + describe('health threshold env vars', () => { + it('should load custom health thresholds from env', () => { + process.env.HEALTH_MAX_MEMORY = '70'; + process.env.HEALTH_MAX_STORAGE = '60'; + process.env.HEALTH_MAX_RESPONSE_TIME = '100'; + process.env.HEALTH_ERROR_RATE_DEGRADED = '1'; + process.env.HEALTH_ERROR_RATE_UNHEALTHY = '3'; + + const config = ConfigManager.load(); + + expect(config.monitoring.healthThresholds.maxMemoryPercent).toBe(70); + expect(config.monitoring.healthThresholds.maxStoragePercent).toBe(60); + expect(config.monitoring.healthThresholds.maxResponseTimeMs).toBe(100); + expect(config.monitoring.healthThresholds.errorRateDegraded).toBe(1); + expect(config.monitoring.healthThresholds.errorRateUnhealthy).toBe(3); + }); + }); + + describe('validate()', () => { + it('should accept valid config', () => { + const config = ConfigManager.load(); + expect(() => ConfigManager.validate(config)).not.toThrow(); + }); + + it('should reject maxHistorySize = 0', () => { + const config = ConfigManager.load(); + config.state.maxHistorySize = 0; + expect(() => ConfigManager.validate(config)).toThrow('MAX_HISTORY_SIZE must be between 1 and 10000'); + }); + + it('should reject maxHistorySize = 10001', () => { + const config = ConfigManager.load(); + config.state.maxHistorySize = 10001; + expect(() => ConfigManager.validate(config)).toThrow('MAX_HISTORY_SIZE must be between 1 and 10000'); + }); + + it('should reject maxThoughtLength = -1', () => { + const config = ConfigManager.load(); + config.state.maxThoughtLength = -1; + expect(() => ConfigManager.validate(config)).toThrow('maxThoughtLength must be between 1 and 100000'); + }); + + it('should reject maxThoughtLength = 100001', () => { + const config = ConfigManager.load(); + config.state.maxThoughtLength = 100001; + expect(() => ConfigManager.validate(config)).toThrow('maxThoughtLength must be between 1 and 100000'); + }); + + it('should accept maxThoughtLength = 1', () => { + const config = ConfigManager.load(); + config.state.maxThoughtLength = 1; + expect(() => ConfigManager.validate(config)).not.toThrow(); + }); + + it('should accept maxThoughtLength = 100000', () => { + const config = ConfigManager.load(); + config.state.maxThoughtLength = 100000; + expect(() => ConfigManager.validate(config)).not.toThrow(); + }); + + it('should reject maxThoughtsPerMinute out of range', () => { + const config = ConfigManager.load(); + config.security.maxThoughtsPerMinute = 0; + expect(() => ConfigManager.validate(config)).toThrow('maxThoughtsPerMinute must be between 1 and 1000'); + }); + + it('should reject negative maxBranchAge', () => { + const config = ConfigManager.load(); + config.state.maxBranchAge = -1; + expect(() => ConfigManager.validate(config)).toThrow('maxBranchAge must be >= 0'); + }); + + it('should reject maxThoughtsPerBranch out of range', () => { + const config = ConfigManager.load(); + config.state.maxThoughtsPerBranch = 0; + expect(() => ConfigManager.validate(config)).toThrow('maxThoughtsPerBranch must be between 1 and 10000'); + }); + + it('should reject maxThoughtsPerBranch exceeding 10000', () => { + const config = ConfigManager.load(); + config.state.maxThoughtsPerBranch = 10001; + expect(() => ConfigManager.validate(config)).toThrow('maxThoughtsPerBranch must be between 1 and 10000'); + }); + + it('should reject negative cleanupInterval', () => { + const config = ConfigManager.load(); + config.state.cleanupInterval = -1; + expect(() => ConfigManager.validate(config)).toThrow('cleanupInterval must be >= 0'); + }); + }); + + describe('getEnvironmentInfo()', () => { + it('should return correct shape', () => { + const info = ConfigManager.getEnvironmentInfo(); + + expect(typeof info.nodeVersion).toBe('string'); + expect(typeof info.platform).toBe('string'); + expect(typeof info.arch).toBe('string'); + expect(typeof info.pid).toBe('number'); + expect(info.memoryUsage).toHaveProperty('heapUsed'); + expect(typeof info.uptime).toBe('number'); + }); + }); + + describe('loadBlockedPatterns()', () => { + it('should load defaults when BLOCKED_PATTERNS is not set', () => { + delete process.env.BLOCKED_PATTERNS; + + const config = ConfigManager.load(); + + expect(config.security.blockedPatterns.length).toBeGreaterThan(0); + expect(config.security.blockedPatterns[0]).toBeInstanceOf(RegExp); + }); + + it('should parse BLOCKED_PATTERNS env var', () => { + process.env.BLOCKED_PATTERNS = 'foo,bar'; + + const config = ConfigManager.load(); + + expect(config.security.blockedPatterns).toHaveLength(2); + expect(config.security.blockedPatterns[0].test('foo')).toBe(true); + }); + + it('should fall back to defaults on invalid regex', () => { + process.env.BLOCKED_PATTERNS = '(invalid['; + + const config = ConfigManager.load(); + + // Should fall back to defaults + expect(config.security.blockedPatterns.length).toBeGreaterThan(0); + }); + }); + + describe('LOG_LEVEL validation', () => { + it('should fall back to info for invalid LOG_LEVEL', () => { + process.env.LOG_LEVEL = 'verbose'; + const config = ConfigManager.load(); + expect(config.logging.level).toBe('info'); + }); + + it('should accept valid LOG_LEVEL values', () => { + for (const level of ['debug', 'info', 'warn', 'error']) { + process.env.LOG_LEVEL = level; + const config = ConfigManager.load(); + expect(config.logging.level).toBe(level); + } + }); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/container.test.ts b/src/sequentialthinking/__tests__/unit/container.test.ts new file mode 100644 index 0000000000..467baf967d --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/container.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { SimpleContainer, SequentialThinkingApp } from '../../container.js'; + +describe('SimpleContainer', () => { + it('should register and retrieve a service', () => { + const container = new SimpleContainer(); + container.register('greeting', () => 'hello'); + expect(container.get('greeting')).toBe('hello'); + }); + + it('should return cached instance on second get', () => { + const container = new SimpleContainer(); + let callCount = 0; + container.register('counter', () => ++callCount); + expect(container.get('counter')).toBe(1); + expect(container.get('counter')).toBe(1); // Same instance + }); + + it('should throw for unregistered service', () => { + const container = new SimpleContainer(); + expect(() => container.get('nonexistent')).toThrow("Service 'nonexistent' not registered"); + }); + + it('should call destroy on services that have it', () => { + const container = new SimpleContainer(); + const destroyFn = vi.fn(); + container.register('svc', () => ({ destroy: destroyFn })); + container.get('svc'); // Instantiate + container.destroy(); + expect(destroyFn).toHaveBeenCalledTimes(1); + }); + + it('should handle destroy throwing without crashing', () => { + const container = new SimpleContainer(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + container.register('bad', () => ({ + destroy: () => { throw new Error('boom'); }, + })); + container.get('bad'); + expect(() => container.destroy()).not.toThrow(); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('should clear cached instance on re-register', () => { + const container = new SimpleContainer(); + container.register('svc', () => 'v1'); + expect(container.get('svc')).toBe('v1'); + container.register('svc', () => 'v2'); + expect(container.get('svc')).toBe('v2'); + }); + + it('should not call factory until first get (lazy instantiation)', () => { + const container = new SimpleContainer(); + const factory = vi.fn(() => 'lazy-value'); + container.register('lazy', factory); + expect(factory).not.toHaveBeenCalled(); + const value = container.get('lazy'); + expect(factory).toHaveBeenCalledTimes(1); + expect(value).toBe('lazy-value'); + }); + + describe('double-destroy safety', () => { + it('should not throw on double destroy', () => { + const container = new SimpleContainer(); + const destroyFn = vi.fn(); + container.register('svc', () => ({ destroy: destroyFn })); + container.get('svc'); // Instantiate + + container.destroy(); + container.destroy(); // Second call should be no-op + + expect(destroyFn).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('SequentialThinkingApp', () => { + let app: SequentialThinkingApp; + + afterEach(() => { + app?.destroy(); + }); + + it('should create app with default config', () => { + app = new SequentialThinkingApp(); + expect(app.getContainer()).toBeDefined(); + }); + + it('should resolve registered services', () => { + app = new SequentialThinkingApp(); + const container = app.getContainer(); + expect(() => container.get('config')).not.toThrow(); + expect(() => container.get('logger')).not.toThrow(); + expect(() => container.get('formatter')).not.toThrow(); + expect(() => container.get('storage')).not.toThrow(); + expect(() => container.get('security')).not.toThrow(); + expect(() => container.get('metrics')).not.toThrow(); + expect(() => container.get('healthChecker')).not.toThrow(); + }); + + it('should destroy without errors', () => { + app = new SequentialThinkingApp(); + // Force instantiation + app.getContainer().get('storage'); + expect(() => app.destroy()).not.toThrow(); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/error-handler.test.ts b/src/sequentialthinking/__tests__/unit/error-handler.test.ts new file mode 100644 index 0000000000..81f18744a5 --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/error-handler.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { CompositeErrorHandler } from '../../error-handlers.js'; +import { ValidationError, SecurityError } from '../../errors.js'; + +describe('CompositeErrorHandler', () => { + const handler = new CompositeErrorHandler(); + + it('should format SequentialThinkingError with correct fields', () => { + const error = new ValidationError('Bad input', { field: 'thought' }); + const result = handler.handle(error); + + expect(result.isError).toBe(true); + expect(result.statusCode).toBe(400); + + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('VALIDATION_ERROR'); + expect(data.message).toBe('Bad input'); + expect(data.category).toBe('VALIDATION'); + expect(data.statusCode).toBe(400); + expect(data.details).toEqual({ field: 'thought' }); + expect(data.timestamp).toBeDefined(); + }); + + it('should format SecurityError with correct status code', () => { + const error = new SecurityError('Forbidden'); + const result = handler.handle(error); + + expect(result.statusCode).toBe(403); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('SECURITY_ERROR'); + expect(data.category).toBe('SECURITY'); + }); + + it('should handle non-SequentialThinkingError as INTERNAL_ERROR', () => { + const error = new Error('Something unexpected'); + const result = handler.handle(error); + + expect(result.isError).toBe(true); + expect(result.statusCode).toBe(500); + + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('INTERNAL_ERROR'); + expect(data.message).toBe('An unexpected error occurred'); + expect(data.category).toBe('SYSTEM'); + expect(data.statusCode).toBe(500); + expect(data.timestamp).toBeDefined(); + }); + + it('should handle TypeError as INTERNAL_ERROR', () => { + const error = new TypeError('Cannot read property of undefined'); + const result = handler.handle(error); + + expect(result.statusCode).toBe(500); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('INTERNAL_ERROR'); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/formatter.test.ts b/src/sequentialthinking/__tests__/unit/formatter.test.ts new file mode 100644 index 0000000000..107b12c14d --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/formatter.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { ConsoleThoughtFormatter } from '../../formatter.js'; +import { createTestThought as makeThought } from '../helpers/factories.js'; + +describe('ConsoleThoughtFormatter', () => { + describe('formatHeader (non-color mode)', () => { + const formatter = new ConsoleThoughtFormatter(false); + + it('should produce plain [Thought] prefix for regular thought', () => { + const header = formatter.formatHeader(makeThought()); + expect(header).toBe('[Thought] 1/3'); + }); + + it('should produce [Revision] prefix for revision', () => { + const header = formatter.formatHeader( + makeThought({ isRevision: true, revisesThought: 1, thoughtNumber: 2 }), + ); + expect(header).toBe('[Revision] 2/3 (revising thought 1)'); + }); + + it('should produce [Branch] prefix for branch', () => { + const header = formatter.formatHeader( + makeThought({ branchFromThought: 1, branchId: 'b1', thoughtNumber: 2 }), + ); + expect(header).toBe('[Branch] 2/3 (from thought 1, ID: b1)'); + }); + + it('should not contain emoji in non-color mode', () => { + const header = formatter.formatHeader(makeThought()); + expect(header).not.toMatch(/[\u{1F300}-\u{1FAD6}]/u); + }); + }); + + describe('formatHeader (color mode)', () => { + const formatter = new ConsoleThoughtFormatter(true); + + it('should contain [Thought] text for regular thought', () => { + const header = formatter.formatHeader(makeThought()); + // chalk is mocked as identity, so output is same as plain + expect(header).toContain('[Thought]'); + expect(header).toContain('1/3'); + }); + + it('should contain [Revision] text for revision', () => { + const header = formatter.formatHeader( + makeThought({ isRevision: true, revisesThought: 1, thoughtNumber: 2 }), + ); + expect(header).toContain('[Revision]'); + }); + }); + + describe('format (non-color mode)', () => { + const formatter = new ConsoleThoughtFormatter(false); + + it('should produce box-drawing border', () => { + const output = formatter.format(makeThought()); + expect(output).toContain('┌'); + expect(output).toContain('┘'); + expect(output).toContain('─'); + }); + + it('should contain header and body', () => { + const output = formatter.format(makeThought({ thought: 'My analysis' })); + expect(output).toContain('[Thought] 1/3'); + expect(output).toContain('My analysis'); + }); + + it('should have border width matching content', () => { + const thought = makeThought({ thought: 'Short' }); + const output = formatter.format(thought); + const lines = output.split('\n'); + // All border lines should have the same length + const borderLines = lines.filter(l => l.startsWith('┌') || l.startsWith('└') || l.startsWith('├')); + const lengths = borderLines.map(l => l.length); + expect(new Set(lengths).size).toBe(1); + }); + }); + + describe('formatBody', () => { + const formatter = new ConsoleThoughtFormatter(false); + + it('should return thought text as-is', () => { + const body = formatter.formatBody(makeThought({ thought: 'hello world' })); + expect(body).toBe('hello world'); + }); + }); + + describe('multiline body', () => { + const formatter = new ConsoleThoughtFormatter(false); + + it('should not throw on multiline thought body', () => { + const output = formatter.format(makeThought({ thought: 'Line one\nLine two' })); + expect(output).toContain('Line one'); + expect(output).toContain('Line two'); + }); + }); + + describe('undefined optional fields', () => { + const formatter = new ConsoleThoughtFormatter(false); + + it('should show fallback for undefined revisesThought', () => { + const header = formatter.formatHeader( + makeThought({ isRevision: true, revisesThought: undefined }), + ); + expect(header).toContain('?'); + expect(header).not.toContain('undefined'); + }); + + it('should show fallback for undefined branchId', () => { + const header = formatter.formatHeader( + makeThought({ branchFromThought: 1, branchId: undefined }), + ); + expect(header).toContain('unknown'); + expect(header).not.toContain('undefined'); + }); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/health-checker.test.ts b/src/sequentialthinking/__tests__/unit/health-checker.test.ts new file mode 100644 index 0000000000..3d5c9e4b69 --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/health-checker.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect } from 'vitest'; +import { ComprehensiveHealthChecker } from '../../health-checker.js'; +import type { MetricsCollector, ThoughtStorage, SecurityService, StorageStats, RequestMetrics, ThoughtMetrics, SystemMetrics } from '../../interfaces.js'; + +function makeMockMetrics(overrides?: Partial): MetricsCollector { + return { + recordRequest: () => {}, + recordError: () => {}, + recordThoughtProcessed: () => {}, + destroy: () => {}, + getMetrics: () => ({ + requests: { + totalRequests: 10, + successfulRequests: 10, + failedRequests: 0, + averageResponseTime: 50, + lastRequestTime: new Date(), + requestsPerMinute: 5, + ...overrides, + }, + thoughts: { + totalThoughts: 0, + averageThoughtLength: 0, + thoughtsPerMinute: 0, + revisionCount: 0, + branchCount: 0, + activeSessions: 0, + }, + system: { + memoryUsage: process.memoryUsage(), + cpuUsage: process.cpuUsage(), + uptime: process.uptime(), + timestamp: new Date(), + }, + }), + }; +} + +function makeMockStorage(overrides?: Partial): ThoughtStorage { + return { + addThought: () => {}, + getHistory: () => [], + getBranches: () => [], + destroy: () => {}, + getStats: () => ({ + historySize: 10, + historyCapacity: 100, + branchCount: 0, + sessionCount: 0, + ...overrides, + }), + }; +} + +function makeMockSecurity(): SecurityService { + return { + validateThought: () => {}, + sanitizeContent: (c: string) => c, + getSecurityStatus: () => ({ status: 'healthy', activeSessions: 0, ipConnections: 0, blockedPatterns: 5 }), + generateSessionId: () => 'test-id', + validateSession: () => true, + }; +} + +describe('ComprehensiveHealthChecker', () => { + it('should return healthy when all checks pass', async () => { + const checker = new ComprehensiveHealthChecker( + makeMockMetrics(), + makeMockStorage(), + makeMockSecurity(), + ); + const health = await checker.checkHealth(); + expect(health.status).toBe('healthy'); + expect(health.checks.memory.status).toBe('healthy'); + expect(health.checks.storage.status).toBe('healthy'); + expect(health.checks.security.status).toBe('healthy'); + }); + + it('should return degraded on elevated storage usage (>64% of capacity)', async () => { + const checker = new ComprehensiveHealthChecker( + makeMockMetrics(), + makeMockStorage({ historySize: 70, historyCapacity: 100 }), + makeMockSecurity(), + ); + const health = await checker.checkHealth(); + expect(health.checks.storage.status).toBe('degraded'); + }); + + it('should handle division-by-zero guard (capacity = 0)', async () => { + const checker = new ComprehensiveHealthChecker( + makeMockMetrics(), + makeMockStorage({ historySize: 0, historyCapacity: 0 }), + makeMockSecurity(), + ); + const health = await checker.checkHealth(); + // Should not produce NaN/Infinity — should be healthy with 0% + expect(health.checks.storage.status).toBe('healthy'); + expect(health.checks.storage.message).toContain('0'); + }); + + it('should use fallback on rejected check', async () => { + const brokenSecurity: SecurityService = { + validateThought: () => {}, + sanitizeContent: (c: string) => c, + getSecurityStatus: () => { throw new Error('boom'); }, + generateSessionId: () => 'x', + validateSession: () => true, + }; + + const checker = new ComprehensiveHealthChecker( + makeMockMetrics(), + makeMockStorage(), + brokenSecurity, + ); + const health = await checker.checkHealth(); + // Security check should be unhealthy but others should be fine + expect(health.checks.security.status).toBe('unhealthy'); + expect(health.checks.memory.status).toBe('healthy'); + }); + + it('should return degraded on elevated response time (>80% of max)', async () => { + const checker = new ComprehensiveHealthChecker( + makeMockMetrics({ averageResponseTime: 170 }), + makeMockStorage(), + makeMockSecurity(), + ); + const health = await checker.checkHealth(); + expect(health.checks.responseTime.status).toBe('degraded'); + }); + + it('should include all 5 check fields', async () => { + const checker = new ComprehensiveHealthChecker( + makeMockMetrics(), + makeMockStorage(), + makeMockSecurity(), + ); + const health = await checker.checkHealth(); + expect(health.checks).toHaveProperty('memory'); + expect(health.checks).toHaveProperty('responseTime'); + expect(health.checks).toHaveProperty('errorRate'); + expect(health.checks).toHaveProperty('storage'); + expect(health.checks).toHaveProperty('security'); + }); + + it('should include summary, uptime, and timestamp', async () => { + const checker = new ComprehensiveHealthChecker( + makeMockMetrics(), + makeMockStorage(), + makeMockSecurity(), + ); + const health = await checker.checkHealth(); + expect(typeof health.summary).toBe('string'); + expect(typeof health.uptime).toBe('number'); + expect(health.timestamp).toBeInstanceOf(Date); + }); + + it('should return degraded on elevated error rate (>2%)', async () => { + const checker = new ComprehensiveHealthChecker( + makeMockMetrics({ totalRequests: 100, failedRequests: 3, successfulRequests: 97 }), + makeMockStorage(), + makeMockSecurity(), + ); + const health = await checker.checkHealth(); + expect(health.checks.errorRate.status).toBe('degraded'); + }); + + it('should return unhealthy on high error rate (>5%)', async () => { + const checker = new ComprehensiveHealthChecker( + makeMockMetrics({ totalRequests: 100, failedRequests: 6, successfulRequests: 94 }), + makeMockStorage(), + makeMockSecurity(), + ); + const health = await checker.checkHealth(); + expect(health.checks.errorRate.status).toBe('unhealthy'); + }); + + it('should return unhealthy on response time exceeding max', async () => { + const checker = new ComprehensiveHealthChecker( + makeMockMetrics({ averageResponseTime: 250 }), + makeMockStorage(), + makeMockSecurity(), + ); + const health = await checker.checkHealth(); + expect(health.checks.responseTime.status).toBe('unhealthy'); + }); + + it('should return unhealthy on storage usage exceeding max', async () => { + const checker = new ComprehensiveHealthChecker( + makeMockMetrics(), + makeMockStorage({ historySize: 90, historyCapacity: 100 }), + makeMockSecurity(), + ); + const health = await checker.checkHealth(); + expect(health.checks.storage.status).toBe('unhealthy'); + }); + + describe('custom thresholds', () => { + it('should use custom maxStoragePercent threshold', async () => { + const checker = new ComprehensiveHealthChecker( + makeMockMetrics(), + makeMockStorage({ historySize: 55, historyCapacity: 100 }), + makeMockSecurity(), + { maxMemoryPercent: 90, maxStoragePercent: 50, maxResponseTimeMs: 200, errorRateDegraded: 2, errorRateUnhealthy: 5 }, + ); + const health = await checker.checkHealth(); + // 55% > 50% maxStoragePercent → unhealthy + expect(health.checks.storage.status).toBe('unhealthy'); + }); + + it('should use custom maxResponseTimeMs threshold', async () => { + const checker = new ComprehensiveHealthChecker( + makeMockMetrics({ averageResponseTime: 60 }), + makeMockStorage(), + makeMockSecurity(), + { maxMemoryPercent: 90, maxStoragePercent: 80, maxResponseTimeMs: 50, errorRateDegraded: 2, errorRateUnhealthy: 5 }, + ); + const health = await checker.checkHealth(); + // 60 > 50 → unhealthy + expect(health.checks.responseTime.status).toBe('unhealthy'); + }); + + it('should use custom error rate thresholds', async () => { + const checker = new ComprehensiveHealthChecker( + makeMockMetrics({ totalRequests: 100, failedRequests: 2, successfulRequests: 98 }), + makeMockStorage(), + makeMockSecurity(), + { maxMemoryPercent: 90, maxStoragePercent: 80, maxResponseTimeMs: 200, errorRateDegraded: 1, errorRateUnhealthy: 3 }, + ); + const health = await checker.checkHealth(); + // 2% > 1% degraded threshold → degraded + expect(health.checks.errorRate.status).toBe('degraded'); + }); + }); + + describe('error rate clamping', () => { + it('should clamp error rate to 100% when failedRequests > totalRequests', async () => { + const checker = new ComprehensiveHealthChecker( + makeMockMetrics({ totalRequests: 100, failedRequests: 200, successfulRequests: 0 }), + makeMockStorage(), + makeMockSecurity(), + ); + const health = await checker.checkHealth(); + expect(health.checks.errorRate.status).toBe('unhealthy'); + // Error rate should be clamped to 100, not 200 + const details = health.checks.errorRate.details as { errorRate: number }; + expect(details.errorRate).toBe(100); + }); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/logger.test.ts b/src/sequentialthinking/__tests__/unit/logger.test.ts new file mode 100644 index 0000000000..21fc64d40c --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/logger.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { StructuredLogger } from '../../logger.js'; + +describe('StructuredLogger', () => { + let errorSpy: ReturnType; + + beforeEach(() => { + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + describe('log level filtering', () => { + it('should suppress debug messages at info level', () => { + const logger = new StructuredLogger({ level: 'info', enableColors: false, enableThoughtLogging: true }); + logger.debug('should not appear'); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('should output info messages at info level', () => { + const logger = new StructuredLogger({ level: 'info', enableColors: false, enableThoughtLogging: true }); + logger.info('visible'); + expect(errorSpy).toHaveBeenCalledTimes(1); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(entry.level).toBe('info'); + expect(entry.message).toBe('visible'); + }); + + it('should output debug messages at debug level', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + logger.debug('debug msg'); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + + it('should suppress info messages at warn level', () => { + const logger = new StructuredLogger({ level: 'warn', enableColors: false, enableThoughtLogging: true }); + logger.info('should not appear'); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('should output error messages at error level', () => { + const logger = new StructuredLogger({ level: 'error', enableColors: false, enableThoughtLogging: true }); + logger.error('err'); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('sensitive field redaction', () => { + it('should redact password fields', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + logger.info('test', { password: 'secret123' }); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(entry.meta.password).toBe('[REDACTED]'); + }); + + it('should redact nested sensitive fields', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + logger.info('test', { user: { token: 'abc', name: 'Alice' } }); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(entry.meta.user.token).toBe('[REDACTED]'); + expect(entry.meta.user.name).toBe('Alice'); + }); + + it('should redact auth-related fields', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + logger.info('test', { authorization: 'Bearer xyz' }); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(entry.meta.authorization).toBe('[REDACTED]'); + }); + }); + + describe('word-boundary-aware sensitive field matching', () => { + it('should redact authorization', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + logger.info('test', { authorization: 'Bearer xyz' }); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(entry.meta.authorization).toBe('[REDACTED]'); + }); + + it('should redact password', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + logger.info('test', { password: 'secret' }); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(entry.meta.password).toBe('[REDACTED]'); + }); + + it('should NOT redact authoritativeSource', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + logger.info('test', { authoritativeSource: 'docs.example.com' }); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(entry.meta.authoritativeSource).toBe('docs.example.com'); + }); + + it('should redact mySecretKey (camelCase boundary)', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + logger.info('test', { mySecretKey: 'value' }); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(entry.meta.mySecretKey).toBe('[REDACTED]'); + }); + + it('should redact api_key (underscore boundary)', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + logger.info('test', { api_key: 'abc123' }); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(entry.meta.api_key).toBe('[REDACTED]'); + }); + + it('should NOT redact keyboard', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + logger.info('test', { keyboard: 'mechanical' }); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(entry.meta.keyboard).toBe('mechanical'); + }); + + it('should NOT redact monkey', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + logger.info('test', { monkey: 'see monkey do' }); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(entry.meta.monkey).toBe('see monkey do'); + }); + }); + + describe('depth limit on sanitize', () => { + it('should return [Object] for deeply nested objects', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + + // Build an object nested 15 levels deep + let deep: Record = { value: 'leaf' }; + for (let i = 0; i < 15; i++) { + deep = { nested: deep }; + } + + logger.info('test', deep as Record); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + + // Walk down until we find '[Object]' + let current: unknown = entry.meta; + let depth = 0; + while (typeof current === 'object' && current !== null && depth < 20) { + current = (current as Record).nested; + depth++; + } + expect(current).toBe('[Object]'); + expect(depth).toBeLessThan(15); + }); + }); + + describe('circular reference handling', () => { + it('should handle circular references without crashing', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + const obj: Record = { name: 'root' }; + obj.self = obj; + + logger.info('test', obj); + expect(errorSpy).toHaveBeenCalledTimes(1); + const output = errorSpy.mock.calls[0][0] as string; + expect(output).toContain('[Circular]'); + }); + + it('should handle nested circular references', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + const a: Record = { name: 'a' }; + const b: Record = { name: 'b', ref: a }; + a.ref = b; + + logger.info('test', a); + expect(errorSpy).toHaveBeenCalledTimes(1); + const output = errorSpy.mock.calls[0][0] as string; + expect(output).toContain('[Circular]'); + }); + }); + + describe('logThought', () => { + it('should produce debug entry with thought metadata', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + logger.logThought('session-1', { + thought: 'test thought', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + }); + expect(errorSpy).toHaveBeenCalledTimes(1); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(entry.level).toBe('debug'); + expect(entry.message).toBe('Thought processed'); + expect(entry.meta.sessionId).toBe('session-1'); + expect(entry.meta.thoughtNumber).toBe(1); + }); + + it('should not log thought at info level', () => { + const logger = new StructuredLogger({ level: 'info', enableColors: false, enableThoughtLogging: true }); + logger.logThought('session-1', { + thought: 'test', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }); + expect(errorSpy).not.toHaveBeenCalled(); + }); + }); + + describe('error logging', () => { + it('should log Error instances with stack info', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + logger.error('fail', new Error('boom')); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(entry.meta.error.name).toBe('Error'); + expect(entry.meta.error.message).toBe('boom'); + expect(entry.meta.error.stack).toBeDefined(); + }); + + it('should log non-Error values as meta', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + logger.error('fail', 'string error'); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(entry.meta.error).toBe('string error'); + }); + + it('should log error without error argument', () => { + const logger = new StructuredLogger({ level: 'debug', enableColors: false, enableThoughtLogging: true }); + logger.error('something went wrong'); + expect(errorSpy).toHaveBeenCalledTimes(1); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(entry.level).toBe('error'); + expect(entry.message).toBe('something went wrong'); + expect(entry.meta).toBeUndefined(); + }); + }); + + describe('warn logging', () => { + it('should output warn messages at warn level', () => { + const logger = new StructuredLogger({ level: 'warn', enableColors: false, enableThoughtLogging: true }); + logger.warn('caution'); + expect(errorSpy).toHaveBeenCalledTimes(1); + const entry = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(entry.level).toBe('warn'); + expect(entry.message).toBe('caution'); + }); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/mcts.test.ts b/src/sequentialthinking/__tests__/unit/mcts.test.ts new file mode 100644 index 0000000000..56cb0ed1a4 --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/mcts.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect } from 'vitest'; +import { MCTSEngine } from '../../mcts.js'; +import { ThoughtTree } from '../../thought-tree.js'; +import type { ThoughtData } from '../../circular-buffer.js'; + +function makeThought(overrides: Partial = {}): ThoughtData { + return { + thought: 'Test thought', + thoughtNumber: 1, + totalThoughts: 5, + nextThoughtNeeded: true, + sessionId: 'test-session', + ...overrides, + }; +} + +describe('MCTSEngine', () => { + const engine = new MCTSEngine(); + + describe('computeUCB1', () => { + it('should return Infinity for unvisited nodes', () => { + const result = engine.computeUCB1(0, 0, 10, Math.SQRT2); + expect(result).toBe(Infinity); + }); + + it('should compute exploitation + exploration', () => { + // nodeVisits=4, nodeValue=2.0, parentVisits=10, C=sqrt(2) + const result = engine.computeUCB1(4, 2.0, 10, Math.SQRT2); + const exploitation = 2.0 / 4; // 0.5 + const exploration = Math.SQRT2 * Math.sqrt(Math.log(10) / 4); + expect(result).toBeCloseTo(exploitation + exploration, 10); + }); + + it('should increase with higher exploitation value', () => { + const low = engine.computeUCB1(4, 1.0, 10, Math.SQRT2); + const high = engine.computeUCB1(4, 3.0, 10, Math.SQRT2); + expect(high).toBeGreaterThan(low); + }); + + it('should increase with lower visit count (more exploration bonus)', () => { + const moreVisits = engine.computeUCB1(10, 5.0, 20, Math.SQRT2); + const fewerVisits = engine.computeUCB1(2, 1.0, 20, Math.SQRT2); + expect(fewerVisits).toBeGreaterThan(moreVisits); + }); + }); + + describe('backpropagate', () => { + it('should update visit count and value along path to root', () => { + const tree = new ThoughtTree('session-1', 500); + const root = tree.addThought(makeThought({ thoughtNumber: 1 })); + const child = tree.addThought(makeThought({ thoughtNumber: 2 })); + const grandchild = tree.addThought(makeThought({ thoughtNumber: 3 })); + + const updated = engine.backpropagate(tree, grandchild.nodeId, 0.8); + + expect(updated).toBe(3); + expect(grandchild.visitCount).toBe(1); + expect(grandchild.totalValue).toBeCloseTo(0.8); + expect(child.visitCount).toBe(1); + expect(child.totalValue).toBeCloseTo(0.8); + expect(root.visitCount).toBe(1); + expect(root.totalValue).toBeCloseTo(0.8); + }); + + it('should accumulate with multiple evaluations', () => { + const tree = new ThoughtTree('session-1', 500); + const root = tree.addThought(makeThought({ thoughtNumber: 1 })); + const child = tree.addThought(makeThought({ thoughtNumber: 2 })); + + engine.backpropagate(tree, child.nodeId, 0.6); + engine.backpropagate(tree, child.nodeId, 0.9); + + expect(child.visitCount).toBe(2); + expect(child.totalValue).toBeCloseTo(1.5); + expect(root.visitCount).toBe(2); + expect(root.totalValue).toBeCloseTo(1.5); + }); + + it('should handle root node evaluation', () => { + const tree = new ThoughtTree('session-1', 500); + const root = tree.addThought(makeThought({ thoughtNumber: 1 })); + + const updated = engine.backpropagate(tree, root.nodeId, 0.5); + expect(updated).toBe(1); + expect(root.visitCount).toBe(1); + expect(root.totalValue).toBeCloseTo(0.5); + }); + }); + + describe('suggestNext', () => { + it('should suggest unexplored nodes first', () => { + const tree = new ThoughtTree('session-1', 500); + const root = tree.addThought(makeThought({ thoughtNumber: 1 })); + tree.addThought(makeThought({ thoughtNumber: 2 })); + + // Evaluate root but not child + engine.backpropagate(tree, root.nodeId, 0.5); + + const result = engine.suggestNext(tree, 'balanced'); + expect(result.suggestion).not.toBeNull(); + // The unvisited node (child, thought 2) should have Infinity UCB1 + expect(result.suggestion!.ucb1Score).toBe(Infinity); + }); + + it('should return null suggestion when all nodes are terminal', () => { + const tree = new ThoughtTree('session-1', 500); + tree.addThought(makeThought({ thoughtNumber: 1, nextThoughtNeeded: false })); + + const result = engine.suggestNext(tree, 'balanced'); + expect(result.suggestion).toBeNull(); + expect(result.alternatives).toHaveLength(0); + }); + + it('should return alternatives', () => { + const tree = new ThoughtTree('session-1', 500); + const root = tree.addThought(makeThought({ thoughtNumber: 1 })); + + tree.setCursor(root.nodeId); + tree.addThought(makeThought({ thoughtNumber: 2 })); + + tree.setCursor(root.nodeId); + tree.addThought(makeThought({ thoughtNumber: 3 })); + + const result = engine.suggestNext(tree, 'balanced'); + expect(result.suggestion).not.toBeNull(); + expect(result.alternatives.length).toBeGreaterThan(0); + }); + + it('should respond to different strategies', () => { + const tree = new ThoughtTree('session-1', 500); + const root = tree.addThought(makeThought({ thoughtNumber: 1 })); + tree.addThought(makeThought({ thoughtNumber: 2 })); + tree.setCursor(root.nodeId); + tree.addThought(makeThought({ thoughtNumber: 3 })); + + // Evaluate to create some variation + engine.backpropagate(tree, root.nodeId, 0.5); + + // All strategies should work without error + const explore = engine.suggestNext(tree, 'explore'); + const exploit = engine.suggestNext(tree, 'exploit'); + const balanced = engine.suggestNext(tree, 'balanced'); + + expect(explore.suggestion).not.toBeNull(); + expect(exploit.suggestion).not.toBeNull(); + expect(balanced.suggestion).not.toBeNull(); + }); + }); + + describe('extractBestPath', () => { + it('should extract path following highest average value', () => { + const tree = new ThoughtTree('session-1', 500); + const root = tree.addThought(makeThought({ thoughtNumber: 1 })); + const goodChild = tree.addThought(makeThought({ thoughtNumber: 2 })); + + tree.setCursor(root.nodeId); + const badChild = tree.addThought(makeThought({ thoughtNumber: 3 })); + + // Make goodChild better + engine.backpropagate(tree, goodChild.nodeId, 0.9); + engine.backpropagate(tree, badChild.nodeId, 0.1); + + const path = engine.extractBestPath(tree); + expect(path).toHaveLength(2); + expect(path[0].nodeId).toBe(root.nodeId); + expect(path[1].nodeId).toBe(goodChild.nodeId); + }); + + it('should return empty for empty tree', () => { + const tree = new ThoughtTree('session-1', 500); + const path = engine.extractBestPath(tree); + expect(path).toHaveLength(0); + }); + + it('should return single node for root-only tree', () => { + const tree = new ThoughtTree('session-1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + + const path = engine.extractBestPath(tree); + expect(path).toHaveLength(1); + }); + }); + + describe('getTreeStats', () => { + it('should compute stats for empty tree', () => { + const tree = new ThoughtTree('session-1', 500); + const stats = engine.getTreeStats(tree); + + expect(stats.totalNodes).toBe(0); + expect(stats.maxDepth).toBe(0); + expect(stats.unexploredCount).toBe(0); + expect(stats.averageValue).toBe(0); + expect(stats.terminalCount).toBe(0); + }); + + it('should compute stats for populated tree', () => { + const tree = new ThoughtTree('session-1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + tree.addThought(makeThought({ thoughtNumber: 2 })); + tree.addThought(makeThought({ thoughtNumber: 3, nextThoughtNeeded: false })); + + const stats = engine.getTreeStats(tree); + expect(stats.totalNodes).toBe(3); + expect(stats.maxDepth).toBe(2); + expect(stats.unexploredCount).toBe(3); // None evaluated yet + expect(stats.terminalCount).toBe(1); + }); + + it('should track unexplored vs explored correctly', () => { + const tree = new ThoughtTree('session-1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + const child = tree.addThought(makeThought({ thoughtNumber: 2 })); + + engine.backpropagate(tree, child.nodeId, 0.7); + + const stats = engine.getTreeStats(tree); + expect(stats.unexploredCount).toBe(0); // Both visited via backprop + expect(stats.averageValue).toBeCloseTo(0.7); + }); + }); + + describe('toNodeInfo', () => { + it('should convert ThoughtNode to TreeNodeInfo', () => { + const tree = new ThoughtTree('session-1', 500); + const node = tree.addThought(makeThought({ thoughtNumber: 1, thought: 'Hello world' })); + + const info = engine.toNodeInfo(node); + expect(info.nodeId).toBe(node.nodeId); + expect(info.thoughtNumber).toBe(1); + expect(info.thought).toBe('Hello world'); + expect(info.depth).toBe(0); + expect(info.visitCount).toBe(0); + expect(info.averageValue).toBe(0); + expect(info.childCount).toBe(0); + expect(info.isTerminal).toBe(false); + }); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/metrics.test.ts b/src/sequentialthinking/__tests__/unit/metrics.test.ts new file mode 100644 index 0000000000..83f3729c85 --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/metrics.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { BasicMetricsCollector } from '../../metrics.js'; +import { SessionTracker } from '../../session-tracker.js'; +import { BoundedThoughtManager } from '../../state-manager.js'; +import { createTestThought as makeThought } from '../helpers/factories.js'; + +describe('BasicMetricsCollector', () => { + let metrics: BasicMetricsCollector; + let sessionTracker: SessionTracker; + let storage: BoundedThoughtManager; + + beforeEach(() => { + sessionTracker = new SessionTracker(0); + storage = new BoundedThoughtManager({ + maxHistorySize: 100, + maxBranchAge: 3600000, + maxThoughtLength: 5000, + maxThoughtsPerBranch: 50, + cleanupInterval: 0, + }, sessionTracker); + metrics = new BasicMetricsCollector(sessionTracker, storage); + }); + + afterEach(() => { + storage.destroy(); + sessionTracker.destroy(); + }); + + describe('recordRequest', () => { + it('should increment total and successful on success', () => { + metrics.recordRequest(10, true); + const m = metrics.getMetrics(); + expect(m.requests.totalRequests).toBe(1); + expect(m.requests.successfulRequests).toBe(1); + expect(m.requests.failedRequests).toBe(0); + }); + + it('should increment total and failed on failure', () => { + metrics.recordRequest(10, false); + const m = metrics.getMetrics(); + expect(m.requests.totalRequests).toBe(1); + expect(m.requests.failedRequests).toBe(1); + expect(m.requests.successfulRequests).toBe(0); + }); + + it('should compute average response time', () => { + metrics.recordRequest(10, true); + metrics.recordRequest(20, true); + const m = metrics.getMetrics(); + expect(m.requests.averageResponseTime).toBe(15); + }); + + it('should update lastRequestTime', () => { + metrics.recordRequest(5, true); + const m = metrics.getMetrics(); + expect(m.requests.lastRequestTime).toBeInstanceOf(Date); + }); + }); + + describe('recordThoughtProcessed', () => { + it('should track total thoughts', () => { + metrics.recordThoughtProcessed(makeThought()); + metrics.recordThoughtProcessed(makeThought({ thoughtNumber: 2 })); + expect(metrics.getMetrics().thoughts.totalThoughts).toBe(2); + }); + + it('should track unique branches', () => { + // Branch count is now queried from storage, so add to storage first + const t1 = makeThought({ branchId: 'b1' }); + storage.addThought(t1); + metrics.recordThoughtProcessed(t1); + + const t2 = makeThought({ branchId: 'b1' }); + storage.addThought(t2); + metrics.recordThoughtProcessed(t2); + + const t3 = makeThought({ branchId: 'b2' }); + storage.addThought(t3); + metrics.recordThoughtProcessed(t3); + + expect(metrics.getMetrics().thoughts.branchCount).toBe(2); + }); + + it('should track sessions', () => { + // Record thoughts in tracker first (mimics what happens in real flow) + sessionTracker.recordThought('s1'); + metrics.recordThoughtProcessed(makeThought({ sessionId: 's1' })); + sessionTracker.recordThought('s2'); + metrics.recordThoughtProcessed(makeThought({ sessionId: 's2' })); + expect(metrics.getMetrics().thoughts.activeSessions).toBe(2); + }); + + it('should track revisions', () => { + metrics.recordThoughtProcessed(makeThought({ isRevision: true })); + expect(metrics.getMetrics().thoughts.revisionCount).toBe(1); + }); + + it('should compute average thought length', () => { + metrics.recordThoughtProcessed(makeThought({ thought: 'abcde' })); // 5 + metrics.recordThoughtProcessed(makeThought({ thought: 'abcdefghij' })); // 10 + // average: (5+10)/2 = 7.5, rounded = 8 + expect(metrics.getMetrics().thoughts.averageThoughtLength).toBe(8); + }); + }); + + describe('response time ring buffer', () => { + it('should keep only last 100 response times', () => { + for (let i = 0; i < 110; i++) { + metrics.recordRequest(i, true); + } + // Average should be based on last 100 values (10-109) + const avg = metrics.getMetrics().requests.averageResponseTime; + // Sum of 10..109 = 5950, avg = 59.5 + expect(avg).toBeCloseTo(59.5, 0); + }); + + it('should compute correct average after adding 150 response times', () => { + for (let i = 1; i <= 150; i++) { + metrics.recordRequest(i, true); + } + // Last 100 values are 51..150 + // Sum = (51+150)*100/2 = 10050, avg = 100.5 + const avg = metrics.getMetrics().requests.averageResponseTime; + expect(avg).toBeCloseTo(100.5, 0); + }); + }); + + describe('getMetrics shape', () => { + it('should return correct top-level structure', () => { + const m = metrics.getMetrics(); + expect(m).toHaveProperty('requests'); + expect(m).toHaveProperty('thoughts'); + expect(m).toHaveProperty('system'); + }); + + it('should include system metrics', () => { + const m = metrics.getMetrics(); + expect(m.system.memoryUsage).toHaveProperty('heapUsed'); + expect(m.system.cpuUsage).toHaveProperty('user'); + expect(typeof m.system.uptime).toBe('number'); + expect(m.system.timestamp).toBeInstanceOf(Date); + }); + }); + + describe('destroy', () => { + it('should reset all counters and collections', () => { + metrics.recordRequest(10, true); + metrics.recordRequest(20, false); + metrics.recordThoughtProcessed(makeThought({ sessionId: 's1', branchId: 'b1' })); + metrics.recordThoughtProcessed(makeThought({ sessionId: 's2', isRevision: true })); + + metrics.destroy(); + + const m = metrics.getMetrics(); + expect(m.requests.totalRequests).toBe(0); + expect(m.requests.successfulRequests).toBe(0); + expect(m.requests.failedRequests).toBe(0); + expect(m.requests.averageResponseTime).toBe(0); + expect(m.requests.lastRequestTime).toBeNull(); + expect(m.requests.requestsPerMinute).toBe(0); + expect(m.thoughts.totalThoughts).toBe(0); + expect(m.thoughts.averageThoughtLength).toBe(0); + expect(m.thoughts.thoughtsPerMinute).toBe(0); + expect(m.thoughts.revisionCount).toBe(0); + expect(m.thoughts.branchCount).toBe(0); + expect(m.thoughts.activeSessions).toBe(0); + }); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/race-condition.test.ts b/src/sequentialthinking/__tests__/unit/race-condition.test.ts new file mode 100644 index 0000000000..8f22ac347f --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/race-condition.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SecureThoughtSecurity, SecurityServiceConfigSchema } from '../../security-service.js'; +import { SessionTracker } from '../../session-tracker.js'; +import { SecurityError } from '../../errors.js'; + +describe('Race Condition: Rate Limit Recording', () => { + let sessionTracker: SessionTracker; + let security: SecureThoughtSecurity; + + beforeEach(() => { + sessionTracker = new SessionTracker(0); + security = new SecureThoughtSecurity( + SecurityServiceConfigSchema.parse({ maxThoughtsPerMinute: 3 }), + sessionTracker, + ); + }); + + afterEach(() => { + sessionTracker.destroy(); + }); + + it('should record thought immediately after successful validation', () => { + // First validation should succeed + security.validateThought('test 1', 'race-session'); + + // Check that it was recorded by verifying the count + const stats = sessionTracker.getActiveSessionCount(); + expect(stats).toBeGreaterThan(0); + }); + + it('should prevent race condition with rapid sequential validations', () => { + // Rapid fire 3 validations - all should succeed + security.validateThought('test 1', 'rapid-session'); + security.validateThought('test 2', 'rapid-session'); + security.validateThought('test 3', 'rapid-session'); + + // 4th should fail because rate limit was recorded after each validation + expect(() => security.validateThought('test 4', 'rapid-session')) + .toThrow(SecurityError); + expect(() => security.validateThought('test 4', 'rapid-session')) + .toThrow('Rate limit exceeded'); + }); + + it('should enforce rate limit correctly even with interleaved sessions', () => { + // Session A: 3 thoughts (at limit) + security.validateThought('a1', 'session-a'); + security.validateThought('a2', 'session-a'); + security.validateThought('a3', 'session-a'); + + // Session B: 2 thoughts (under limit) + security.validateThought('b1', 'session-b'); + security.validateThought('b2', 'session-b'); + + // Session A: should fail (at limit) + expect(() => security.validateThought('a4', 'session-a')) + .toThrow('Rate limit exceeded'); + + // Session B: should succeed (1 more allowed) + expect(() => security.validateThought('b3', 'session-b')) + .not.toThrow(); + + // Session B: should now fail (at limit) + expect(() => security.validateThought('b4', 'session-b')) + .toThrow('Rate limit exceeded'); + }); + + it('should handle validation failure without recording', () => { + // Create security with blocked pattern + const securityWithBlock = new SecureThoughtSecurity( + SecurityServiceConfigSchema.parse({ + maxThoughtsPerMinute: 5, + blockedPatterns: ['forbidden'], + }), + sessionTracker, + ); + + // This should fail validation due to blocked pattern + expect(() => securityWithBlock.validateThought('this is forbidden', 'test-session')) + .toThrow(SecurityError); + + // Session should not have any rate limit entries since validation failed + // Try 5 more validations with valid content + for (let i = 0; i < 5; i++) { + securityWithBlock.validateThought(`valid thought ${i}`, 'test-session'); + } + + // 6th should fail due to rate limit (not including the failed validation) + expect(() => securityWithBlock.validateThought('valid thought 6', 'test-session')) + .toThrow('Rate limit exceeded'); + }); + + it('should maintain accurate count even with empty session IDs', () => { + // Empty session ID should not be rate limited or recorded + security.validateThought('test 1', ''); + security.validateThought('test 2', ''); + security.validateThought('test 3', ''); + security.validateThought('test 4', ''); // Should not throw + + // Verify that empty sessions don't pollute the tracker + expect(sessionTracker.getActiveSessionCount()).toBe(0); + }); + + it('should correctly expire old rate limit entries', () => { + // This test verifies that old entries don't prevent new thoughts + const tracker = new SessionTracker(0); + const sec = new SecureThoughtSecurity( + SecurityServiceConfigSchema.parse({ maxThoughtsPerMinute: 2 }), + tracker, + ); + + // Add 2 thoughts (at limit) + sec.validateThought('old 1', 'expire-session'); + sec.validateThought('old 2', 'expire-session'); + + // Should be at limit + expect(() => sec.validateThought('new 1', 'expire-session')) + .toThrow('Rate limit exceeded'); + + // Wait for rate window to expire (61 seconds) + // Simulate by manually pruning old timestamps + tracker.cleanup(); + + tracker.destroy(); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/security-service.test.ts b/src/sequentialthinking/__tests__/unit/security-service.test.ts new file mode 100644 index 0000000000..8b40621827 --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/security-service.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SecureThoughtSecurity, SecurityServiceConfigSchema } from '../../security-service.js'; +import { SessionTracker } from '../../session-tracker.js'; +import { SecurityError } from '../../errors.js'; + +describe('SecureThoughtSecurity', () => { + let sessionTracker: SessionTracker; + + beforeEach(() => { + sessionTracker = new SessionTracker(0); + }); + + afterEach(() => { + sessionTracker.destroy(); + }); + + describe('sanitizeContent', () => { + let security: SecureThoughtSecurity; + beforeEach(() => { + security = new SecureThoughtSecurity(undefined, sessionTracker); + }); + + it('should strip world'); + expect(result).toBe('hello world'); + }); + + it('should strip javascript: protocol', () => { + const result = security.sanitizeContent('visit javascript:void(0)'); + expect(result).toBe('visit void(0)'); + }); + + it('should strip eval(', () => { + const result = security.sanitizeContent('call eval(x)'); + expect(result).toBe('call x)'); + }); + + it('should strip Function(', () => { + const result = security.sanitizeContent('new Function(code)'); + expect(result).toBe('new code)'); + }); + + it('should strip event handlers', () => { + const result = security.sanitizeContent('
'); + expect(result).toBe('
'); + }); + }); + + describe('validateSession', () => { + let security: SecureThoughtSecurity; + beforeEach(() => { + security = new SecureThoughtSecurity(undefined, sessionTracker); + }); + + it('should accept 100-char session ID', () => { + expect(security.validateSession('a'.repeat(100))).toBe(true); + }); + + it('should reject 101-char session ID', () => { + expect(security.validateSession('a'.repeat(101))).toBe(false); + }); + + it('should reject empty session ID', () => { + expect(security.validateSession('')).toBe(false); + }); + + it('should accept normal session ID', () => { + expect(security.validateSession('session-123')).toBe(true); + }); + }); + + describe('generateSessionId', () => { + let security: SecureThoughtSecurity; + beforeEach(() => { + security = new SecureThoughtSecurity(undefined, sessionTracker); + }); + + it('should return UUID format', () => { + const id = security.generateSessionId(); + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + + it('should return unique IDs', () => { + const ids = new Set(Array.from({ length: 10 }, () => security.generateSessionId())); + expect(ids.size).toBe(10); + }); + }); + + describe('validateThought', () => { + it('should block eval( via regex matching', () => { + const security = new SecureThoughtSecurity( + SecurityServiceConfigSchema.parse({ + blockedPatterns: ['eval\\s*\\('], + }), + sessionTracker, + ); + expect(() => security.validateThought('call eval(x)', 'sess')).toThrow(SecurityError); + expect(() => security.validateThought('call eval (x)', 'sess')).toThrow(SecurityError); + }); + + it('should block literal patterns like javascript:', () => { + const security = new SecureThoughtSecurity(undefined, sessionTracker); + expect(() => security.validateThought('visit javascript:void(0)', 'sess')).toThrow(SecurityError); + }); + + it('should skip malformed regex patterns gracefully', () => { + const security = new SecureThoughtSecurity( + SecurityServiceConfigSchema.parse({ + blockedPatterns: ['(invalid[', 'eval\\('], + }), + sessionTracker, + ); + // Should not throw on the malformed pattern, but should catch eval( + expect(() => security.validateThought('call eval(x)', 'sess')).toThrow(SecurityError); + }); + + it('should allow safe content', () => { + const security = new SecureThoughtSecurity(undefined, sessionTracker); + expect(() => security.validateThought('normal analysis text', 'sess')).not.toThrow(); + }); + }); + + describe('repeated regex validation (no lastIndex statefulness)', () => { + it('should block content consistently on repeated calls', () => { + const security = new SecureThoughtSecurity(undefined, sessionTracker); + // Call validateThought 3 times with the same blocked content — all must throw + expect(() => security.validateThought('visit javascript:void(0)', 'sess')).toThrow(SecurityError); + expect(() => security.validateThought('visit javascript:void(0)', 'sess')).toThrow(SecurityError); + expect(() => security.validateThought('visit javascript:void(0)', 'sess')).toThrow(SecurityError); + }); + + it('should block forbidden content consistently on repeated calls', () => { + const security = new SecureThoughtSecurity(undefined, sessionTracker); + expect(() => security.validateThought('this is forbidden content', 'sess2')).toThrow(SecurityError); + expect(() => security.validateThought('this is forbidden content', 'sess2')).toThrow(SecurityError); + expect(() => security.validateThought('this is forbidden content', 'sess2')).toThrow(SecurityError); + }); + }); + + describe('getSecurityStatus', () => { + it('should return status object', () => { + const security = new SecureThoughtSecurity(undefined, sessionTracker); + const status = security.getSecurityStatus(); + expect(status.status).toBe('healthy'); + expect(typeof status.blockedPatterns).toBe('number'); + }); + }); + + + describe('rate limiting', () => { + it('should allow requests within limit', () => { + const tracker = new SessionTracker(0); + const security = new SecureThoughtSecurity( + SecurityServiceConfigSchema.parse({ maxThoughtsPerMinute: 5 }), + tracker, + ); + // validateThought now records automatically + for (let i = 0; i < 5; i++) { + expect(() => security.validateThought('test thought', 'rate-sess')).not.toThrow(); + } + tracker.destroy(); + }); + + it('should throw SecurityError when rate limit exceeded', () => { + const tracker = new SessionTracker(0); + const security = new SecureThoughtSecurity( + SecurityServiceConfigSchema.parse({ maxThoughtsPerMinute: 3 }), + tracker, + ); + // Use up the limit - validateThought records automatically + security.validateThought('thought 1', 'rate-sess'); + security.validateThought('thought 2', 'rate-sess'); + security.validateThought('thought 3', 'rate-sess'); + // 4th should exceed + expect(() => security.validateThought('thought 4', 'rate-sess')).toThrow(SecurityError); + expect(() => security.validateThought('thought 4', 'rate-sess')).toThrow('Rate limit exceeded'); + tracker.destroy(); + }); + + it('should not rate-limit different sessions', () => { + const tracker = new SessionTracker(0); + const security = new SecureThoughtSecurity( + SecurityServiceConfigSchema.parse({ maxThoughtsPerMinute: 2 }), + tracker, + ); + // validateThought records automatically + security.validateThought('thought 1', 'sess-a'); + security.validateThought('thought 2', 'sess-a'); + // sess-a is at limit, but sess-b should still work + expect(() => security.validateThought('thought 1', 'sess-b')).not.toThrow(); + tracker.destroy(); + }); + + it('should not rate-limit when sessionId is empty', () => { + const tracker = new SessionTracker(0); + const security = new SecureThoughtSecurity( + SecurityServiceConfigSchema.parse({ maxThoughtsPerMinute: 1 }), + tracker, + ); + // Empty sessionId should skip rate limiting entirely + expect(() => security.validateThought('thought 1', '')).not.toThrow(); + expect(() => security.validateThought('thought 2', '')).not.toThrow(); + tracker.destroy(); + }); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/session-validation.test.ts b/src/sequentialthinking/__tests__/unit/session-validation.test.ts new file mode 100644 index 0000000000..c615ea6e72 --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/session-validation.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SequentialThinkingServer } from '../../lib.js'; + +describe('Session ID Validation at Entry Point', () => { + let server: SequentialThinkingServer; + + beforeEach(() => { + server = new SequentialThinkingServer(); + }); + + afterEach(() => { + server.destroy(); + }); + + describe('Valid session IDs', () => { + it('should accept valid UUID format session ID', async () => { + const result = await server.processThought({ + thought: 'test thought', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }); + + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0].text); + expect(data.sessionId).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + }); + + it('should accept short alphanumeric session ID', async () => { + const result = await server.processThought({ + thought: 'test thought', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: 'session123', + }); + + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0].text); + expect(data.sessionId).toBe('session123'); + }); + + it('should accept session ID at maximum length (100 chars)', async () => { + const maxLengthId = 'a'.repeat(100); + const result = await server.processThought({ + thought: 'test thought', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: maxLengthId, + }); + + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0].text); + expect(data.sessionId).toBe(maxLengthId); + }); + + it('should accept session ID with hyphens and underscores', async () => { + const result = await server.processThought({ + thought: 'test thought', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: 'my-session_id-123', + }); + + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0].text); + expect(data.sessionId).toBe('my-session_id-123'); + }); + }); + + describe('Invalid session IDs', () => { + it('should reject empty string session ID', async () => { + const result = await server.processThought({ + thought: 'test thought', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: '', + }); + + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('SECURITY_ERROR'); + expect(data.message).toContain('Invalid session ID format'); + expect(data.message).toContain('got 0'); + }); + + it('should reject session ID exceeding maximum length (101 chars)', async () => { + const tooLongId = 'a'.repeat(101); + const result = await server.processThought({ + thought: 'test thought', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: tooLongId, + }); + + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('SECURITY_ERROR'); + expect(data.message).toContain('Invalid session ID format'); + expect(data.message).toContain('got 101'); + }); + + it('should reject extremely long session ID', async () => { + const extremelyLongId = 'x'.repeat(1000); + const result = await server.processThought({ + thought: 'test thought', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: extremelyLongId, + }); + + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('SECURITY_ERROR'); + expect(data.message).toContain('Invalid session ID format'); + }); + }); + + describe('Session ID generation when not provided', () => { + it('should generate session ID when undefined', async () => { + const result = await server.processThought({ + thought: 'test thought', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + // sessionId not provided + }); + + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0].text); + // Should have generated a UUID-format session ID + expect(data.sessionId).toBeTruthy(); + expect(data.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + }); + + it('should generate different session IDs for different requests', async () => { + const result1 = await server.processThought({ + thought: 'thought 1', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }); + + const result2 = await server.processThought({ + thought: 'thought 2', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }); + + const data1 = JSON.parse(result1.content[0].text); + const data2 = JSON.parse(result2.content[0].text); + + expect(data1.sessionId).not.toBe(data2.sessionId); + }); + }); + + describe('Session ID validation vs user intent', () => { + it('should preserve valid user-provided session ID exactly', async () => { + const userSessionId = 'my-custom-session-2024'; + const result = await server.processThought({ + thought: 'test thought', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: userSessionId, + }); + + const data = JSON.parse(result.content[0].text); + // Should NOT replace with anonymous- prefix + expect(data.sessionId).toBe(userSessionId); + expect(data.sessionId).not.toContain('anonymous-'); + }); + + it('should fail fast on invalid session ID rather than silently replacing', async () => { + // This test verifies that we don't silently replace invalid IDs + const invalidId = ''; // Empty string is invalid + + const result = await server.processThought({ + thought: 'test thought', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: invalidId, + }); + + // Should error, not silently replace + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('SECURITY_ERROR'); + }); + }); + + describe('Edge cases', () => { + it('should handle session ID with special characters', async () => { + // Test that validation is based on length, not content restrictions + const specialId = 'session-!@#$%^&*()_+-=[]{}|;:,.<>?'; + const result = await server.processThought({ + thought: 'test thought', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: specialId, + }); + + // Should accept if within length bounds + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0].text); + expect(data.sessionId).toBe(specialId); + }); + + it('should handle session ID with Unicode characters', async () => { + const unicodeId = 'session-世界-🌍'; + const result = await server.processThought({ + thought: 'test thought', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + sessionId: unicodeId, + }); + + // Should accept if within length bounds + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0].text); + expect(data.sessionId).toBe(unicodeId); + }); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/state-manager.test.ts b/src/sequentialthinking/__tests__/unit/state-manager.test.ts new file mode 100644 index 0000000000..19b635eeb5 --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/state-manager.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { BoundedThoughtManager } from '../../state-manager.js'; +import { SessionTracker } from '../../session-tracker.js'; +import { createTestThought as makeThought } from '../helpers/factories.js'; + +const defaultConfig = { + maxHistorySize: 100, + maxBranchAge: 3600000, + maxThoughtLength: 5000, + maxThoughtsPerBranch: 50, + cleanupInterval: 0, // Disable timer in tests +}; + +describe('BoundedThoughtManager', () => { + let manager: BoundedThoughtManager; + let sessionTracker: SessionTracker; + + beforeEach(() => { + sessionTracker = new SessionTracker(0); + manager = new BoundedThoughtManager({ ...defaultConfig }, sessionTracker); + }); + + afterEach(() => { + manager.destroy(); + sessionTracker.destroy(); + }); + + describe('addThought', () => { + it('should add a thought to history', () => { + manager.addThought(makeThought()); + expect(manager.getHistory()).toHaveLength(1); + }); + + it('should not mutate the original thought', () => { + const thought = makeThought(); + manager.addThought(thought); + // Original should not be mutated + expect(thought.timestamp).toBeUndefined(); + // Stored entry should have timestamp + const history = manager.getHistory(); + expect(history[0].timestamp).toBeGreaterThan(0); + }); + }); + + describe('branch management', () => { + it('should create branch when branchId is provided', () => { + manager.addThought(makeThought({ branchId: 'b1' })); + expect(manager.getBranches()).toContain('b1'); + }); + + it('should track multiple branches', () => { + manager.addThought(makeThought({ branchId: 'b1' })); + manager.addThought(makeThought({ branchId: 'b2' })); + expect(manager.getBranches()).toEqual(expect.arrayContaining(['b1', 'b2'])); + }); + + it('should add thoughts to existing branch', () => { + manager.addThought(makeThought({ branchId: 'b1', thoughtNumber: 1 })); + manager.addThought(makeThought({ branchId: 'b1', thoughtNumber: 2 })); + const branch = manager.getBranch('b1'); + expect(branch?.getThoughtCount()).toBe(2); + }); + + it('should enforce per-branch thought limits', () => { + const limitTracker = new SessionTracker(0); + const mgr = new BoundedThoughtManager({ + ...defaultConfig, + maxThoughtsPerBranch: 2, + }, limitTracker); + mgr.addThought(makeThought({ branchId: 'b1', thoughtNumber: 1 })); + mgr.addThought(makeThought({ branchId: 'b1', thoughtNumber: 2 })); + mgr.addThought(makeThought({ branchId: 'b1', thoughtNumber: 3 })); + const branch = mgr.getBranch('b1'); + expect(branch?.getThoughtCount()).toBe(2); + mgr.destroy(); + limitTracker.destroy(); + }); + }); + + describe('getBranchThoughts', () => { + it('should return empty array for non-existent branch', () => { + expect(manager.getBranchThoughts('no-such-branch')).toEqual([]); + }); + + it('should return thoughts for an existing branch', () => { + manager.addThought(makeThought({ branchId: 'b1', thoughtNumber: 1, thought: 'first' })); + manager.addThought(makeThought({ branchId: 'b1', thoughtNumber: 2, thought: 'second' })); + const thoughts = manager.getBranchThoughts('b1'); + expect(thoughts).toHaveLength(2); + expect(thoughts[0].thought).toBe('first'); + expect(thoughts[1].thought).toBe('second'); + }); + + it('should return a copy that does not mutate internal state', () => { + manager.addThought(makeThought({ branchId: 'b1', thoughtNumber: 1 })); + const thoughts = manager.getBranchThoughts('b1'); + thoughts.push(makeThought({ thoughtNumber: 99 })); + expect(manager.getBranchThoughts('b1')).toHaveLength(1); + }); + }); + + describe('isExpired (via cleanup)', () => { + it('should remove expired branches', () => { + vi.useFakeTimers(); + try { + manager.addThought(makeThought({ branchId: 'old-branch' })); + expect(manager.getBranches()).toContain('old-branch'); + + // Advance past maxBranchAge + vi.advanceTimersByTime(3600001); + + manager.cleanup(); + expect(manager.getBranches()).not.toContain('old-branch'); + } finally { + vi.useRealTimers(); + } + }); + + it('should keep non-expired branches', () => { + vi.useFakeTimers(); + try { + manager.addThought(makeThought({ branchId: 'fresh-branch' })); + + vi.advanceTimersByTime(1000); + + manager.cleanup(); + expect(manager.getBranches()).toContain('fresh-branch'); + } finally { + vi.useRealTimers(); + } + }); + + it('should remove old session stats', () => { + vi.useFakeTimers(); + try { + sessionTracker.recordThought('old-session'); // Record in tracker first + manager.addThought(makeThought({ sessionId: 'old-session' })); + const statsBefore = manager.getStats(); + expect(statsBefore.sessionCount).toBe(1); + + vi.advanceTimersByTime(3600001); + + manager.cleanup(); + const statsAfter = manager.getStats(); + expect(statsAfter.sessionCount).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('session stats use numeric timestamps', () => { + it('should store and retrieve sessions correctly', () => { + sessionTracker.recordThought('num-sess'); // Record in tracker first + manager.addThought(makeThought({ sessionId: 'num-sess' })); + expect(manager.getStats().sessionCount).toBe(1); + }); + + it('should expire sessions based on numeric comparison', () => { + vi.useFakeTimers(); + try { + sessionTracker.recordThought('timed-sess'); // Record in tracker first + manager.addThought(makeThought({ sessionId: 'timed-sess' })); + expect(manager.getStats().sessionCount).toBe(1); + + vi.advanceTimersByTime(3600001); + manager.cleanup(); + + expect(manager.getStats().sessionCount).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('stopCleanupTimer', () => { + it('should not throw when called multiple times', () => { + manager.stopCleanupTimer(); + expect(() => manager.stopCleanupTimer()).not.toThrow(); + }); + }); + + describe('getStats', () => { + it('should return correct shape', () => { + const stats = manager.getStats(); + expect(stats).toEqual({ + historySize: 0, + historyCapacity: 100, + branchCount: 0, + sessionCount: 0, + }); + }); + + it('should reflect added thoughts', () => { + sessionTracker.recordThought('s1'); // Record in tracker first + manager.addThought(makeThought({ branchId: 'b1', sessionId: 's1' })); + const stats = manager.getStats(); + expect(stats.historySize).toBe(1); + expect(stats.branchCount).toBe(1); + expect(stats.sessionCount).toBe(1); + }); + }); + + describe('clearHistory', () => { + it('should clear all data', () => { + manager.addThought(makeThought({ branchId: 'b1', sessionId: 's1' })); + manager.clearHistory(); + expect(manager.getHistory()).toHaveLength(0); + expect(manager.getBranches()).toHaveLength(0); + // Session count is tracked externally in SessionTracker, not cleared by clearHistory + expect(manager.getStats().sessionCount).toBeGreaterThanOrEqual(0); + }); + }); + + describe('destroy', () => { + it('should stop timer and clear history', () => { + manager.addThought(makeThought()); + manager.destroy(); + expect(manager.getHistory()).toHaveLength(0); + }); + }); + + describe('cleanup timer', () => { + it('should fire cleanup and remove expired branches', () => { + vi.useFakeTimers(); + try { + const timerTracker = new SessionTracker(0); + const timerManager = new BoundedThoughtManager({ + ...defaultConfig, + cleanupInterval: 5000, + maxBranchAge: 3000, + }, timerTracker); + + timerManager.addThought(makeThought({ branchId: 'timer-branch' })); + expect(timerManager.getBranches()).toContain('timer-branch'); + + // Advance past branch expiry + cleanup interval + vi.advanceTimersByTime(6000); + + // Branch should be expired and cleaned up by the timer + expect(timerManager.getBranches()).not.toContain('timer-branch'); + + timerManager.destroy(); + timerTracker.destroy(); + } finally { + vi.useRealTimers(); + } + }); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/storage.test.ts b/src/sequentialthinking/__tests__/unit/storage.test.ts new file mode 100644 index 0000000000..b28e4376e2 --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/storage.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { BoundedThoughtManager } from '../../state-manager.js'; +import { SessionTracker } from '../../session-tracker.js'; +import { createTestThought as makeThought } from '../helpers/factories.js'; + +describe('BoundedThoughtManager (Storage Interface)', () => { + let storage: BoundedThoughtManager; + let sessionTracker: SessionTracker; + + afterEach(() => { + storage?.destroy(); + sessionTracker?.destroy(); + }); + + function createStorage() { + sessionTracker = new SessionTracker(0); + storage = new BoundedThoughtManager({ + maxHistorySize: 100, + maxBranchAge: 3600000, + maxThoughtLength: 5000, + maxThoughtsPerBranch: 50, + cleanupInterval: 0, + }, sessionTracker); + return storage; + } + + it('should generate anonymous session ID when missing', () => { + const s = createStorage(); + const thought = makeThought(); + s.addThought(thought); + // Original should not be mutated (input mutation fix) + expect(thought.sessionId).toBeUndefined(); + // Stored entry should have session ID + const history = s.getHistory(); + expect(history[0].sessionId).toMatch(/^anonymous-/); + }); + + it('should keep provided session ID', () => { + const s = createStorage(); + const thought = makeThought({ sessionId: 'my-session' }); + s.addThought(thought); + expect(thought.sessionId).toBe('my-session'); + const history = s.getHistory(); + expect(history[0].sessionId).toBe('my-session'); + }); + + it('should return history', () => { + const s = createStorage(); + s.addThought(makeThought()); + s.addThought(makeThought({ thoughtNumber: 2 })); + expect(s.getHistory()).toHaveLength(2); + expect(s.getHistory(1)).toHaveLength(1); + }); + + it('should track branches', () => { + const s = createStorage(); + s.addThought(makeThought({ branchId: 'b1' })); + expect(s.getBranches()).toContain('b1'); + }); + + it('should return stats', () => { + const s = createStorage(); + const stats = s.getStats(); + expect(stats).toHaveProperty('historySize'); + expect(stats).toHaveProperty('historyCapacity'); + }); + + it('should clear history', () => { + const s = createStorage(); + s.addThought(makeThought()); + s.clearHistory(); + expect(s.getHistory()).toHaveLength(0); + }); + + it('should destroy without errors', () => { + const s = createStorage(); + s.addThought(makeThought()); + expect(() => s.destroy()).not.toThrow(); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/thinking-modes.test.ts b/src/sequentialthinking/__tests__/unit/thinking-modes.test.ts new file mode 100644 index 0000000000..85d0958ed8 --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/thinking-modes.test.ts @@ -0,0 +1,958 @@ +import { describe, it, expect } from 'vitest'; +import { ThinkingModeEngine } from '../../thinking-modes.js'; +import type { ThinkingModeConfig } from '../../thinking-modes.js'; +import { ThoughtTree } from '../../thought-tree.js'; +import { MCTSEngine } from '../../mcts.js'; +import type { ThoughtData } from '../../circular-buffer.js'; + +function makeThought(overrides: Partial = {}): ThoughtData { + return { + thought: 'Test thought', + thoughtNumber: 1, + totalThoughts: 5, + nextThoughtNeeded: true, + sessionId: 'test-session', + ...overrides, + }; +} + +describe('ThinkingModeEngine', () => { + const modeEngine = new ThinkingModeEngine(); + const mctsEngine = new MCTSEngine(); + + describe('getPreset', () => { + it('should return correct config for fast mode', () => { + const config = modeEngine.getPreset('fast'); + expect(config.mode).toBe('fast'); + expect(config.explorationConstant).toBe(0.5); + expect(config.suggestStrategy).toBe('exploit'); + expect(config.maxBranchingFactor).toBe(1); + expect(config.targetDepthMin).toBe(3); + expect(config.targetDepthMax).toBe(5); + expect(config.autoEvaluate).toBe(true); + expect(config.autoEvalValue).toBe(0.7); + expect(config.enableBacktracking).toBe(false); + expect(config.minEvaluationsBeforeConverge).toBe(0); + expect(config.convergenceThreshold).toBe(0); + }); + + it('should return correct config for expert mode', () => { + const config = modeEngine.getPreset('expert'); + expect(config.mode).toBe('expert'); + expect(config.explorationConstant).toBe(Math.SQRT2); + expect(config.suggestStrategy).toBe('balanced'); + expect(config.maxBranchingFactor).toBe(3); + expect(config.targetDepthMin).toBe(5); + expect(config.targetDepthMax).toBe(10); + expect(config.autoEvaluate).toBe(false); + expect(config.enableBacktracking).toBe(true); + expect(config.minEvaluationsBeforeConverge).toBe(3); + expect(config.convergenceThreshold).toBe(0.7); + }); + + it('should return correct config for deep mode', () => { + const config = modeEngine.getPreset('deep'); + expect(config.mode).toBe('deep'); + expect(config.explorationConstant).toBe(2.0); + expect(config.suggestStrategy).toBe('explore'); + expect(config.maxBranchingFactor).toBe(5); + expect(config.targetDepthMin).toBe(10); + expect(config.targetDepthMax).toBe(20); + expect(config.autoEvaluate).toBe(false); + expect(config.enableBacktracking).toBe(true); + expect(config.minEvaluationsBeforeConverge).toBe(5); + expect(config.convergenceThreshold).toBe(0.85); + }); + + it('should return independent copies', () => { + const c1 = modeEngine.getPreset('fast'); + const c2 = modeEngine.getPreset('fast'); + c1.targetDepthMax = 999; + expect(c2.targetDepthMax).toBe(5); + }); + }); + + describe('getAutoEvalValue', () => { + it('should return 0.7 for fast mode', () => { + const config = modeEngine.getPreset('fast'); + expect(modeEngine.getAutoEvalValue(config)).toBe(0.7); + }); + + it('should return null for expert mode', () => { + const config = modeEngine.getPreset('expert'); + expect(modeEngine.getAutoEvalValue(config)).toBeNull(); + }); + + it('should return null for deep mode', () => { + const config = modeEngine.getPreset('deep'); + expect(modeEngine.getAutoEvalValue(config)).toBeNull(); + }); + }); + + describe('generateGuidance — fast mode', () => { + it('should recommend continue when below target depth', () => { + const config = modeEngine.getPreset('fast'); + const tree = new ThoughtTree('s1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + tree.addThought(makeThought({ thoughtNumber: 2 })); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.mode).toBe('fast'); + expect(guidance.recommendedAction).toBe('continue'); + expect(guidance.currentPhase).toBe('exploring'); + expect(guidance.convergenceStatus).toBeNull(); + expect(guidance.branchingSuggestion).toBeNull(); + expect(guidance.backtrackSuggestion).toBeNull(); + }); + + it('should recommend conclude at target depth', () => { + const config = modeEngine.getPreset('fast'); + const tree = new ThoughtTree('s1', 500); + // Build chain of 6 thoughts (depth = 5, which is targetDepthMax) + for (let i = 1; i <= 6; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.recommendedAction).toBe('conclude'); + expect(guidance.currentPhase).toBe('concluded'); + }); + + it('should never recommend branch', () => { + const config = modeEngine.getPreset('fast'); + const tree = new ThoughtTree('s1', 500); + for (let i = 1; i <= 4; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.recommendedAction).not.toBe('branch'); + expect(guidance.branchingSuggestion).toBeNull(); + }); + + it('should set targetTotalThoughts to targetDepthMax', () => { + const config = modeEngine.getPreset('fast'); + const tree = new ThoughtTree('s1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.targetTotalThoughts).toBe(5); + }); + }); + + describe('generateGuidance — expert mode', () => { + it('should recommend branching at decision points', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('s1', 500); + // Build chain of 3 thoughts (depth = 2) + for (let i = 1; i <= 3; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.recommendedAction).toBe('branch'); + expect(guidance.branchingSuggestion).not.toBeNull(); + expect(guidance.branchingSuggestion!.shouldBranch).toBe(true); + }); + + it('should recommend backtracking on low scores', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('s1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + tree.addThought(makeThought({ thoughtNumber: 2 })); + const node3 = tree.addThought(makeThought({ thoughtNumber: 3 })); + + // Give the cursor a low score + mctsEngine.backpropagate(tree, node3.nodeId, 0.2); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.recommendedAction).toBe('backtrack'); + expect(guidance.backtrackSuggestion).not.toBeNull(); + expect(guidance.backtrackSuggestion!.shouldBacktrack).toBe(true); + }); + + it('should recommend evaluate for unevaluated leaves', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('s1', 500); + const root = tree.addThought(makeThought({ thoughtNumber: 1 })); + + // Create multiple branches so cursor has maxBranchingFactor children + tree.addThought(makeThought({ thoughtNumber: 2 })); + tree.setCursor(root.nodeId); + tree.addThought(makeThought({ thoughtNumber: 3 })); + tree.setCursor(root.nodeId); + tree.addThought(makeThought({ thoughtNumber: 4 })); + + // Now cursor is root with 3 children — at maxBranchingFactor + tree.setCursor(root.nodeId); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.recommendedAction).toBe('evaluate'); + }); + + it('should recommend conclude when convergence met', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('s1', 500); + // Build deep enough tree + for (let i = 1; i <= 6; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + // Evaluate with high values to trigger convergence + const leaves = tree.getLeafNodes(); + for (const leaf of leaves) { + mctsEngine.backpropagate(tree, leaf.nodeId, 0.9); + mctsEngine.backpropagate(tree, leaf.nodeId, 0.85); + mctsEngine.backpropagate(tree, leaf.nodeId, 0.88); + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.recommendedAction).toBe('conclude'); + expect(guidance.convergenceStatus).not.toBeNull(); + expect(guidance.convergenceStatus!.isConverged).toBe(true); + }); + }); + + describe('generateGuidance — deep mode', () => { + it('should recommend aggressive branching', () => { + const config = modeEngine.getPreset('deep'); + const tree = new ThoughtTree('s1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.recommendedAction).toBe('branch'); + expect(guidance.branchingSuggestion).not.toBeNull(); + expect(guidance.branchingSuggestion!.shouldBranch).toBe(true); + }); + + it('should use explore strategy', () => { + const config = modeEngine.getPreset('deep'); + expect(config.suggestStrategy).toBe('explore'); + }); + + it('should have high convergence threshold', () => { + const config = modeEngine.getPreset('deep'); + expect(config.convergenceThreshold).toBe(0.85); + expect(config.minEvaluationsBeforeConverge).toBe(5); + }); + + it('should recommend backtracking on mediocre scores', () => { + const config = modeEngine.getPreset('deep'); + const tree = new ThoughtTree('s1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + const child = tree.addThought(makeThought({ thoughtNumber: 2 })); + // Give it a child so backtracking logic triggers + tree.addThought(makeThought({ thoughtNumber: 3 })); + tree.setCursor(child.nodeId); + + // Score below 0.5 + mctsEngine.backpropagate(tree, child.nodeId, 0.3); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.recommendedAction).toBe('backtrack'); + expect(guidance.backtrackSuggestion).not.toBeNull(); + }); + + it('should not conclude until high convergence is met', () => { + const config = modeEngine.getPreset('deep'); + const tree = new ThoughtTree('s1', 500); + for (let i = 1; i <= 11; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + // Evaluate with moderate values — below 0.85 threshold + const leaves = tree.getLeafNodes(); + for (const leaf of leaves) { + mctsEngine.backpropagate(tree, leaf.nodeId, 0.6); + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.recommendedAction).not.toBe('conclude'); + }); + }); + + describe('convergence detection', () => { + it('should not be converged with too few evaluations', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('s1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + const child = tree.addThought(makeThought({ thoughtNumber: 2 })); + + // Only 1 evaluation, need 3 + mctsEngine.backpropagate(tree, child.nodeId, 0.9); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.convergenceStatus).not.toBeNull(); + expect(guidance.convergenceStatus!.isConverged).toBe(false); + }); + + it('should not be converged when score below threshold', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('s1', 500); + for (let i = 1; i <= 6; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + // Evaluate all with low values + const leaves = tree.getLeafNodes(); + for (const leaf of leaves) { + mctsEngine.backpropagate(tree, leaf.nodeId, 0.3); + mctsEngine.backpropagate(tree, leaf.nodeId, 0.2); + mctsEngine.backpropagate(tree, leaf.nodeId, 0.4); + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.convergenceStatus).not.toBeNull(); + expect(guidance.convergenceStatus!.isConverged).toBe(false); + }); + + it('should be converged when enough evals + threshold met', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('s1', 500); + for (let i = 1; i <= 4; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + // 3+ evaluations with high values + const leaves = tree.getLeafNodes(); + for (const leaf of leaves) { + mctsEngine.backpropagate(tree, leaf.nodeId, 0.9); + mctsEngine.backpropagate(tree, leaf.nodeId, 0.85); + mctsEngine.backpropagate(tree, leaf.nodeId, 0.88); + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.convergenceStatus).not.toBeNull(); + expect(guidance.convergenceStatus!.isConverged).toBe(true); + expect(guidance.convergenceStatus!.score).toBeGreaterThanOrEqual(0.7); + }); + + it('should have null convergence for fast mode', () => { + const config = modeEngine.getPreset('fast'); + const tree = new ThoughtTree('s1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.convergenceStatus).toBeNull(); + }); + }); + + describe('thoughtPrompt templates', () => { + it('should produce non-empty thoughtPrompt for every mode', () => { + for (const mode of ['fast', 'expert', 'deep'] as const) { + const config = modeEngine.getPreset(mode); + const tree = new ThoughtTree(`tp-${mode}`, 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.thoughtPrompt).toBeDefined(); + expect(guidance.thoughtPrompt.length).toBeGreaterThan(0); + } + }); + + it('should have no unreplaced {{param}} placeholders in any output', () => { + for (const mode of ['fast', 'expert', 'deep'] as const) { + const config = modeEngine.getPreset(mode); + const tree = new ThoughtTree(`tp-noparam-${mode}`, 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + tree.addThought(makeThought({ thoughtNumber: 2 })); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.thoughtPrompt).not.toMatch(/\{\{\w+\}\}/); + } + }); + + it('fast continue template should contain step number and target', () => { + const config = modeEngine.getPreset('fast'); + const tree = new ThoughtTree('tp-fast-cont', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + tree.addThought(makeThought({ thoughtNumber: 2 })); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.recommendedAction).toBe('continue'); + expect(guidance.thoughtPrompt).toContain('2'); // thoughtNumber + expect(guidance.thoughtPrompt).toContain('5'); // targetDepthMax + }); + + it('fast conclude template should say "Synthesize"', () => { + const config = modeEngine.getPreset('fast'); + const tree = new ThoughtTree('tp-fast-conc', 500); + for (let i = 1; i <= 6; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.recommendedAction).toBe('conclude'); + expect(guidance.thoughtPrompt).toContain('Synthesize'); + }); + + it('expert branch template should contain the branchFromNodeId', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('tp-expert-br', 500); + for (let i = 1; i <= 3; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.recommendedAction).toBe('branch'); + expect(guidance.branchingSuggestion).not.toBeNull(); + expect(guidance.thoughtPrompt).toContain(guidance.branchingSuggestion!.fromNodeId); + }); + + it('expert backtrack template should contain the backtrackToNodeId', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('tp-expert-bt', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + tree.addThought(makeThought({ thoughtNumber: 2 })); + const node3 = tree.addThought(makeThought({ thoughtNumber: 3 })); + + mctsEngine.backpropagate(tree, node3.nodeId, 0.2); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.recommendedAction).toBe('backtrack'); + expect(guidance.backtrackSuggestion).not.toBeNull(); + expect(guidance.thoughtPrompt).toContain(guidance.backtrackSuggestion!.toNodeId); + }); + + it('deep branch template should reference "contrarian"', () => { + const config = modeEngine.getPreset('deep'); + const tree = new ThoughtTree('tp-deep-br', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.recommendedAction).toBe('branch'); + expect(guidance.thoughtPrompt).toContain('contrarian'); + }); + + it('deep conclude template should reference convergence score and threshold', () => { + const config = modeEngine.getPreset('deep'); + const tree = new ThoughtTree('tp-deep-conc', 500); + for (let i = 1; i <= 11; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + // Evaluate with very high values to trigger convergence (threshold 0.85) + const leaves = tree.getLeafNodes(); + for (const leaf of leaves) { + for (let j = 0; j < 5; j++) { + mctsEngine.backpropagate(tree, leaf.nodeId, 0.95); + } + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.recommendedAction).toBe('conclude'); + expect(guidance.thoughtPrompt).toContain('0.85'); // convergenceThreshold + expect(guidance.thoughtPrompt).toMatch(/\d+\.\d+/); // convergence score + expect(guidance.thoughtPrompt).toContain('counterarguments'); + }); + + it('should compress long thoughts using smart compression (no 300-char strings in output)', () => { + const config = modeEngine.getPreset('fast'); + const tree = new ThoughtTree('tp-trunc', 500); + const longThought = 'A'.repeat(300); + tree.addThought(makeThought({ thoughtNumber: 1, thought: longThought })); + tree.addThought(makeThought({ thoughtNumber: 2, thought: longThought })); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + // The raw 300-char string should NOT appear verbatim + expect(guidance.thoughtPrompt).not.toContain(longThought); + // Smart compression uses "..." for single-sentence text + expect(guidance.thoughtPrompt).toContain('...'); + }); + }); + + describe('phase detection', () => { + it('should start in exploring phase', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('s1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.currentPhase).toBe('exploring'); + }); + + it('should move to evaluating after some evaluations and depth', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('s1', 500); + // Build to targetDepthMin (5), need 6 nodes for depth 5 + for (let i = 1; i <= 6; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + // Evaluate the root only (1 node evaluated, but backprop from root only affects root) + // This gives us 1 evaluated node — below minEvaluationsBeforeConverge (3) + // but with depth >= targetDepthMin and some evaluations + const root = tree.root!; + root.visitCount = 1; + root.totalValue = 0.5; + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + // With 1 eval (below minEvals 3) but depth >= targetDepthMin, should be evaluating + expect(guidance.currentPhase).toBe('evaluating'); + }); + + it('should move to converging when enough evaluations', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('s1', 500); + for (let i = 1; i <= 4; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + // 3 evaluations (meets minEvaluationsBeforeConverge for expert) + const leaf = tree.getLeafNodes()[0]; + mctsEngine.backpropagate(tree, leaf.nodeId, 0.5); + mctsEngine.backpropagate(tree, leaf.nodeId, 0.5); + mctsEngine.backpropagate(tree, leaf.nodeId, 0.5); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.currentPhase).toBe('converging'); + }); + + it('should be concluded when convergence is met', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('s1', 500); + for (let i = 1; i <= 4; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + const leaf = tree.getLeafNodes()[0]; + mctsEngine.backpropagate(tree, leaf.nodeId, 0.9); + mctsEngine.backpropagate(tree, leaf.nodeId, 0.85); + mctsEngine.backpropagate(tree, leaf.nodeId, 0.88); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.currentPhase).toBe('concluded'); + }); + }); + + describe('compressThought', () => { + it('should return short text unchanged', () => { + const config = modeEngine.getPreset('fast'); + const tree = new ThoughtTree('compress-short', 500); + const shortText = 'Short thought.'; + tree.addThought(makeThought({ thoughtNumber: 1 })); + // Cursor is on node 2 — the template renders cursor's thought + tree.addThought(makeThought({ thoughtNumber: 2, thought: shortText })); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + // The short text should appear verbatim in the prompt + expect(guidance.thoughtPrompt).toContain(shortText); + }); + + it('should produce first + [...] + last pattern for long multi-sentence text', () => { + const config = modeEngine.getPreset('fast'); + const tree = new ThoughtTree('compress-multi', 500); + const longMultiSentence = 'First sentence here. ' + 'Middle content. '.repeat(15) + 'Last sentence here.'; + tree.addThought(makeThought({ thoughtNumber: 1, thought: longMultiSentence })); + tree.addThought(makeThought({ thoughtNumber: 2, thought: longMultiSentence })); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.thoughtPrompt).toContain('[...]'); + expect(guidance.thoughtPrompt).not.toContain(longMultiSentence); + }); + + it('should produce word-boundary "..." for long single-sentence text', () => { + const config = modeEngine.getPreset('fast'); + const tree = new ThoughtTree('compress-single', 500); + // A long text with no sentence boundaries + const longSingle = 'word '.repeat(60).trim(); + tree.addThought(makeThought({ thoughtNumber: 1, thought: longSingle })); + tree.addThought(makeThought({ thoughtNumber: 2, thought: longSingle })); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.thoughtPrompt).toContain('...'); + expect(guidance.thoughtPrompt).not.toContain(longSingle); + }); + + it('should not have raw 300-char strings in output and should contain [...] marker for multi-sentence', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('compress-300', 500); + const longMulti = 'First part of the analysis. ' + 'X'.repeat(250) + '. Final conclusion here.'; + tree.addThought(makeThought({ thoughtNumber: 1, thought: longMulti })); + tree.addThought(makeThought({ thoughtNumber: 2, thought: longMulti })); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.thoughtPrompt).not.toContain(longMulti); + }); + + it('should produce different compression lengths for different modes', () => { + const longText = 'First sentence. ' + 'M'.repeat(200) + '. Last sentence.'; + + const fastConfig = modeEngine.getPreset('fast'); + const fastTree = new ThoughtTree('compress-fast', 500); + fastTree.addThought(makeThought({ thoughtNumber: 1, thought: longText })); + fastTree.addThought(makeThought({ thoughtNumber: 2 })); + const fastGuidance = modeEngine.generateGuidance(fastConfig, fastTree, mctsEngine); + + const deepConfig = modeEngine.getPreset('deep'); + const deepTree = new ThoughtTree('compress-deep', 500); + deepTree.addThought(makeThought({ thoughtNumber: 1, thought: longText })); + const deepGuidance = modeEngine.generateGuidance(deepConfig, deepTree, mctsEngine); + + // Fast mode has maxThoughtDisplayLength=150, deep has 300 + // The prompts use different templates, but the key assertion is that + // the fast mode config uses shorter max (150 vs 300) + expect(fastConfig.maxThoughtDisplayLength).toBe(150); + expect(deepConfig.maxThoughtDisplayLength).toBe(300); + expect(fastConfig.maxThoughtDisplayLength).toBeLessThan(deepConfig.maxThoughtDisplayLength); + }); + }); + + describe('progressOverview', () => { + it('should return null when not at interval', () => { + const config = modeEngine.getPreset('fast'); // interval = 3 + const tree = new ThoughtTree('po-null', 500); + // 2 nodes — not at interval of 3 + tree.addThought(makeThought({ thoughtNumber: 1 })); + tree.addThought(makeThought({ thoughtNumber: 2 })); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.progressOverview).toBeNull(); + }); + + it('should return non-null at interval (fast mode, 3rd thought)', () => { + const config = modeEngine.getPreset('fast'); // interval = 3 + const tree = new ThoughtTree('po-3rd', 500); + for (let i = 1; i <= 3; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.progressOverview).not.toBeNull(); + expect(guidance.progressOverview).toContain('PROGRESS'); + }); + + it('should contain node count, depth, evaluated count, gap count', () => { + const config = modeEngine.getPreset('fast'); // interval = 3 + const tree = new ThoughtTree('po-content', 500); + for (let i = 1; i <= 3; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + const overview = guidance.progressOverview!; + expect(overview).toContain('3 thoughts'); + expect(overview).toContain('depth'); + expect(overview).toContain('Evaluated'); + expect(overview).toContain('Gaps'); + }); + + it('should contain best path info', () => { + const config = modeEngine.getPreset('fast'); // interval = 3 + const tree = new ThoughtTree('po-bestpath', 500); + for (let i = 1; i <= 3; i++) { + tree.addThought(makeThought({ thoughtNumber: i, thought: `Step ${i} thought.` })); + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + const overview = guidance.progressOverview!; + expect(overview).toContain('Best path'); + expect(overview).toContain('score'); + }); + }); + + describe('critique', () => { + it('should always be null for fast mode', () => { + const config = modeEngine.getPreset('fast'); + const tree = new ThoughtTree('crit-fast', 500); + for (let i = 1; i <= 6; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.critique).toBeNull(); + }); + + it('should be null when bestPath < 2 nodes', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('crit-short', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.critique).toBeNull(); + }); + + it('should be non-null for expert mode with sufficient path', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('crit-expert', 500); + for (let i = 1; i <= 3; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.critique).not.toBeNull(); + expect(guidance.critique).toContain('CRITIQUE'); + }); + + it('should contain weakest link, unchallenged count, branch coverage %, balance label', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('crit-detail', 500); + for (let i = 1; i <= 4; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + // Score some nodes + const leaf = tree.getLeafNodes()[0]; + mctsEngine.backpropagate(tree, leaf.nodeId, 0.6); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + const critique = guidance.critique!; + expect(critique).toContain('Weakest'); + expect(critique).toContain('Unchallenged'); + expect(critique).toContain('Coverage'); + expect(critique).toContain('%'); + expect(critique).toContain('Balance'); + }); + + it('should identify correct weakest node', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('crit-weakest', 500); + tree.addThought(makeThought({ thoughtNumber: 1, thought: 'Strong root.' })); + tree.addThought(makeThought({ thoughtNumber: 2, thought: 'Weak middle step.' })); + tree.addThought(makeThought({ thoughtNumber: 3, thought: 'Strong conclusion.' })); + + // Score root high via a direct manipulation + const root = tree.root!; + root.visitCount = 1; + root.totalValue = 0.9; + + // Score second node low + const allNodes = tree.getAllNodes(); + const node2 = allNodes.find(n => n.thoughtNumber === 2)!; + node2.visitCount = 1; + node2.totalValue = 0.2; + + // Score third node high + const node3 = allNodes.find(n => n.thoughtNumber === 3)!; + node3.visitCount = 1; + node3.totalValue = 0.85; + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + const critique = guidance.critique!; + // The weakest node should be step 2 with score 0.20 + expect(critique).toContain('step 2'); + expect(critique).toContain('0.20'); + }); + }); + + describe('new config fields in presets', () => { + it('should have correct progressOverviewInterval per mode', () => { + expect(modeEngine.getPreset('fast').progressOverviewInterval).toBe(3); + expect(modeEngine.getPreset('expert').progressOverviewInterval).toBe(4); + expect(modeEngine.getPreset('deep').progressOverviewInterval).toBe(5); + }); + + it('should have correct maxThoughtDisplayLength per mode', () => { + expect(modeEngine.getPreset('fast').maxThoughtDisplayLength).toBe(150); + expect(modeEngine.getPreset('expert').maxThoughtDisplayLength).toBe(250); + expect(modeEngine.getPreset('deep').maxThoughtDisplayLength).toBe(300); + }); + + it('should have correct enableCritique per mode', () => { + expect(modeEngine.getPreset('fast').enableCritique).toBe(false); + expect(modeEngine.getPreset('expert').enableCritique).toBe(true); + expect(modeEngine.getPreset('deep').enableCritique).toBe(true); + }); + }); + + describe('complex scenarios with progress and critique', () => { + it('should progress through multiple overview checkpoints at correct intervals', () => { + const config = modeEngine.getPreset('fast'); // interval = 3 + const tree = new ThoughtTree('progress-sequence', 500); + + // At node 1 and 2 — no overview + for (let i = 1; i <= 2; i++) { + tree.addThought(makeThought({ thoughtNumber: i, thought: `Thought ${i}.` })); + } + let guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.progressOverview).toBeNull(); + + // At node 3 — overview appears + tree.addThought(makeThought({ thoughtNumber: 3, thought: 'Thought 3.' })); + guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.progressOverview).not.toBeNull(); + expect(guidance.progressOverview).toContain('3 thoughts'); + + // At node 4 and 5 — no overview + for (let i = 4; i <= 5; i++) { + tree.addThought(makeThought({ thoughtNumber: i, thought: `Thought ${i}.` })); + } + guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.progressOverview).toBeNull(); + + // At node 6 — overview appears again + tree.addThought(makeThought({ thoughtNumber: 6, thought: 'Thought 6.' })); + guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + expect(guidance.progressOverview).not.toBeNull(); + expect(guidance.progressOverview).toContain('6 thoughts'); + }); + + it('should track evaluated vs unevaluated nodes in progress overview', () => { + const config = modeEngine.getPreset('expert'); // interval = 4 + const tree = new ThoughtTree('eval-tracking', 500); + + // Add 4 nodes + for (let i = 1; i <= 4; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + // Evaluate 2 of them + const allNodes = tree.getAllNodes(); + mctsEngine.backpropagate(tree, allNodes[0].nodeId, 0.8); + mctsEngine.backpropagate(tree, allNodes[1].nodeId, 0.7); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + const overview = guidance.progressOverview!; + + // Should show evaluated count + expect(overview).toContain('Evaluated'); + expect(overview).toMatch(/Evaluated \d+\/4/); + }); + + it('should show balance assessment changing as tree grows', () => { + const config = modeEngine.getPreset('expert'); // interval = 4, enableCritique = true + const tree = new ThoughtTree('balance-growth', 500); + + // Linear path: root -> n1 -> n2 -> n3 + for (let i = 1; i <= 3; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + const guidance1 = modeEngine.generateGuidance(config, tree, mctsEngine); + const critique1 = guidance1.critique; + + // Add one more to reach 4 nodes (at interval for expert) + tree.addThought(makeThought({ thoughtNumber: 4 })); + const guidance2 = modeEngine.generateGuidance(config, tree, mctsEngine); + const critique2 = guidance2.critique!; + + // With a linear path (4 nodes on bestPath out of 4 total), balance should be "one-sided" + expect(critique2).toContain('one-sided'); + expect(critique2).toContain('100%'); + + // Add branching to make it more balanced + const root = tree.root!; + tree.setCursor(root.nodeId); + tree.addThought(makeThought({ thoughtNumber: 5, thought: 'Branch 1.' })); + tree.setCursor(root.nodeId); + tree.addThought(makeThought({ thoughtNumber: 6, thought: 'Branch 2.' })); + + const guidance3 = modeEngine.generateGuidance(config, tree, mctsEngine); + const critique3 = guidance3.critique; + + // bestPath is still linear (root -> n1 -> n2 -> n3 -> n4) = 5 nodes out of 6 total + // That's ~83%, still "one-sided" + if (critique3) { + // Only check if critique is present (it might be null if bestPath requirements change) + expect(critique3).toContain('Balance'); + } + }); + + it('should correctly identify unchallenged steps in critique', () => { + const config = modeEngine.getPreset('deep'); // enableCritique = true + const tree = new ThoughtTree('unchallenged', 500); + + // Build a linear path (all nodes have only 1 child) + for (let i = 1; i <= 4; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + // Evaluate to get critique + const leaf = tree.getLeafNodes()[0]; + mctsEngine.backpropagate(tree, leaf.nodeId, 0.7); + mctsEngine.backpropagate(tree, leaf.nodeId, 0.7); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + const critique = guidance.critique!; + + // In a linear path of 4 nodes, there are 3 edges + // Each interior node (1, 2, 3) has 1 child, so 3 unchallenged steps out of 3 + expect(critique).toContain('Unchallenged'); + expect(critique).toContain('3/3'); + }); + + it('should compress thoughts in critique output when text is long', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('crit-compress', 500); + + const longThought = 'First part. ' + 'X'.repeat(200) + '. Last part.'; + tree.addThought(makeThought({ thoughtNumber: 1, thought: longThought })); + tree.addThought(makeThought({ thoughtNumber: 2, thought: longThought })); + tree.addThought(makeThought({ thoughtNumber: 3, thought: longThought })); + + // Evaluate to trigger critique + const leaf = tree.getLeafNodes()[0]; + mctsEngine.backpropagate(tree, leaf.nodeId, 0.3); + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + const critique = guidance.critique!; + + // Critique should not contain the full 200-char middle section + expect(critique).not.toContain(longThought); + // But should reference the weakest node + expect(critique).toContain('Weakest'); + }); + + it('should handle trees with no evaluated nodes in critique', () => { + const config = modeEngine.getPreset('expert'); + const tree = new ThoughtTree('no-evals', 500); + + // Add nodes but don't evaluate any + for (let i = 1; i <= 3; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + const critique = guidance.critique!; + + // Should still generate critique but handle no-eval case + expect(critique).toContain('CRITIQUE'); + // When no nodes are evaluated, weakest should be N/A + expect(critique).toContain('N/A'); + }); + + it('should differentiate compress output based on mode maxThoughtDisplayLength', () => { + // Text longer than even deep mode's 300 max + const veryLongMulti = 'Opening. ' + 'Content. '.repeat(50) + 'Closing.'; + + // Fast mode: 150 chars max + const fastConfig = modeEngine.getPreset('fast'); + const fastTree = new ThoughtTree('compress-fast', 500); + fastTree.addThought(makeThought({ thoughtNumber: 1, thought: veryLongMulti })); + fastTree.addThought(makeThought({ thoughtNumber: 2, thought: veryLongMulti })); + const fastGuidance = modeEngine.generateGuidance(fastConfig, fastTree, mctsEngine); + const fastPrompt = fastGuidance.thoughtPrompt; + + // Deep mode: 300 chars max + const deepConfig = modeEngine.getPreset('deep'); + const deepTree = new ThoughtTree('compress-deep', 500); + deepTree.addThought(makeThought({ thoughtNumber: 1, thought: veryLongMulti })); + deepTree.addThought(makeThought({ thoughtNumber: 2, thought: veryLongMulti })); + const deepGuidance = modeEngine.generateGuidance(deepConfig, deepTree, mctsEngine); + const deepPrompt = deepGuidance.thoughtPrompt; + + // Very long text should trigger compression in both + expect(fastPrompt).toContain('[...]'); + expect(deepPrompt).toContain('[...]'); + // Neither should contain the full original text + expect(fastPrompt).not.toContain(veryLongMulti); + expect(deepPrompt).not.toContain(veryLongMulti); + }); + + it('should include progressOverview in thoughtPrompt when present (not separate field)', () => { + const config = modeEngine.getPreset('fast'); + const tree = new ThoughtTree('overview-in-response', 500); + for (let i = 1; i <= 3; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + + const guidance = modeEngine.generateGuidance(config, tree, mctsEngine); + // progressOverview is a separate field + expect(guidance.progressOverview).not.toBeNull(); + // thoughtPrompt should still be the main prompt (not containing PROGRESS) + expect(guidance.thoughtPrompt).not.toContain('PROGRESS'); + // Both should be present in the response object + expect(guidance).toHaveProperty('thoughtPrompt'); + expect(guidance).toHaveProperty('progressOverview'); + }); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/thought-tree-manager.test.ts b/src/sequentialthinking/__tests__/unit/thought-tree-manager.test.ts new file mode 100644 index 0000000000..5f675b0ae6 --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/thought-tree-manager.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ThoughtTreeManager } from '../../thought-tree-manager.js'; +import type { MCTSConfig } from '../../interfaces.js'; +import type { ThoughtData } from '../../circular-buffer.js'; + +function defaultConfig(): MCTSConfig { + return { + maxNodesPerTree: 500, + maxTreeAge: 3600000, + explorationConstant: Math.SQRT2, + enableAutoTree: true, + }; +} + +function makeThought(overrides: Partial = {}): ThoughtData { + return { + thought: 'Test thought', + thoughtNumber: 1, + totalThoughts: 5, + nextThoughtNeeded: true, + sessionId: 'test-session', + ...overrides, + }; +} + +describe('ThoughtTreeManager', () => { + let manager: ThoughtTreeManager; + + beforeEach(() => { + manager = new ThoughtTreeManager(defaultConfig()); + }); + + afterEach(() => { + manager.destroy(); + }); + + describe('recordThought', () => { + it('should create tree and record first thought', () => { + const result = manager.recordThought(makeThought({ + sessionId: 'session-1', + thoughtNumber: 1, + })); + + expect(result).not.toBeNull(); + expect(result!.nodeId).toBeDefined(); + expect(result!.parentNodeId).toBeNull(); + expect(result!.treeStats.totalNodes).toBe(1); + }); + + it('should record sequential thoughts in same session', () => { + manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + const result = manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 2 })); + + expect(result).not.toBeNull(); + expect(result!.parentNodeId).not.toBeNull(); + expect(result!.treeStats.totalNodes).toBe(2); + }); + + it('should return null when enableAutoTree is false', () => { + const disabledManager = new ThoughtTreeManager({ + ...defaultConfig(), + enableAutoTree: false, + }); + + const result = disabledManager.recordThought(makeThought()); + expect(result).toBeNull(); + + disabledManager.destroy(); + }); + + it('should return null when sessionId is missing', () => { + const result = manager.recordThought(makeThought({ sessionId: undefined })); + expect(result).toBeNull(); + }); + + it('should create separate trees for different sessions', () => { + const r1 = manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + const r2 = manager.recordThought(makeThought({ sessionId: 's2', thoughtNumber: 1 })); + + expect(r1).not.toBeNull(); + expect(r2).not.toBeNull(); + expect(r1!.treeStats.totalNodes).toBe(1); + expect(r2!.treeStats.totalNodes).toBe(1); + }); + }); + + describe('backtrack', () => { + it('should move cursor to specified node', () => { + const r1 = manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 2 })); + + const result = manager.backtrack('s1', r1!.nodeId); + + expect(result.node.nodeId).toBe(r1!.nodeId); + expect(result.children).toHaveLength(1); + expect(result.treeStats.totalNodes).toBe(2); + }); + + it('should throw for non-existent session', () => { + expect(() => manager.backtrack('nonexistent', 'node-1')).toThrow('No thought tree found'); + }); + + it('should throw for non-existent node', () => { + manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + expect(() => manager.backtrack('s1', 'nonexistent')).toThrow('Node not found'); + }); + }); + + describe('evaluate', () => { + it('should backpropagate value through tree', () => { + const r1 = manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + const r2 = manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 2 })); + + const result = manager.evaluate('s1', r2!.nodeId, 0.8); + + expect(result.nodeId).toBe(r2!.nodeId); + expect(result.newVisitCount).toBe(1); + expect(result.newAverageValue).toBeCloseTo(0.8); + expect(result.nodesUpdated).toBe(2); + }); + + it('should handle boundary value 0', () => { + const r1 = manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + const result = manager.evaluate('s1', r1!.nodeId, 0); + + expect(result.newVisitCount).toBe(1); + expect(result.newAverageValue).toBe(0); + }); + + it('should handle boundary value 1', () => { + const r1 = manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + const result = manager.evaluate('s1', r1!.nodeId, 1); + + expect(result.newVisitCount).toBe(1); + expect(result.newAverageValue).toBe(1); + }); + + it('should accumulate multiple evaluations', () => { + const r1 = manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + manager.evaluate('s1', r1!.nodeId, 0.4); + const result = manager.evaluate('s1', r1!.nodeId, 0.8); + + expect(result.newVisitCount).toBe(2); + expect(result.newAverageValue).toBeCloseTo(0.6); + }); + + it('should throw for non-existent node', () => { + manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + expect(() => manager.evaluate('s1', 'nonexistent', 0.5)).toThrow('Node not found'); + }); + }); + + describe('suggest', () => { + it('should suggest unexplored nodes', () => { + manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 2 })); + + const result = manager.suggest('s1'); + expect(result.suggestion).not.toBeNull(); + expect(result.treeStats.totalNodes).toBe(2); + }); + + it('should return null suggestion when all terminal', () => { + manager.recordThought(makeThought({ + sessionId: 's1', + thoughtNumber: 1, + nextThoughtNeeded: false, + })); + + const result = manager.suggest('s1'); + expect(result.suggestion).toBeNull(); + }); + + it('should accept strategy parameter', () => { + manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + + const explore = manager.suggest('s1', 'explore'); + const exploit = manager.suggest('s1', 'exploit'); + const balanced = manager.suggest('s1', 'balanced'); + + expect(explore.suggestion).not.toBeNull(); + expect(exploit.suggestion).not.toBeNull(); + expect(balanced.suggestion).not.toBeNull(); + }); + }); + + describe('getSummary', () => { + it('should return summary with best path and tree structure', () => { + manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + const r2 = manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 2 })); + manager.evaluate('s1', r2!.nodeId, 0.9); + + const summary = manager.getSummary('s1'); + expect(summary.bestPath).toHaveLength(2); + expect(summary.treeStructure).not.toBeNull(); + expect(summary.treeStats.totalNodes).toBe(2); + }); + + it('should respect maxDepth parameter', () => { + manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 2 })); + manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 3 })); + + const summary = manager.getSummary('s1', 0); + const tree = summary.treeStructure as Record; + expect(tree.children).toBe('[1 children truncated]'); + }); + }); + + describe('setMode / getMode', () => { + it('should store and retrieve mode config', () => { + manager.setMode('s1', 'fast'); + const config = manager.getMode('s1'); + + expect(config).not.toBeNull(); + expect(config!.mode).toBe('fast'); + expect(config!.explorationConstant).toBe(0.5); + }); + + it('should return null for session without mode', () => { + expect(manager.getMode('nonexistent')).toBeNull(); + }); + + it('should create tree when setting mode', () => { + manager.setMode('s-new', 'expert'); + // Tree should exist now — backtrack will fail with node error, not session error + expect(() => manager.backtrack('s-new', 'nonexistent-node')).toThrow('Node not found'); + }); + + it('should override previous mode', () => { + manager.setMode('s1', 'fast'); + manager.setMode('s1', 'deep'); + const config = manager.getMode('s1'); + expect(config!.mode).toBe('deep'); + }); + }); + + describe('recordThought with mode', () => { + it('should include modeGuidance in result when mode is active', () => { + manager.setMode('s1', 'expert'); + const result = manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + + expect(result).not.toBeNull(); + expect(result!.modeGuidance).toBeDefined(); + expect(result!.modeGuidance!.mode).toBe('expert'); + expect(result!.modeGuidance!.currentPhase).toBeDefined(); + expect(result!.modeGuidance!.recommendedAction).toBeDefined(); + }); + + it('should not include modeGuidance when no mode is set', () => { + const result = manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + expect(result).not.toBeNull(); + expect(result!.modeGuidance).toBeUndefined(); + }); + + it('should auto-evaluate in fast mode', () => { + manager.setMode('s1', 'fast'); + const result = manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + + expect(result).not.toBeNull(); + // Auto-eval should have run backpropagate, so node has visitCount > 0 + expect(result!.treeStats.unexploredCount).toBe(0); + }); + + it('should not auto-evaluate in expert mode', () => { + manager.setMode('s1', 'expert'); + const result = manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + + expect(result).not.toBeNull(); + expect(result!.treeStats.unexploredCount).toBe(1); + }); + }); + + describe('cleanup', () => { + it('should remove expired trees', async () => { + const shortLivedManager = new ThoughtTreeManager({ + ...defaultConfig(), + maxTreeAge: 1, // 1ms expiry + }); + shortLivedManager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + + // Wait for tree to expire + await new Promise(resolve => setTimeout(resolve, 10)); + shortLivedManager.cleanup(); + + expect(() => shortLivedManager.suggest('s1')).toThrow('No thought tree found'); + shortLivedManager.destroy(); + }); + }); + + describe('destroy', () => { + it('should clear all trees and stop timer', () => { + manager.recordThought(makeThought({ sessionId: 's1', thoughtNumber: 1 })); + manager.destroy(); + + expect(() => manager.backtrack('s1', 'any')).toThrow('No thought tree found'); + }); + + it('should be safe to call multiple times', () => { + expect(() => { + manager.destroy(); + manager.destroy(); + }).not.toThrow(); + }); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/thought-tree.test.ts b/src/sequentialthinking/__tests__/unit/thought-tree.test.ts new file mode 100644 index 0000000000..9608880aa7 --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/thought-tree.test.ts @@ -0,0 +1,360 @@ +import { describe, it, expect } from 'vitest'; +import { ThoughtTree } from '../../thought-tree.js'; +import type { ThoughtData } from '../../circular-buffer.js'; + +function makeThought(overrides: Partial = {}): ThoughtData { + return { + thought: 'Test thought', + thoughtNumber: 1, + totalThoughts: 5, + nextThoughtNeeded: true, + sessionId: 'test-session', + ...overrides, + }; +} + +describe('ThoughtTree', () => { + describe('addThought', () => { + it('should create root node for the first thought', () => { + const tree = new ThoughtTree('session-1', 500); + const node = tree.addThought(makeThought({ thoughtNumber: 1 })); + + expect(node.parentId).toBeNull(); + expect(node.depth).toBe(0); + expect(node.thoughtNumber).toBe(1); + expect(tree.size).toBe(1); + expect(tree.root).toBe(node); + expect(tree.cursor).toBe(node); + }); + + it('should create sequential child of cursor', () => { + const tree = new ThoughtTree('session-1', 500); + const root = tree.addThought(makeThought({ thoughtNumber: 1 })); + const child = tree.addThought(makeThought({ thoughtNumber: 2 })); + + expect(child.parentId).toBe(root.nodeId); + expect(child.depth).toBe(1); + expect(tree.cursor).toBe(child); + expect(root.children).toContain(child.nodeId); + }); + + it('should create branch as child of branchFromThought target', () => { + const tree = new ThoughtTree('session-1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + tree.addThought(makeThought({ thoughtNumber: 2 })); + + const branch = tree.addThought(makeThought({ + thoughtNumber: 3, + branchFromThought: 1, + branchId: 'alt-branch', + })); + + const root = tree.root!; + expect(branch.parentId).toBe(root.nodeId); + expect(branch.depth).toBe(1); + expect(root.children).toContain(branch.nodeId); + }); + + it('should create revision as sibling of revised node', () => { + const tree = new ThoughtTree('session-1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + const second = tree.addThought(makeThought({ thoughtNumber: 2 })); + + const revision = tree.addThought(makeThought({ + thoughtNumber: 3, + isRevision: true, + revisesThought: 2, + })); + + // Revision of thought 2 should be sibling (same parent as thought 2) + expect(revision.parentId).toBe(second.parentId); + expect(revision.depth).toBe(second.depth); + }); + + it('should create revision of root as child of root', () => { + const tree = new ThoughtTree('session-1', 500); + const root = tree.addThought(makeThought({ thoughtNumber: 1 })); + + const revision = tree.addThought(makeThought({ + thoughtNumber: 2, + isRevision: true, + revisesThought: 1, + })); + + expect(revision.parentId).toBe(root.nodeId); + expect(revision.depth).toBe(1); + }); + + it('should mark terminal nodes when nextThoughtNeeded is false', () => { + const tree = new ThoughtTree('session-1', 500); + const node = tree.addThought(makeThought({ + thoughtNumber: 1, + nextThoughtNeeded: false, + })); + + expect(node.isTerminal).toBe(true); + }); + + it('should mark non-terminal nodes when nextThoughtNeeded is true', () => { + const tree = new ThoughtTree('session-1', 500); + const node = tree.addThought(makeThought({ + thoughtNumber: 1, + nextThoughtNeeded: true, + })); + + expect(node.isTerminal).toBe(false); + }); + + it('should initialize visitCount and totalValue to 0', () => { + const tree = new ThoughtTree('session-1', 500); + const node = tree.addThought(makeThought()); + + expect(node.visitCount).toBe(0); + expect(node.totalValue).toBe(0); + }); + + it('should fallback to cursor when branchFromThought target not found', () => { + const tree = new ThoughtTree('session-1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + const second = tree.addThought(makeThought({ thoughtNumber: 2 })); + + const branch = tree.addThought(makeThought({ + thoughtNumber: 3, + branchFromThought: 99, // doesn't exist + branchId: 'missing-branch', + })); + + expect(branch.parentId).toBe(second.nodeId); + }); + }); + + describe('setCursor', () => { + it('should move cursor to specified node', () => { + const tree = new ThoughtTree('session-1', 500); + const first = tree.addThought(makeThought({ thoughtNumber: 1 })); + tree.addThought(makeThought({ thoughtNumber: 2 })); + + const result = tree.setCursor(first.nodeId); + expect(result).toBe(first); + expect(tree.cursor).toBe(first); + }); + + it('should throw for non-existent node', () => { + const tree = new ThoughtTree('session-1', 500); + tree.addThought(makeThought()); + + expect(() => tree.setCursor('nonexistent')).toThrow('Node not found'); + }); + }); + + describe('findNodeByThoughtNumber', () => { + it('should find node by thought number', () => { + const tree = new ThoughtTree('session-1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + const second = tree.addThought(makeThought({ thoughtNumber: 2 })); + + const found = tree.findNodeByThoughtNumber(2); + expect(found).toBe(second); + }); + + it('should return undefined for missing thought number', () => { + const tree = new ThoughtTree('session-1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + + expect(tree.findNodeByThoughtNumber(99)).toBeUndefined(); + }); + + it('should prefer cursor ancestor when multiple nodes have same thought number', () => { + const tree = new ThoughtTree('session-1', 500); + const first = tree.addThought(makeThought({ thoughtNumber: 1 })); + tree.addThought(makeThought({ thoughtNumber: 2 })); + + // Create a branch also with thoughtNumber 2 + tree.setCursor(first.nodeId); + const branchTwo = tree.addThought(makeThought({ + thoughtNumber: 2, + branchFromThought: 1, + branchId: 'branch-alt', + })); + + // Now cursor is at branchTwo, so it should be preferred + const found = tree.findNodeByThoughtNumber(2); + expect(found?.nodeId).toBe(branchTwo.nodeId); + }); + }); + + describe('getAncestorPath', () => { + it('should return path from root to node', () => { + const tree = new ThoughtTree('session-1', 500); + const first = tree.addThought(makeThought({ thoughtNumber: 1 })); + const second = tree.addThought(makeThought({ thoughtNumber: 2 })); + const third = tree.addThought(makeThought({ thoughtNumber: 3 })); + + const path = tree.getAncestorPath(third.nodeId); + expect(path).toHaveLength(3); + expect(path[0].nodeId).toBe(first.nodeId); + expect(path[1].nodeId).toBe(second.nodeId); + expect(path[2].nodeId).toBe(third.nodeId); + }); + + it('should return single element for root', () => { + const tree = new ThoughtTree('session-1', 500); + const root = tree.addThought(makeThought({ thoughtNumber: 1 })); + + const path = tree.getAncestorPath(root.nodeId); + expect(path).toHaveLength(1); + expect(path[0].nodeId).toBe(root.nodeId); + }); + }); + + describe('getChildren', () => { + it('should return children of a node', () => { + const tree = new ThoughtTree('session-1', 500); + const root = tree.addThought(makeThought({ thoughtNumber: 1 })); + const child1 = tree.addThought(makeThought({ thoughtNumber: 2 })); + + tree.setCursor(root.nodeId); + const child2 = tree.addThought(makeThought({ thoughtNumber: 3 })); + + const children = tree.getChildren(root.nodeId); + expect(children).toHaveLength(2); + expect(children.map(c => c.nodeId)).toContain(child1.nodeId); + expect(children.map(c => c.nodeId)).toContain(child2.nodeId); + }); + + it('should return empty for leaf node', () => { + const tree = new ThoughtTree('session-1', 500); + const leaf = tree.addThought(makeThought()); + + expect(tree.getChildren(leaf.nodeId)).toHaveLength(0); + }); + + it('should return empty for non-existent node', () => { + const tree = new ThoughtTree('session-1', 500); + expect(tree.getChildren('nonexistent')).toHaveLength(0); + }); + }); + + describe('getLeafNodes', () => { + it('should return all leaf nodes', () => { + const tree = new ThoughtTree('session-1', 500); + const root = tree.addThought(makeThought({ thoughtNumber: 1 })); + const child1 = tree.addThought(makeThought({ thoughtNumber: 2, nextThoughtNeeded: false })); + + tree.setCursor(root.nodeId); + const child2 = tree.addThought(makeThought({ thoughtNumber: 3, nextThoughtNeeded: false })); + + const leaves = tree.getLeafNodes(); + expect(leaves).toHaveLength(2); + expect(leaves.map(l => l.nodeId)).toContain(child1.nodeId); + expect(leaves.map(l => l.nodeId)).toContain(child2.nodeId); + }); + }); + + describe('getExpandableNodes', () => { + it('should return non-terminal nodes', () => { + const tree = new ThoughtTree('session-1', 500); + tree.addThought(makeThought({ thoughtNumber: 1, nextThoughtNeeded: true })); + tree.addThought(makeThought({ thoughtNumber: 2, nextThoughtNeeded: false })); + + const expandable = tree.getExpandableNodes(); + expect(expandable).toHaveLength(1); + expect(expandable[0].thoughtNumber).toBe(1); + }); + }); + + describe('toJSON', () => { + it('should serialize tree structure', () => { + const tree = new ThoughtTree('session-1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + tree.addThought(makeThought({ thoughtNumber: 2 })); + + const json = tree.toJSON() as Record; + expect(json).not.toBeNull(); + expect(json.thoughtNumber).toBe(1); + expect(json.childCount).toBe(1); + }); + + it('should return null for empty tree', () => { + const tree = new ThoughtTree('session-1', 500); + expect(tree.toJSON()).toBeNull(); + }); + + it('should respect maxDepth', () => { + const tree = new ThoughtTree('session-1', 500); + tree.addThought(makeThought({ thoughtNumber: 1 })); + tree.addThought(makeThought({ thoughtNumber: 2 })); + tree.addThought(makeThought({ thoughtNumber: 3 })); + + const json = tree.toJSON(0) as Record; + expect(json.children).toBe('[1 children truncated]'); + }); + }); + + describe('prune', () => { + it('should remove lowest-value leaves when over capacity', () => { + const tree = new ThoughtTree('session-1', 5); + + // Build a tree with branches so there are prunable leaves + const root = tree.addThought(makeThought({ thoughtNumber: 1 })); + + // Branch A: 2 children off root + tree.addThought(makeThought({ thoughtNumber: 2 })); + tree.setCursor(root.nodeId); + tree.addThought(makeThought({ thoughtNumber: 3 })); + tree.setCursor(root.nodeId); + tree.addThought(makeThought({ thoughtNumber: 4 })); + tree.setCursor(root.nodeId); + tree.addThought(makeThought({ thoughtNumber: 5 })); + + expect(tree.size).toBe(5); + + // Adding one more should trigger pruning — leaf nodes off root can be pruned + tree.setCursor(root.nodeId); + tree.addThought(makeThought({ thoughtNumber: 6 })); + expect(tree.size).toBeLessThanOrEqual(5); + }); + + it('should never remove root or cursor', () => { + const tree = new ThoughtTree('session-1', 4); + const root = tree.addThought(makeThought({ thoughtNumber: 1 })); + + // Create branches off root so leaves can be pruned + tree.addThought(makeThought({ thoughtNumber: 2 })); + tree.setCursor(root.nodeId); + tree.addThought(makeThought({ thoughtNumber: 3 })); + tree.setCursor(root.nodeId); + tree.addThought(makeThought({ thoughtNumber: 4 })); + + // Cursor is at thought 4, root is thought 1; both should survive + tree.setCursor(root.nodeId); + tree.addThought(makeThought({ thoughtNumber: 5 })); + + expect(tree.root?.nodeId).toBe(root.nodeId); + expect(tree.cursor).toBeDefined(); + }); + }); + + describe('edge cases', () => { + it('should handle single node tree', () => { + const tree = new ThoughtTree('session-1', 500); + const node = tree.addThought(makeThought({ thoughtNumber: 1 })); + + expect(tree.size).toBe(1); + expect(tree.root).toBe(node); + expect(tree.cursor).toBe(node); + expect(tree.getLeafNodes()).toHaveLength(1); + expect(tree.getAncestorPath(node.nodeId)).toHaveLength(1); + }); + + it('should build deep linear chain', () => { + const tree = new ThoughtTree('session-1', 500); + for (let i = 1; i <= 10; i++) { + tree.addThought(makeThought({ thoughtNumber: i })); + } + expect(tree.size).toBe(10); + expect(tree.cursor?.depth).toBe(9); + expect(tree.getAncestorPath(tree.cursor!.nodeId)).toHaveLength(10); + }); + }); +}); diff --git a/src/sequentialthinking/__tests__/unit/timestamp-tracking.test.ts b/src/sequentialthinking/__tests__/unit/timestamp-tracking.test.ts new file mode 100644 index 0000000000..6747246624 --- /dev/null +++ b/src/sequentialthinking/__tests__/unit/timestamp-tracking.test.ts @@ -0,0 +1,435 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { BasicMetricsCollector } from '../../metrics.js'; +import { SessionTracker } from '../../session-tracker.js'; +import { BoundedThoughtManager } from '../../state-manager.js'; +import { createTestThought as makeThought } from '../helpers/factories.js'; + +describe('Timestamp Tracking with CircularBuffer', () => { + let metrics: BasicMetricsCollector; + let sessionTracker: SessionTracker; + let storage: BoundedThoughtManager; + + beforeEach(() => { + sessionTracker = new SessionTracker(0); + storage = new BoundedThoughtManager({ + maxHistorySize: 100, + maxBranchAge: 3600000, + maxThoughtLength: 5000, + maxThoughtsPerBranch: 50, + cleanupInterval: 0, + }, sessionTracker); + metrics = new BasicMetricsCollector(sessionTracker, storage); + }); + + afterEach(() => { + storage.destroy(); + sessionTracker.destroy(); + metrics.destroy(); + }); + + describe('Request timestamp filtering', () => { + it('should only count requests within last 60 seconds', () => { + vi.useFakeTimers(); + try { + const baseTime = Date.now(); + vi.setSystemTime(baseTime); + + // Record 3 requests at base time + metrics.recordRequest(10, true); + metrics.recordRequest(15, true); + metrics.recordRequest(20, true); + + expect(metrics.getMetrics().requests.requestsPerMinute).toBe(3); + + // Advance 30 seconds, add 2 more + vi.advanceTimersByTime(30000); + metrics.recordRequest(12, true); + metrics.recordRequest(18, true); + + expect(metrics.getMetrics().requests.requestsPerMinute).toBe(5); + + // Advance another 31 seconds (total 61s) - first 3 should be excluded + vi.advanceTimersByTime(31000); + metrics.recordRequest(14, true); + + const m = metrics.getMetrics(); + // Should only count the 2 from 30s ago + 1 just now = 3 + expect(m.requests.requestsPerMinute).toBe(3); + } finally { + vi.useRealTimers(); + } + }); + + it('should handle rapid bursts of requests correctly', () => { + vi.useFakeTimers(); + try { + const baseTime = Date.now(); + vi.setSystemTime(baseTime); + + // Record 50 requests in quick succession + for (let i = 0; i < 50; i++) { + metrics.recordRequest(10, true); + } + + expect(metrics.getMetrics().requests.requestsPerMinute).toBe(50); + + // Advance 61 seconds - all should be excluded + vi.advanceTimersByTime(61000); + metrics.recordRequest(10, true); + + expect(metrics.getMetrics().requests.requestsPerMinute).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it('should handle requests exactly at 60 second boundary', () => { + vi.useFakeTimers(); + try { + const baseTime = Date.now(); + vi.setSystemTime(baseTime); + + metrics.recordRequest(10, true); + + // Advance exactly 60 seconds + vi.advanceTimersByTime(60000); + metrics.recordRequest(15, true); + + const m = metrics.getMetrics(); + // Request at exactly 60s old is excluded (> cutoff, not >=) + // Only the current request is counted + expect(m.requests.requestsPerMinute).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('Thought timestamp filtering', () => { + it('should only count thoughts within last 60 seconds', () => { + vi.useFakeTimers(); + try { + const baseTime = Date.now(); + vi.setSystemTime(baseTime); + + // Record 4 thoughts at base time + for (let i = 0; i < 4; i++) { + metrics.recordThoughtProcessed(makeThought({ thoughtNumber: i + 1 })); + } + + expect(metrics.getMetrics().thoughts.thoughtsPerMinute).toBe(4); + + // Advance 40 seconds, add 3 more + vi.advanceTimersByTime(40000); + for (let i = 0; i < 3; i++) { + metrics.recordThoughtProcessed(makeThought({ thoughtNumber: i + 5 })); + } + + expect(metrics.getMetrics().thoughts.thoughtsPerMinute).toBe(7); + + // Advance another 25 seconds (total 65s) - first 4 should be excluded + vi.advanceTimersByTime(25000); + metrics.recordThoughtProcessed(makeThought({ thoughtNumber: 8 })); + + const m = metrics.getMetrics(); + // Should only count the 3 from 40s ago + 1 just now = 4 + expect(m.thoughts.thoughtsPerMinute).toBe(4); + } finally { + vi.useRealTimers(); + } + }); + + it('should handle thought bursts across time windows', () => { + vi.useFakeTimers(); + try { + const baseTime = Date.now(); + vi.setSystemTime(baseTime); + + // Burst 1: 20 thoughts now + for (let i = 0; i < 20; i++) { + metrics.recordThoughtProcessed(makeThought()); + } + + expect(metrics.getMetrics().thoughts.thoughtsPerMinute).toBe(20); + + // Advance 30 seconds + vi.advanceTimersByTime(30000); + + // Burst 2: 15 thoughts + for (let i = 0; i < 15; i++) { + metrics.recordThoughtProcessed(makeThought()); + } + + expect(metrics.getMetrics().thoughts.thoughtsPerMinute).toBe(35); + + // Advance 31 seconds (total 61s) - burst 1 should be excluded + vi.advanceTimersByTime(31000); + + metrics.recordThoughtProcessed(makeThought()); + + const m = metrics.getMetrics(); + // Should only count burst 2 (15) + 1 just now = 16 + expect(m.thoughts.thoughtsPerMinute).toBe(16); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('CircularBuffer overflow behavior', () => { + it('should handle more than 1000 requests correctly', () => { + vi.useFakeTimers(); + try { + const baseTime = Date.now(); + vi.setSystemTime(baseTime); + + // Record 1200 requests within 60 seconds (exceed buffer capacity) + for (let i = 0; i < 1200; i++) { + metrics.recordRequest(5, true); + // Advance by 40ms each (1200 * 40ms = 48s total) + vi.advanceTimersByTime(40); + } + + const m = metrics.getMetrics(); + // All requests should be within 60s window + // But CircularBuffer only keeps last 1000 + expect(m.requests.requestsPerMinute).toBe(1000); + expect(m.requests.totalRequests).toBe(1200); // Total counter should be accurate + } finally { + vi.useRealTimers(); + } + }); + + it('should handle more than 1000 thoughts correctly', () => { + vi.useFakeTimers(); + try { + const baseTime = Date.now(); + vi.setSystemTime(baseTime); + + // Record 1500 thoughts within 60 seconds (exceed buffer capacity) + for (let i = 0; i < 1500; i++) { + sessionTracker.recordThought('session-1'); + metrics.recordThoughtProcessed(makeThought({ sessionId: 'session-1' })); + // Advance by 30ms each (1500 * 30ms = 45s total) + vi.advanceTimersByTime(30); + } + + const m = metrics.getMetrics(); + // All thoughts should be within 60s window + // But CircularBuffer only keeps last 1000 + expect(m.thoughts.thoughtsPerMinute).toBe(1000); + expect(m.thoughts.totalThoughts).toBe(1500); // Total counter should be accurate + } finally { + vi.useRealTimers(); + } + }); + + it('should maintain accurate counts after buffer wraps around', () => { + vi.useFakeTimers(); + try { + const baseTime = Date.now(); + vi.setSystemTime(baseTime); + + // Fill buffer past capacity + for (let i = 0; i < 1100; i++) { + metrics.recordRequest(10, true); + } + + // Advance 61 seconds - all should be stale + vi.advanceTimersByTime(61000); + + // New request + metrics.recordRequest(10, true); + + const m = metrics.getMetrics(); + // Should only count the 1 recent request + expect(m.requests.requestsPerMinute).toBe(1); + expect(m.requests.totalRequests).toBe(1101); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('Mixed request and thought tracking', () => { + it('should independently track request and thought rates', () => { + vi.useFakeTimers(); + try { + const baseTime = Date.now(); + vi.setSystemTime(baseTime); + + // Record 10 requests + for (let i = 0; i < 10; i++) { + metrics.recordRequest(10, true); + } + + // Record 5 thoughts + for (let i = 0; i < 5; i++) { + metrics.recordThoughtProcessed(makeThought()); + } + + let m = metrics.getMetrics(); + expect(m.requests.requestsPerMinute).toBe(10); + expect(m.thoughts.thoughtsPerMinute).toBe(5); + + // Advance 61 seconds + vi.advanceTimersByTime(61000); + + // Record 3 more requests + for (let i = 0; i < 3; i++) { + metrics.recordRequest(10, true); + } + + m = metrics.getMetrics(); + expect(m.requests.requestsPerMinute).toBe(3); + // Note: thoughtsPerMinute still shows 5 because metrics are only + // recalculated when recordThoughtProcessed is called + expect(m.thoughts.thoughtsPerMinute).toBe(5); + + // Record one more thought to trigger recalculation + metrics.recordThoughtProcessed(makeThought()); + + m = metrics.getMetrics(); + expect(m.thoughts.thoughtsPerMinute).toBe(1); // Only the new thought + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('Destroy cleanup', () => { + it('should clear all circular buffers on destroy', () => { + // Record some data + metrics.recordRequest(10, true); + metrics.recordRequest(15, true); + metrics.recordThoughtProcessed(makeThought()); + metrics.recordThoughtProcessed(makeThought()); + + let m = metrics.getMetrics(); + expect(m.requests.requestsPerMinute).toBeGreaterThan(0); + expect(m.thoughts.thoughtsPerMinute).toBeGreaterThan(0); + + // Destroy + metrics.destroy(); + + // Verify all cleared + m = metrics.getMetrics(); + expect(m.requests.requestsPerMinute).toBe(0); + expect(m.thoughts.thoughtsPerMinute).toBe(0); + expect(m.requests.totalRequests).toBe(0); + expect(m.thoughts.totalThoughts).toBe(0); + }); + + it('should handle destroy being called multiple times', () => { + metrics.recordRequest(10, true); + metrics.recordThoughtProcessed(makeThought()); + + metrics.destroy(); + metrics.destroy(); // Second call should be safe + + const m = metrics.getMetrics(); + expect(m.requests.requestsPerMinute).toBe(0); + expect(m.thoughts.thoughtsPerMinute).toBe(0); + }); + }); + + describe('Edge cases', () => { + it('should handle no requests recorded', () => { + const m = metrics.getMetrics(); + expect(m.requests.requestsPerMinute).toBe(0); + expect(m.thoughts.thoughtsPerMinute).toBe(0); + }); + + it('should handle single request at exact boundary', () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(60000); // Start at t=60s + + metrics.recordRequest(10, true); + + vi.setSystemTime(120000); // Advance to t=120s (exactly 60s later) + + metrics.recordRequest(10, true); + + const m = metrics.getMetrics(); + // First request at exactly 60s old is excluded (> cutoff, not >=) + // Only the second request is counted + expect(m.requests.requestsPerMinute).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it('should handle rapid alternating success/failure', () => { + vi.useFakeTimers(); + try { + const baseTime = Date.now(); + vi.setSystemTime(baseTime); + + for (let i = 0; i < 100; i++) { + metrics.recordRequest(10, i % 2 === 0); // Alternate success/fail + } + + const m = metrics.getMetrics(); + expect(m.requests.requestsPerMinute).toBe(100); + expect(m.requests.successfulRequests).toBe(50); + expect(m.requests.failedRequests).toBe(50); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('Performance characteristics', () => { + it('should efficiently handle sustained high request rate', () => { + vi.useFakeTimers(); + try { + const baseTime = Date.now(); + vi.setSystemTime(baseTime); + + // Simulate 5 minutes of sustained load at 100 req/min + for (let minute = 0; minute < 5; minute++) { + for (let req = 0; req < 100; req++) { + metrics.recordRequest(10, true); + vi.advanceTimersByTime(600); // 600ms between requests + } + } + + const m = metrics.getMetrics(); + // Should count approximately last minute of requests + // Allow for off-by-one due to boundary timing + expect(m.requests.requestsPerMinute).toBeGreaterThanOrEqual(99); + expect(m.requests.requestsPerMinute).toBeLessThanOrEqual(101); + expect(m.requests.totalRequests).toBe(500); + } finally { + vi.useRealTimers(); + } + }); + + it('should handle sustained high thought rate', () => { + vi.useFakeTimers(); + try { + const baseTime = Date.now(); + vi.setSystemTime(baseTime); + + // Simulate 10 minutes of sustained load at 50 thoughts/min + for (let minute = 0; minute < 10; minute++) { + for (let thought = 0; thought < 50; thought++) { + sessionTracker.recordThought('session-1'); + metrics.recordThoughtProcessed(makeThought({ sessionId: 'session-1' })); + vi.advanceTimersByTime(1200); // 1.2s between thoughts + } + } + + const m = metrics.getMetrics(); + // Should count approximately last minute of thoughts + // Allow for off-by-one due to boundary timing + expect(m.thoughts.thoughtsPerMinute).toBeGreaterThanOrEqual(49); + expect(m.thoughts.thoughtsPerMinute).toBeLessThanOrEqual(51); + expect(m.thoughts.totalThoughts).toBe(500); + } finally { + vi.useRealTimers(); + } + }); + }); +}); diff --git a/src/sequentialthinking/circular-buffer.ts b/src/sequentialthinking/circular-buffer.ts new file mode 100644 index 0000000000..ce7ae8e39b --- /dev/null +++ b/src/sequentialthinking/circular-buffer.ts @@ -0,0 +1,91 @@ +export interface ThoughtData { + thought: string; + thoughtNumber: number; + totalThoughts: number; + isRevision?: boolean; + revisesThought?: number; + branchFromThought?: number; + branchId?: string; + needsMoreThoughts?: boolean; + nextThoughtNeeded: boolean; + timestamp?: number; + sessionId?: string; +} + +export class CircularBuffer { + private buffer: T[]; + private head: number = 0; + private size: number = 0; + + constructor(private readonly capacity: number) { + if (capacity < 1 || !Number.isInteger(capacity)) { + throw new Error('CircularBuffer capacity must be a positive integer'); + } + this.buffer = new Array(capacity); + } + + add(item: T): void { + this.buffer[this.head] = item; + this.head = (this.head + 1) % this.capacity; + this.size = Math.min(this.size + 1, this.capacity); + } + + getAll(limit?: number): T[] { + if (limit !== undefined && limit < this.size) { + if (limit <= 0) return []; + // Return most recent items + const start = (this.head - limit + this.capacity) % this.capacity; + return this.getRange(start, limit); + } + return this.getRange(0, this.size); + } + + getRange(start: number, count: number): T[] { + const result: T[] = []; + + for (let i = 0; i < count; i++) { + // Calculate buffer index using modular arithmetic: + // (head - size + start + i + capacity) % capacity + // This accounts for: + // - head: current write position + // - size: number of valid items in buffer + // - start: offset from oldest item + // - i: iteration counter + // - capacity: added to prevent negative intermediate values + // Result: proper index even when buffer wraps around + const index = (this.head - this.size + start + i + this.capacity) % this.capacity; + const item = this.buffer[index]; + if (item !== undefined) { + result.push(item); + } + } + + return result; + } + + get currentSize(): number { + return this.size; + } + + get isFull(): boolean { + return this.size === this.capacity; + } + + clear(): void { + this.head = 0; + this.size = 0; + this.buffer = new Array(this.capacity); + } + + getOldest(): T | undefined { + if (this.size === 0) return undefined; + const oldestIndex = (this.head - this.size + this.capacity) % this.capacity; + return this.buffer[oldestIndex]; + } + + getNewest(): T | undefined { + if (this.size === 0) return undefined; + const newestIndex = (this.head - 1 + this.capacity) % this.capacity; + return this.buffer[newestIndex]; + } +} diff --git a/src/sequentialthinking/config.ts b/src/sequentialthinking/config.ts new file mode 100644 index 0000000000..b4be569b07 --- /dev/null +++ b/src/sequentialthinking/config.ts @@ -0,0 +1,181 @@ +import type { AppConfig } from './interfaces.js'; + +export const SESSION_EXPIRY_MS = 3600000; + +export interface EnvironmentInfo { + nodeVersion: string; + platform: string; + arch: string; + pid: number; + memoryUsage: NodeJS.MemoryUsage; + uptime: number; +} + +function parseIntOrDefault(value: string | undefined, defaultValue: number): number { + if (value === undefined) return defaultValue; + const parsed = parseInt(value, 10); + return Number.isNaN(parsed) ? defaultValue : parsed; +} + +function parseFloatOrDefault(value: string | undefined, defaultValue: number): number { + if (value === undefined) return defaultValue; + const parsed = parseFloat(value); + return Number.isNaN(parsed) ? defaultValue : parsed; +} + +export class ConfigManager { + static load(): AppConfig { + return { + server: this.loadServerConfig(), + state: this.loadStateConfig(), + security: this.loadSecurityConfig(), + logging: this.loadLoggingConfig(), + monitoring: this.loadMonitoringConfig(), + mcts: this.loadMctsConfig(), + }; + } + + private static loadServerConfig(): AppConfig['server'] { + return { + name: process.env.SERVER_NAME ?? 'sequential-thinking-server', + version: process.env.SERVER_VERSION ?? '1.0.0', + }; + } + + private static loadStateConfig(): AppConfig['state'] { + return { + maxHistorySize: parseIntOrDefault(process.env.MAX_HISTORY_SIZE, 1000), + maxBranchAge: parseIntOrDefault(process.env.MAX_BRANCH_AGE, 3600000), + maxThoughtLength: parseIntOrDefault(process.env.MAX_THOUGHT_LENGTH, 5000), + maxThoughtsPerBranch: parseIntOrDefault(process.env.MAX_THOUGHTS_PER_BRANCH, 100), + cleanupInterval: parseIntOrDefault(process.env.CLEANUP_INTERVAL, 300000), + }; + } + + private static loadSecurityConfig(): AppConfig['security'] { + return { + maxThoughtsPerMinute: parseIntOrDefault(process.env.MAX_THOUGHTS_PER_MIN, 60), + blockedPatterns: this.loadBlockedPatterns(), + }; + } + + private static loadLoggingConfig(): AppConfig['logging'] { + const validLevels: AppConfig['logging']['level'][] = ['debug', 'info', 'warn', 'error']; + const envLevel = process.env.LOG_LEVEL; + const level = envLevel && validLevels.includes(envLevel as AppConfig['logging']['level']) + ? (envLevel as AppConfig['logging']['level']) + : 'info'; + return { + level, + enableColors: process.env.ENABLE_COLORS !== 'false', + enableThoughtLogging: process.env.DISABLE_THOUGHT_LOGGING !== 'true', + }; + } + + private static loadMonitoringConfig(): AppConfig['monitoring'] { + return { + enableMetrics: process.env.ENABLE_METRICS !== 'false', + enableHealthChecks: process.env.ENABLE_HEALTH_CHECKS !== 'false', + healthThresholds: { + maxMemoryPercent: parseIntOrDefault(process.env.HEALTH_MAX_MEMORY, 90), + maxStoragePercent: parseIntOrDefault(process.env.HEALTH_MAX_STORAGE, 80), + maxResponseTimeMs: parseIntOrDefault(process.env.HEALTH_MAX_RESPONSE_TIME, 200), + errorRateDegraded: parseIntOrDefault(process.env.HEALTH_ERROR_RATE_DEGRADED, 2), + errorRateUnhealthy: parseIntOrDefault(process.env.HEALTH_ERROR_RATE_UNHEALTHY, 5), + }, + }; + } + + private static defaultBlockedPatterns(): RegExp[] { + return [ + /)<[^<]*)*<\/script>/i, + /javascript:/i, + /data:text\/html/i, + /eval\s*\(/i, + /function\s*\(/i, + /document\./i, + /window\./i, + /\.php/i, + /\.exe/i, + /\.bat/i, + /\.cmd/i, + ]; + } + + private static loadBlockedPatterns(): RegExp[] { + const patterns = process.env.BLOCKED_PATTERNS; + if (!patterns) { + return this.defaultBlockedPatterns(); + } + + try { + const patternStrings = patterns.split(',').map(p => p.trim()); + return patternStrings.map(pattern => new RegExp(pattern, 'i')); + } catch (error: unknown) { + console.warn('Invalid BLOCKED_PATTERNS, using defaults:', error); + return this.defaultBlockedPatterns(); + } + } + + static validate(config: AppConfig): void { + this.validateState(config.state); + this.validateSecurity(config.security); + this.validateMcts(config.mcts); + } + + private static validateState(state: AppConfig['state']): void { + if (state.maxHistorySize < 1 || state.maxHistorySize > 10000) { + throw new Error('MAX_HISTORY_SIZE must be between 1 and 10000'); + } + if (state.maxThoughtLength < 1 || state.maxThoughtLength > 100000) { + throw new Error('maxThoughtLength must be between 1 and 100000'); + } + if (state.maxBranchAge < 0) { + throw new Error('maxBranchAge must be >= 0'); + } + if (state.maxThoughtsPerBranch < 1 || state.maxThoughtsPerBranch > 10000) { + throw new Error('maxThoughtsPerBranch must be between 1 and 10000'); + } + if (state.cleanupInterval < 0) { + throw new Error('cleanupInterval must be >= 0'); + } + } + + private static validateSecurity(security: AppConfig['security']): void { + if (security.maxThoughtsPerMinute < 1 || security.maxThoughtsPerMinute > 1000) { + throw new Error('maxThoughtsPerMinute must be between 1 and 1000'); + } + } + + private static loadMctsConfig(): AppConfig['mcts'] { + return { + maxNodesPerTree: parseIntOrDefault(process.env.MCTS_MAX_NODES, 500), + maxTreeAge: parseIntOrDefault(process.env.MCTS_MAX_TREE_AGE, 3600000), + explorationConstant: parseFloatOrDefault(process.env.MCTS_EXPLORATION_CONSTANT, Math.SQRT2), + enableAutoTree: process.env.MCTS_DISABLE_AUTO_TREE !== 'true', + }; + } + + private static validateMcts(mcts: AppConfig['mcts']): void { + if (mcts.maxNodesPerTree < 1 || mcts.maxNodesPerTree > 100000) { + throw new Error('MCTS_MAX_NODES must be between 1 and 100000'); + } + if (mcts.maxTreeAge < 0) { + throw new Error('MCTS_MAX_TREE_AGE must be >= 0'); + } + if (mcts.explorationConstant < 0 || mcts.explorationConstant > 10) { + throw new Error('MCTS_EXPLORATION_CONSTANT must be between 0 and 10'); + } + } + + static getEnvironmentInfo(): EnvironmentInfo { + return { + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + pid: process.pid, + memoryUsage: process.memoryUsage(), + uptime: process.uptime(), + }; + } +} diff --git a/src/sequentialthinking/container.ts b/src/sequentialthinking/container.ts new file mode 100644 index 0000000000..748fa29baf --- /dev/null +++ b/src/sequentialthinking/container.ts @@ -0,0 +1,150 @@ +import type { + AppConfig, + ServiceContainer, + Logger, + ThoughtFormatter, + ThoughtStorage, + SecurityService, + MetricsCollector, + HealthChecker, +} from './interfaces.js'; + +// Import all required implementations +import { ConfigManager } from './config.js'; +import { StructuredLogger } from './logger.js'; +import { ConsoleThoughtFormatter } from './formatter.js'; +import { BoundedThoughtManager } from './state-manager.js'; +import { + SecureThoughtSecurity, + SecurityServiceConfigSchema, +} from './security-service.js'; +import { BasicMetricsCollector } from './metrics.js'; +import { ComprehensiveHealthChecker } from './health-checker.js'; +import { SessionTracker } from './session-tracker.js'; +import { ThoughtTreeManager } from './thought-tree-manager.js'; + +export class SimpleContainer implements ServiceContainer { + private readonly services = new Map unknown>(); + private readonly instances = new Map(); + private destroyed = false; + + register(key: string, factory: () => T): void { + this.services.set(key, factory); + // Clear any existing instance when re-registering + this.instances.delete(key); + } + + get(key: string): T { + if (this.instances.has(key)) { + return this.instances.get(key) as T; + } + + const factory = this.services.get(key); + if (!factory) { + throw new Error(`Service '${key}' not registered`); + } + + const instance = factory(); + this.instances.set(key, instance); + return instance as T; + } + + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + + // Cleanup all instances + for (const [key, instance] of this.instances.entries()) { + const obj = instance as Record; + if (obj && typeof obj.destroy === 'function') { + try { + (obj.destroy as () => void)(); + } catch (error) { + console.error(`Error destroying service '${key}':`, error); + } + } + } + this.instances.clear(); + this.services.clear(); + } +} + +export class SequentialThinkingApp { + private readonly container: ServiceContainer; + private readonly config: AppConfig; + private readonly sessionTracker: SessionTracker; + + constructor(config?: AppConfig) { + this.config = config ?? ConfigManager.load(); + ConfigManager.validate(this.config); + // Create session tracker once for all services + this.sessionTracker = new SessionTracker(this.config.state.cleanupInterval); + this.container = new SimpleContainer(); + this.registerServices(); + } + + private registerServices(): void { + this.container.register('config', () => this.config); + this.container.register('sessionTracker', () => this.sessionTracker); + this.container.register('logger', () => this.createLogger()); + this.container.register('formatter', () => this.createFormatter()); + this.container.register('storage', () => this.createStorage()); + this.container.register('security', () => this.createSecurity()); + this.container.register('metrics', () => this.createMetrics()); + this.container.register('healthChecker', () => this.createHealthChecker()); + this.container.register('thoughtTreeManager', () => + new ThoughtTreeManager(this.config.mcts)); + } + + private createLogger(): Logger { + return new StructuredLogger(this.config.logging); + } + + private createFormatter(): ThoughtFormatter { + return new ConsoleThoughtFormatter(this.config.logging.enableColors); + } + + private createStorage(): ThoughtStorage { + return new BoundedThoughtManager(this.config.state, this.sessionTracker); + } + + private createSecurity(): SecurityService { + return new SecureThoughtSecurity( + SecurityServiceConfigSchema.parse({ + ...this.config.security, + maxThoughtLength: this.config.state.maxThoughtLength, + blockedPatterns: this.config.security.blockedPatterns.map( + (p: RegExp) => p.source, + ), + }), + this.sessionTracker, + ); + } + + private createMetrics(): MetricsCollector { + const storage = this.container.get('storage'); + return new BasicMetricsCollector(this.sessionTracker, storage); + } + + private createHealthChecker(): HealthChecker { + const metrics = this.container.get('metrics'); + const storage = this.container.get('storage'); + const security = this.container.get('security'); + + return new ComprehensiveHealthChecker( + metrics, + storage, + security, + this.config.monitoring.healthThresholds, + ); + } + + getContainer(): ServiceContainer { + return this.container; + } + + destroy(): void { + this.sessionTracker.destroy(); + this.container.destroy(); + } +} diff --git a/src/sequentialthinking/error-handlers.ts b/src/sequentialthinking/error-handlers.ts new file mode 100644 index 0000000000..5768c715f2 --- /dev/null +++ b/src/sequentialthinking/error-handlers.ts @@ -0,0 +1,32 @@ +import { SequentialThinkingError } from './errors.js'; +import type { ProcessThoughtResponse } from './lib.js'; + +export class CompositeErrorHandler { + handle(error: Error): ProcessThoughtResponse { + if (error instanceof SequentialThinkingError) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify(error.toJSON(), null, 2), + }], + isError: true, + statusCode: error.statusCode, + }; + } + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + error: 'INTERNAL_ERROR', + message: 'An unexpected error occurred', + category: 'SYSTEM', + statusCode: 500, + timestamp: new Date().toISOString(), + }, null, 2), + }], + isError: true, + statusCode: 500, + }; + } +} diff --git a/src/sequentialthinking/errors.ts b/src/sequentialthinking/errors.ts new file mode 100644 index 0000000000..da9cdde152 --- /dev/null +++ b/src/sequentialthinking/errors.ts @@ -0,0 +1,65 @@ +type ErrorCategory = + | 'VALIDATION' + | 'SECURITY' + | 'BUSINESS_LOGIC' + | 'SYSTEM'; + +export abstract class SequentialThinkingError extends Error { + abstract readonly code: string; + abstract readonly statusCode: number; + abstract readonly category: ErrorCategory; + readonly timestamp = new Date().toISOString(); + + constructor( + message: string, + public readonly details?: unknown, + ) { + super(message); + this.name = this.constructor.name; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } + + toJSON(): Record { + return { + error: this.code, + message: this.message, + category: this.category, + statusCode: this.statusCode, + details: this.details, + timestamp: this.timestamp, + }; + } +} + +export class ValidationError extends SequentialThinkingError { + readonly code = 'VALIDATION_ERROR'; + readonly statusCode = 400; + readonly category = 'VALIDATION' as const; +} + +export class SecurityError extends SequentialThinkingError { + readonly code = 'SECURITY_ERROR'; + readonly statusCode = 403; + readonly category = 'SECURITY' as const; +} + +export class StateError extends SequentialThinkingError { + readonly code = 'STATE_ERROR'; + readonly statusCode = 500; + readonly category = 'SYSTEM' as const; +} + +export class BusinessLogicError extends SequentialThinkingError { + readonly code = 'BUSINESS_LOGIC_ERROR'; + readonly statusCode = 422; + readonly category = 'BUSINESS_LOGIC' as const; +} + +export class TreeError extends SequentialThinkingError { + readonly code = 'TREE_ERROR'; + readonly statusCode = 404; + readonly category = 'BUSINESS_LOGIC' as const; +} diff --git a/src/sequentialthinking/formatter.ts b/src/sequentialthinking/formatter.ts new file mode 100644 index 0000000000..036f0fb8af --- /dev/null +++ b/src/sequentialthinking/formatter.ts @@ -0,0 +1,72 @@ +import type { ThoughtFormatter, ThoughtData } from './interfaces.js'; +import chalk from 'chalk'; + +export class ConsoleThoughtFormatter implements ThoughtFormatter { + constructor(private readonly useColors: boolean = true) {} + + private getHeaderParts(thought: ThoughtData): { prefix: string; context: string } { + const { isRevision, revisesThought, branchFromThought, branchId } = thought; + + if (isRevision) { + return { + prefix: '[Revision]', + context: ` (revising thought ${revisesThought ?? '?'})`, + }; + } else if (branchFromThought) { + return { + prefix: '[Branch]', + context: ` (from thought ${branchFromThought}, ID: ${branchId ?? 'unknown'})`, + }; + } + return { prefix: '[Thought]', context: '' }; + } + + formatHeader(thought: ThoughtData): string { + const { prefix, context } = this.getHeaderParts(thought); + let coloredPrefix = prefix; + if (this.useColors) { + if (thought.isRevision) coloredPrefix = chalk.yellow(prefix); + else if (thought.branchFromThought) coloredPrefix = chalk.green(prefix); + else coloredPrefix = chalk.blue(prefix); + } + return `${coloredPrefix} ${thought.thoughtNumber}/${thought.totalThoughts}${context}`; + } + + formatBody(thought: ThoughtData): string { + return thought.thought; + } + + format(thought: ThoughtData): string { + const headerPlain = this.formatHeaderPlain(thought); + const body = this.formatBody(thought); + + // Calculate border length based on plain text content (no ANSI codes) + const bodyLines = body.split('\n'); + const maxLength = Math.max(headerPlain.length, ...bodyLines.map(l => l.length)); + const border = '─'.repeat(maxLength + 4); + + if (this.useColors) { + const header = this.formatHeader(thought); + const coloredBorder = chalk.gray(border); + + return ` +${chalk.gray('┌')}${coloredBorder}${chalk.gray('┐')} +${chalk.gray('│')} ${chalk.cyan(header)} ${chalk.gray('│')} +${chalk.gray('├')}${coloredBorder}${chalk.gray('┤')} +${chalk.gray('│')} ${body.padEnd(maxLength)} ${chalk.gray('│')} +${chalk.gray('└')}${coloredBorder}${chalk.gray('┘')}`.trim(); + } else { + return ` +┌${border}┐ +│ ${headerPlain} │ +├${border}┤ +│ ${body.padEnd(maxLength)} │ +└${border}┘`.trim(); + } + } + + private formatHeaderPlain(thought: ThoughtData): string { + const { prefix, context } = this.getHeaderParts(thought); + return `${prefix} ${thought.thoughtNumber}/${thought.totalThoughts}${context}`; + } +} diff --git a/src/sequentialthinking/health-checker.ts b/src/sequentialthinking/health-checker.ts new file mode 100644 index 0000000000..dc9780e0b9 --- /dev/null +++ b/src/sequentialthinking/health-checker.ts @@ -0,0 +1,323 @@ +import type { + AppConfig, + HealthChecker, + HealthCheckResult, + HealthStatus, + MetricsCollector, + RequestMetrics, + ThoughtStorage, + SecurityService, +} from './interfaces.js'; + +function createFallbackCheck(): HealthCheckResult { + return { + status: 'unhealthy', + message: 'Check failed', + responseTime: 0, + timestamp: new Date(), + }; +} + +function unwrapSettled( + result: PromiseSettledResult, +): HealthCheckResult { + if (result.status === 'fulfilled') { + return result.value; + } + return createFallbackCheck(); +} + +export class ComprehensiveHealthChecker implements HealthChecker { + private readonly maxMemoryUsage: number; + private readonly maxStorageUsage: number; + private readonly maxResponseTime: number; + private readonly errorRateDegraded: number; + private readonly errorRateUnhealthy: number; + + constructor( + private readonly metrics: MetricsCollector, + private readonly storage: ThoughtStorage, + private readonly security: SecurityService, + thresholds?: AppConfig['monitoring']['healthThresholds'], + ) { + this.maxMemoryUsage = thresholds?.maxMemoryPercent ?? 90; + this.maxStorageUsage = thresholds?.maxStoragePercent ?? 80; + this.maxResponseTime = thresholds?.maxResponseTimeMs ?? 200; + this.errorRateDegraded = thresholds?.errorRateDegraded ?? 2; + this.errorRateUnhealthy = thresholds?.errorRateUnhealthy ?? 5; + } + + private getRequestMetrics(): RequestMetrics { + return this.metrics.getMetrics().requests; + } + + async checkHealth(): Promise { + try { + const settled = await Promise.allSettled([ + this.checkMemory(), + this.checkResponseTime(), + this.checkErrorRate(), + this.checkStorage(), + this.checkSecurity(), + ]); + + const [ + memoryResult, + responseTimeResult, + errorRateResult, + storageResult, + securityResult, + ] = settled.map(unwrapSettled); + + const statuses = [ + memoryResult, + responseTimeResult, + errorRateResult, + storageResult, + securityResult, + ].map((r) => r.status); + + const hasUnhealthy = statuses.includes('unhealthy'); + const hasDegraded = statuses.includes('degraded'); + + return { + status: hasUnhealthy + ? ('unhealthy' as const) + : hasDegraded + ? ('degraded' as const) + : ('healthy' as const), + checks: { + memory: memoryResult, + responseTime: responseTimeResult, + errorRate: errorRateResult, + storage: storageResult, + security: securityResult, + }, + summary: `Health check completed at ${new Date().toISOString()}`, + uptime: process.uptime(), + timestamp: new Date(), + }; + } catch { + const fallback = createFallbackCheck(); + return { + status: 'unhealthy', + checks: { + memory: fallback, + responseTime: { ...fallback }, + errorRate: { ...fallback }, + storage: { ...fallback }, + security: { ...fallback }, + }, + summary: 'Health check failed', + uptime: process.uptime(), + timestamp: new Date(), + }; + } + } + + private makeResult( + status: 'healthy' | 'unhealthy' | 'degraded', + message: string, + startTime: number, + details?: unknown, + ): HealthCheckResult { + return { + status, + message, + details, + responseTime: Date.now() - startTime, + timestamp: new Date(), + }; + } + + private async checkMemory(): Promise { + const startTime = Date.now(); + + try { + const memoryUsage = process.memoryUsage(); + const heapUsedPercent = + (memoryUsage.heapUsed / memoryUsage.heapTotal) * 100; + + const memoryData = { + heapUsed: Math.round(heapUsedPercent), + heapTotal: Math.round(memoryUsage.heapTotal), + external: Math.round(memoryUsage.external), + rss: Math.round(memoryUsage.rss), + }; + + if (heapUsedPercent > this.maxMemoryUsage) { + return this.makeResult( + 'unhealthy', + `Memory usage too high: ${heapUsedPercent.toFixed(1)}%`, + startTime, + memoryData, + ); + } else if (heapUsedPercent > this.maxMemoryUsage * 0.8) { + return this.makeResult( + 'degraded', + `Memory usage elevated: ${heapUsedPercent.toFixed(1)}%`, + startTime, + memoryData, + ); + } + return this.makeResult( + 'healthy', + `Memory usage normal: ${heapUsedPercent.toFixed(1)}%`, + startTime, + memoryData, + ); + } catch { + return this.makeResult('unhealthy', 'Memory check failed', startTime); + } + } + + private async checkResponseTime(): Promise { + const startTime = Date.now(); + + try { + const requests = this.getRequestMetrics(); + const avgResponseTime = requests.averageResponseTime; + + const responseTimeData = { + avgResponseTime: Math.round(avgResponseTime), + requestCount: requests.totalRequests, + }; + + if (avgResponseTime > this.maxResponseTime) { + return this.makeResult( + 'unhealthy', + `Response time too high: ${avgResponseTime.toFixed(0)}ms`, + startTime, + responseTimeData, + ); + } else if (avgResponseTime > this.maxResponseTime * 0.8) { + return this.makeResult( + 'degraded', + `Response time elevated: ${avgResponseTime.toFixed(0)}ms`, + startTime, + responseTimeData, + ); + } + return this.makeResult( + 'healthy', + `Response time normal: ${avgResponseTime.toFixed(0)}ms`, + startTime, + responseTimeData, + ); + } catch { + return this.makeResult( + 'unhealthy', + 'Response time check failed', + startTime, + ); + } + } + + private async checkErrorRate(): Promise { + const startTime = Date.now(); + + try { + const requests = this.getRequestMetrics(); + const { totalRequests, failedRequests } = requests; + + const errorRate = + totalRequests > 0 + ? Math.min((failedRequests / totalRequests) * 100, 100) + : 0; + + if (errorRate > this.errorRateUnhealthy) { + return this.makeResult( + 'unhealthy', + `Error rate: ${errorRate.toFixed(1)}%`, + startTime, + { totalRequests, failedRequests, errorRate }, + ); + } else if (errorRate > this.errorRateDegraded) { + return this.makeResult( + 'degraded', + `Error rate: ${errorRate.toFixed(1)}%`, + startTime, + { totalRequests, failedRequests, errorRate }, + ); + } + return this.makeResult( + 'healthy', + `Error rate: ${errorRate.toFixed(1)}%`, + startTime, + { totalRequests, failedRequests, errorRate }, + ); + } catch { + return this.makeResult( + 'unhealthy', + 'Error rate check failed', + startTime, + ); + } + } + + private async checkStorage(): Promise { + const startTime = Date.now(); + + try { + const stats = this.storage.getStats(); + const usagePercent = stats.historyCapacity > 0 + ? (stats.historySize / stats.historyCapacity) * 100 + : 0; + + const storageData = { + historySize: stats.historySize, + historyCapacity: stats.historyCapacity, + usagePercent: Math.round(usagePercent), + }; + + if (usagePercent > this.maxStorageUsage) { + return this.makeResult( + 'unhealthy', + `Storage usage too high: ${usagePercent.toFixed(1)}%`, + startTime, + storageData, + ); + } else if (usagePercent > this.maxStorageUsage * 0.8) { + return this.makeResult( + 'degraded', + `Storage usage elevated: ${usagePercent.toFixed(1)}%`, + startTime, + storageData, + ); + } + return this.makeResult( + 'healthy', + `Storage usage normal: ${usagePercent.toFixed(1)}%`, + startTime, + storageData, + ); + } catch { + return this.makeResult( + 'unhealthy', + 'Storage check failed', + startTime, + ); + } + } + + private async checkSecurity(): Promise { + const startTime = Date.now(); + + try { + const securityStatus = this.security.getSecurityStatus(); + + return this.makeResult( + 'healthy', + 'Security systems operational', + startTime, + securityStatus, + ); + } catch { + return this.makeResult( + 'unhealthy', + 'Security check failed', + startTime, + ); + } + } +} diff --git a/src/sequentialthinking/index.ts b/src/sequentialthinking/index.ts index 809086a94c..d64281c8e5 100644 --- a/src/sequentialthinking/index.ts +++ b/src/sequentialthinking/index.ts @@ -1,21 +1,34 @@ #!/usr/bin/env node -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod"; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; +import type { ProcessThoughtRequest } from './lib.js'; import { SequentialThinkingServer } from './lib.js'; +import type { AppConfig } from './interfaces.js'; +import { ConfigManager } from './config.js'; + +// Load configuration +let config: AppConfig; +try { + config = ConfigManager.load(); +} catch (error) { + console.error('Failed to load configuration:', error); + process.exit(1); +} const server = new McpServer({ - name: "sequential-thinking-server", - version: "0.2.0", + name: config.server.name, + version: config.server.version, }); const thinkingServer = new SequentialThinkingServer(); +// Register the main sequential thinking tool server.registerTool( - "sequentialthinking", + 'sequentialthinking', { - title: "Sequential Thinking", + title: 'Sequential Thinking', description: `A detailed tool for dynamic and reflective problem-solving through thoughts. This tool helps analyze problems through a flexible thinking process that can adapt and evolve. Each thought can build on, question, or revise previous insights as understanding deepens. @@ -39,6 +52,7 @@ Key features: - Verifies the hypothesis based on the Chain of Thought steps - Repeats the process until satisfied - Provides a correct answer +- Enhanced with security controls, rate limiting, and bounded memory management Parameters explained: - thought: Your current thinking step, which can include: @@ -69,50 +83,285 @@ You should: 8. Verify the hypothesis based on the Chain of Thought steps 9. Repeat the process until satisfied with the solution 10. Provide a single, ideally correct answer as the final output -11. Only set nextThoughtNeeded to false when truly done and a satisfactory answer is reached`, +11. Only set nextThoughtNeeded to false when truly done and a satisfactory answer is reached + +Security Notes: +- All thoughts are validated and sanitized +- Rate limiting is enforced per session +- Maximum thought length and history size are enforced +- Malicious content is automatically filtered`, inputSchema: { - thought: z.string().describe("Your current thinking step"), - nextThoughtNeeded: z.boolean().describe("Whether another thought step is needed"), - thoughtNumber: z.number().int().min(1).describe("Current thought number (numeric value, e.g., 1, 2, 3)"), - totalThoughts: z.number().int().min(1).describe("Estimated total thoughts needed (numeric value, e.g., 5, 10)"), - isRevision: z.boolean().optional().describe("Whether this revises previous thinking"), - revisesThought: z.number().int().min(1).optional().describe("Which thought is being reconsidered"), - branchFromThought: z.number().int().min(1).optional().describe("Branching point thought number"), - branchId: z.string().optional().describe("Branch identifier"), - needsMoreThoughts: z.boolean().optional().describe("If more thoughts are needed") - }, - outputSchema: { - thoughtNumber: z.number(), - totalThoughts: z.number(), - nextThoughtNeeded: z.boolean(), - branches: z.array(z.string()), - thoughtHistoryLength: z.number() + thought: z.string().describe('Your current thinking step'), + nextThoughtNeeded: z.boolean().describe('Whether another thought step is needed'), + thoughtNumber: z.number().int().min(1).describe('Current thought number (numeric value, e.g., 1, 2, 3)'), + totalThoughts: z.number().int().min(1).describe('Estimated total thoughts needed (numeric value, e.g., 5, 10)'), + isRevision: z.boolean().optional().describe('Whether this revises previous thinking'), + revisesThought: z.number().int().min(1).optional().describe('Which thought is being reconsidered'), + branchFromThought: z.number().int().min(1).optional().describe('Branching point thought number'), + branchId: z.string().optional().describe('Branch identifier'), + needsMoreThoughts: z.boolean().optional().describe('If more thoughts are needed'), + sessionId: z.string().optional().describe('Session identifier for tracking'), + thinkingMode: z.enum(['fast', 'expert', 'deep']).optional().describe('Set thinking mode on first thought: fast (3-5 linear steps), expert (balanced branching), deep (exhaustive exploration)'), }, }, async (args) => { - const result = thinkingServer.processThought(args); + const result = await thinkingServer.processThought(args as ProcessThoughtRequest); - if (result.isError) { - return result; + if (result.isError === true || result.content.length === 0) { + return { + content: result.content, + isError: true, + }; } - // Parse the JSON response to get structured content - const parsedContent = JSON.parse(result.content[0].text); + return { content: result.content }; + }, +); + +// Register the thought history retrieval tool +server.registerTool( + 'get_thought_history', + { + title: 'Get Thought History', + description: 'Retrieve past thoughts from a session. Use this to review thinking history, examine branch contents, or recall earlier reasoning steps.', + inputSchema: { + sessionId: z.string().describe('Session identifier to retrieve thoughts for'), + branchId: z.string().optional().describe('Optional branch identifier to filter thoughts by branch'), + limit: z.number().int().min(1).optional().describe('Maximum number of thoughts to return (most recent first)'), + }, + }, + async (args) => { + const thoughts = thinkingServer.getFilteredHistory({ + sessionId: args.sessionId, + branchId: args.branchId, + limit: args.limit, + }); return { - content: result.content, - structuredContent: parsedContent + content: [{ + type: 'text' as const, + text: JSON.stringify({ + sessionId: args.sessionId, + branchId: args.branchId ?? null, + count: thoughts.length, + thoughts: thoughts.map((t) => ({ + thoughtNumber: t.thoughtNumber, + totalThoughts: t.totalThoughts, + thought: t.thought, + nextThoughtNeeded: t.nextThoughtNeeded, + isRevision: t.isRevision ?? false, + revisesThought: t.revisesThought ?? null, + branchId: t.branchId ?? null, + branchFromThought: t.branchFromThought ?? null, + timestamp: t.timestamp, + })), + }, null, 2), + }], }; - } + }, +); + +// Add health check tool for monitoring +server.registerTool( + 'health_check', + { + title: 'Health Check', + description: 'Check the health and status of the Sequential Thinking server', + inputSchema: {}, + }, + async () => { + try { + const healthStatus = await thinkingServer.getHealthStatus(); + return { + content: [{ + type: 'text', + text: JSON.stringify(healthStatus, null, 2), + }], + }; + } catch (error) { + return { + content: [{ + type: 'text', + text: JSON.stringify({ + status: 'unhealthy', + summary: 'Health check failed', + error: error instanceof Error ? error.message : String(error), + timestamp: new Date(), + }, null, 2), + }], + isError: true, + }; + } + }, +); + +// Add metrics tool for monitoring +server.registerTool( + 'metrics', + { + title: 'Server Metrics', + description: 'Get detailed metrics and statistics about the server', + inputSchema: {}, + }, + async () => { + try { + const metrics = thinkingServer.getMetrics(); + return { + content: [{ + type: 'text', + text: JSON.stringify(metrics, null, 2), + }], + }; + } catch (error) { + return { + content: [{ + type: 'text', + text: JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + timestamp: new Date(), + }, null, 2), + }], + isError: true, + }; + } + }, +); + +// Register thinking mode tool +server.registerTool( + 'set_thinking_mode', + { + title: 'Set Thinking Mode', + description: `Set a thinking mode for a session to shape exploration behavior. Modes: +- fast: Linear, exploit-focused. 3-5 steps, no branching, auto-evaluation. +- expert: Balanced exploration with targeted branching, backtracking on low scores, convergence at 0.7. +- deep: Exhaustive exploration. Wide branching (up to 5), aggressive backtracking, convergence at 0.85. + +Once set, each processThought response includes modeGuidance with recommended actions.`, + inputSchema: { + sessionId: z.string().describe('Session identifier'), + mode: z.enum(['fast', 'expert', 'deep']).describe('Thinking mode to activate'), + }, + }, + async (args) => { + const result = await thinkingServer.setThinkingMode(args.sessionId, args.mode); + if (result.isError === true) { + return { content: result.content, isError: true }; + } + return { content: result.content }; + }, ); -async function runServer() { +// Register MCTS tree exploration tools +server.registerTool( + 'backtrack', + { + title: 'Backtrack', + description: 'Move the thought tree cursor back to a previous node, allowing exploration of alternative paths from that point. Returns the node info, its children, and tree statistics.', + inputSchema: { + sessionId: z.string().describe('Session identifier'), + nodeId: z.string().describe('The node ID to backtrack to'), + }, + }, + async (args) => { + const result = await thinkingServer.backtrack(args.sessionId, args.nodeId); + if (result.isError === true) { + return { content: result.content, isError: true }; + } + return { content: result.content }; + }, +); + +server.registerTool( + 'evaluate_thought', + { + title: 'Evaluate Thought', + description: 'Score a thought node with a value between 0 and 1. The value is backpropagated up the tree to all ancestors, updating their visit counts and total values. This drives the MCTS selection process.', + inputSchema: { + sessionId: z.string().describe('Session identifier'), + nodeId: z.string().describe('The node ID to evaluate'), + value: z.number().min(0).max(1).describe('Evaluation score between 0 (poor) and 1 (excellent)'), + }, + }, + async (args) => { + const result = await thinkingServer.evaluateThought(args.sessionId, args.nodeId, args.value); + if (result.isError === true) { + return { content: result.content, isError: true }; + } + return { content: result.content }; + }, +); + +server.registerTool( + 'suggest_next_thought', + { + title: 'Suggest Next Thought', + description: 'Use UCB1-based selection to suggest the most promising node to explore next. Strategies: "explore" favors unvisited nodes, "exploit" favors high-value nodes, "balanced" (default) balances both.', + inputSchema: { + sessionId: z.string().describe('Session identifier'), + strategy: z.enum(['explore', 'exploit', 'balanced']).optional().describe('Selection strategy (default: balanced)'), + }, + }, + async (args) => { + const result = await thinkingServer.suggestNextThought(args.sessionId, args.strategy); + if (result.isError === true) { + return { content: result.content, isError: true }; + } + return { content: result.content }; + }, +); + +server.registerTool( + 'get_thinking_summary', + { + title: 'Get Thinking Summary', + description: 'Get a comprehensive summary of the thought tree including the best reasoning path (highest average value), full tree structure, and statistics.', + inputSchema: { + sessionId: z.string().describe('Session identifier'), + maxDepth: z.number().int().min(0).optional().describe('Maximum depth to include in tree structure (omit for full tree)'), + }, + }, + async (args) => { + const result = await thinkingServer.getThinkingSummary(args.sessionId, args.maxDepth); + if (result.isError === true) { + return { content: result.content, isError: true }; + } + return { content: result.content }; + }, +); + +// Setup graceful shutdown +process.on('SIGINT', () => { + console.error('Received SIGINT, shutting down gracefully...'); + thinkingServer.destroy(); + process.exit(0); +}); + +process.on('SIGTERM', () => { + console.error('Received SIGTERM, shutting down gracefully...'); + thinkingServer.destroy(); + process.exit(0); +}); + +async function runServer(): Promise { const transport = new StdioServerTransport(); await server.connect(transport); - console.error("Sequential Thinking MCP Server running on stdio"); + + const envInfo = ConfigManager.getEnvironmentInfo(); + console.error(`Sequential Thinking MCP Server ${config.server.version} running on stdio`); + console.error(`Node.js ${envInfo.nodeVersion} on ${envInfo.platform}-${envInfo.arch} (PID: ${envInfo.pid})`); + console.error(`Configuration: Max thoughts=${config.state.maxHistorySize}, Rate limit=${config.security.maxThoughtsPerMinute}/min`); + + if (config.monitoring.enableMetrics) { + console.error('Metrics collection enabled'); + } + if (config.monitoring.enableHealthChecks) { + console.error('Health checks enabled'); + } } runServer().catch((error) => { - console.error("Fatal error running server:", error); + console.error('Fatal error running server:', error); + thinkingServer.destroy(); process.exit(1); }); diff --git a/src/sequentialthinking/interfaces.ts b/src/sequentialthinking/interfaces.ts new file mode 100644 index 0000000000..ee392774c1 --- /dev/null +++ b/src/sequentialthinking/interfaces.ts @@ -0,0 +1,229 @@ +import type { ThoughtData } from './circular-buffer.js'; +export type { ThinkingMode, ThinkingModeConfig, ModeGuidance } from './thinking-modes.js'; + +export type { ThoughtData }; + +export interface ThoughtFormatter { + format(thought: ThoughtData): string; +} + +export interface StorageStats { + historySize: number; + historyCapacity: number; + branchCount: number; + sessionCount: number; +} + +export interface ThoughtStorage { + addThought(thought: ThoughtData): void; + getHistory(limit?: number): ThoughtData[]; + getBranches(): string[]; + getBranchThoughts(branchId: string): ThoughtData[]; + getStats(): StorageStats; + destroy(): void; +} + +export interface Logger { + info(message: string, meta?: Record): void; + error(message: string, error?: Error | unknown): void; + debug(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; + logThought(sessionId: string, thought: ThoughtData): void; +} + +export interface SecurityService { + validateThought( + thought: string, + sessionId: string, + ): void; + sanitizeContent(content: string): string; + getSecurityStatus( + sessionId?: string, + ): Record; + generateSessionId(): string; + validateSession(sessionId: string): boolean; +} + +export interface RequestMetrics { + totalRequests: number; + successfulRequests: number; + failedRequests: number; + averageResponseTime: number; + lastRequestTime: Date | null; + requestsPerMinute: number; +} + +export interface ThoughtMetrics { + totalThoughts: number; + averageThoughtLength: number; + thoughtsPerMinute: number; + revisionCount: number; + branchCount: number; + activeSessions: number; +} + +export interface SystemMetrics { + memoryUsage: NodeJS.MemoryUsage; + cpuUsage: NodeJS.CpuUsage; + uptime: number; + timestamp: Date; +} + +export interface MetricsCollector { + recordRequest(duration: number, success: boolean): void; + recordError(error: Error): void; + recordThoughtProcessed(thought: ThoughtData): void; + getMetrics(): { requests: RequestMetrics; thoughts: ThoughtMetrics; system: SystemMetrics }; + destroy(): void; +} + +export interface HealthCheckResult { + status: 'healthy' | 'unhealthy' | 'degraded'; + message: string; + details?: unknown; + responseTime: number; + timestamp: Date; +} + +export interface HealthStatus { + status: 'healthy' | 'unhealthy' | 'degraded'; + checks: { + memory: HealthCheckResult; + responseTime: HealthCheckResult; + errorRate: HealthCheckResult; + storage: HealthCheckResult; + security: HealthCheckResult; + }; + summary: string; + uptime: number; + timestamp: Date; +} + +export interface HealthChecker { + checkHealth(): Promise; +} + +export interface ServiceContainer { + register(key: string, factory: () => T): void; + get(key: string): T; + destroy(): void; +} + +export interface MCTSConfig { + maxNodesPerTree: number; + maxTreeAge: number; + explorationConstant: number; + enableAutoTree: boolean; +} + +export interface TreeStats { + totalNodes: number; + maxDepth: number; + unexploredCount: number; + averageValue: number; + terminalCount: number; +} + +export interface TreeNodeInfo { + nodeId: string; + thoughtNumber: number; + thought: string; + depth: number; + visitCount: number; + averageValue: number; + childCount: number; + isTerminal: boolean; +} + +export interface BacktrackResult { + node: TreeNodeInfo; + children: TreeNodeInfo[]; + treeStats: TreeStats; +} + +export interface EvaluateResult { + nodeId: string; + newVisitCount: number; + newAverageValue: number; + nodesUpdated: number; + treeStats: TreeStats; +} + +export interface SuggestResult { + suggestion: { + nodeId: string; + thoughtNumber: number; + thought: string; + ucb1Score: number; + reason: string; + } | null; + alternatives: Array<{ + nodeId: string; + thoughtNumber: number; + ucb1Score: number; + }>; + treeStats: TreeStats; +} + +export interface ThinkingSummary { + bestPath: TreeNodeInfo[]; + treeStructure: unknown; + treeStats: TreeStats; +} + +export interface ThoughtTreeRecordResult { + nodeId: string; + parentNodeId: string | null; + treeStats: TreeStats; + modeGuidance?: import('./thinking-modes.js').ModeGuidance; +} + +export interface ThoughtTreeService { + recordThought(data: ThoughtData): ThoughtTreeRecordResult | null; + backtrack(sessionId: string, nodeId: string): BacktrackResult; + setMode(sessionId: string, mode: import('./thinking-modes.js').ThinkingMode): import('./thinking-modes.js').ThinkingModeConfig; + getMode(sessionId: string): import('./thinking-modes.js').ThinkingModeConfig | null; + cleanup(): void; + destroy(): void; +} + +export interface MCTSService { + evaluate(sessionId: string, nodeId: string, value: number): EvaluateResult; + suggest(sessionId: string, strategy?: 'explore' | 'exploit' | 'balanced'): SuggestResult; + getSummary(sessionId: string, maxDepth?: number): ThinkingSummary; +} + +export interface AppConfig { + server: { + name: string; + version: string; + }; + state: { + maxHistorySize: number; + maxBranchAge: number; + maxThoughtLength: number; + maxThoughtsPerBranch: number; + cleanupInterval: number; + }; + security: { + maxThoughtsPerMinute: number; + blockedPatterns: RegExp[]; + }; + logging: { + level: 'debug' | 'info' | 'warn' | 'error'; + enableColors: boolean; + enableThoughtLogging: boolean; + }; + monitoring: { + enableMetrics: boolean; + enableHealthChecks: boolean; + healthThresholds: { + maxMemoryPercent: number; + maxStoragePercent: number; + maxResponseTimeMs: number; + errorRateDegraded: number; + errorRateUnhealthy: number; + }; + }; + mcts: MCTSConfig; +} diff --git a/src/sequentialthinking/lib.ts b/src/sequentialthinking/lib.ts index 31a1098644..f78951b840 100644 --- a/src/sequentialthinking/lib.ts +++ b/src/sequentialthinking/lib.ts @@ -1,99 +1,444 @@ -import chalk from 'chalk'; - -export interface ThoughtData { - thought: string; - thoughtNumber: number; - totalThoughts: number; - isRevision?: boolean; - revisesThought?: number; - branchFromThought?: number; - branchId?: string; - needsMoreThoughts?: boolean; - nextThoughtNeeded: boolean; +import type { ThoughtData } from './circular-buffer.js'; +import { SequentialThinkingApp } from './container.js'; +import { CompositeErrorHandler } from './error-handlers.js'; +import { ValidationError, SecurityError, BusinessLogicError, TreeError } from './errors.js'; +import type { Logger, ThoughtStorage, SecurityService, ThoughtFormatter, MetricsCollector, HealthChecker, HealthStatus, RequestMetrics, ThoughtMetrics, SystemMetrics, AppConfig, ThoughtTreeService, MCTSService, ThinkingMode } from './interfaces.js'; + +export type ProcessThoughtRequest = ThoughtData; + +export interface ProcessThoughtResponse { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; + statusCode?: number; } export class SequentialThinkingServer { - private thoughtHistory: ThoughtData[] = []; - private branches: Record = {}; - private disableThoughtLogging: boolean; + private readonly app: SequentialThinkingApp; + private readonly errorHandler: CompositeErrorHandler; constructor() { - this.disableThoughtLogging = (process.env.DISABLE_THOUGHT_LOGGING || "").toLowerCase() === "true"; + this.app = new SequentialThinkingApp(); + this.errorHandler = new CompositeErrorHandler(); } - private formatThought(thoughtData: ThoughtData): string { - const { thoughtNumber, totalThoughts, thought, isRevision, revisesThought, branchFromThought, branchId } = thoughtData; + private validateInput( + input: ProcessThoughtRequest, + ): void { + const config = this.app.getContainer().get('config'); + this.validateStructure(input, config.state.maxThoughtLength); + this.validateBusinessLogic(input); + } - let prefix = ''; - let context = ''; + private static isPositiveInteger(value: unknown): value is number { + return typeof value === 'number' && value >= 1 && Number.isInteger(value); + } - if (isRevision) { - prefix = chalk.yellow('🔄 Revision'); - context = ` (revising thought ${revisesThought})`; - } else if (branchFromThought) { - prefix = chalk.green('🌿 Branch'); - context = ` (from thought ${branchFromThought}, ID: ${branchId})`; - } else { - prefix = chalk.blue('💭 Thought'); - context = ''; + private validateStructure(input: ProcessThoughtRequest, maxThoughtLength: number): void { + if (!input.thought || typeof input.thought !== 'string' || input.thought.trim().length === 0) { + throw new ValidationError( + 'Thought is required and must be a non-empty string', + ); + } + // Unified length validation - single source of truth + if (input.thought.length > maxThoughtLength) { + throw new ValidationError( + `Thought exceeds maximum length of ${maxThoughtLength} characters (actual: ${input.thought.length})`, + ); + } + if (!SequentialThinkingServer.isPositiveInteger(input.thoughtNumber)) { + throw new ValidationError( + 'thoughtNumber must be a positive integer', + ); + } + if (!SequentialThinkingServer.isPositiveInteger(input.totalThoughts)) { + throw new ValidationError( + 'totalThoughts must be a positive integer', + ); + } + if (typeof input.nextThoughtNeeded !== 'boolean') { + throw new ValidationError( + 'nextThoughtNeeded must be a boolean', + ); } + } - const header = `${prefix} ${thoughtNumber}/${totalThoughts}${context}`; - const border = '─'.repeat(Math.max(header.length, thought.length) + 4); + private validateBusinessLogic( + input: ProcessThoughtRequest, + ): void { + if (input.isRevision && !input.revisesThought) { + throw new BusinessLogicError( + 'isRevision requires revisesThought to be specified', + ); + } + if (input.branchFromThought && !input.branchId) { + throw new BusinessLogicError( + 'branchFromThought requires branchId to be specified', + ); + } + } + + private buildThoughtData( + input: ProcessThoughtRequest, + sanitizedThought: string, + sessionId: string, + ): ThoughtData { + const thoughtData: ThoughtData = { + ...input, + thought: sanitizedThought, + sessionId, + timestamp: Date.now(), + }; + if (thoughtData.thoughtNumber > thoughtData.totalThoughts) { + thoughtData.totalThoughts = thoughtData.thoughtNumber; + } + return thoughtData; + } - return ` -┌${border}┐ -│ ${header} │ -├${border}┤ -│ ${thought.padEnd(border.length - 2)} │ -└${border}┘`; + private getServices(): { + logger: Logger; + storage: ThoughtStorage; + security: SecurityService; + formatter: ThoughtFormatter; + metrics: MetricsCollector; + config: AppConfig; + thoughtTreeManager: ThoughtTreeService & MCTSService; + } { + const container = this.app.getContainer(); + return { + logger: container.get('logger'), + storage: container.get('storage'), + security: container.get('security'), + formatter: container.get('formatter'), + metrics: container.get('metrics'), + config: container.get('config'), + thoughtTreeManager: container.get('thoughtTreeManager'), + }; } - public processThought(input: ThoughtData): { content: Array<{ type: "text"; text: string }>; isError?: boolean } { + private resolveSession( + sessionId: string | undefined, + security: SecurityService, + ): string { + // If user provided a sessionId, validate it first + if (sessionId !== undefined && sessionId !== null) { + if (!security.validateSession(sessionId)) { + throw new SecurityError( + `Invalid session ID format: must be 1-100 characters (got ${sessionId.length})`, + ); + } + return sessionId; + } + + // No sessionId provided: generate a new one + const generated = security.generateSessionId(); + if (!security.validateSession(generated)) { + throw new SecurityError('Failed to generate valid session ID'); + } + return generated; + } + + private async processWithServices( + input: ProcessThoughtRequest, + ): Promise { + const { logger, storage, security, formatter, metrics, config, thoughtTreeManager } = + this.getServices(); + const startTime = Date.now(); + try { - // Validation happens at the tool registration layer via Zod - // Adjust totalThoughts if thoughtNumber exceeds it - if (input.thoughtNumber > input.totalThoughts) { - input.totalThoughts = input.thoughtNumber; + const sessionId = this.resolveSession( + input.sessionId, security, + ); + // Sanitize content first to remove harmful patterns + const sanitized = security.sanitizeContent(input.thought); + // Then validate the sanitized content (checks rate limiting, blocked patterns on clean text) + security.validateThought(sanitized, sessionId); + const thoughtData = this.buildThoughtData( + input, sanitized, sessionId, + ); + + // Auto-set thinking mode if provided on input + const thinkingMode = (input as unknown as Record).thinkingMode as string | undefined; + if (thinkingMode && thoughtData.thoughtNumber === 1) { + const validModes = ['fast', 'expert', 'deep']; + if (validModes.includes(thinkingMode)) { + thoughtTreeManager.setMode(sessionId, thinkingMode as ThinkingMode); + } } - this.thoughtHistory.push(input); + storage.addThought(thoughtData); + const treeResult = thoughtTreeManager.recordThought(thoughtData); + const stats = storage.getStats(); + + const responseData: Record = { + thoughtNumber: thoughtData.thoughtNumber, + totalThoughts: thoughtData.totalThoughts, + nextThoughtNeeded: thoughtData.nextThoughtNeeded, + branches: storage.getBranches(), + thoughtHistoryLength: stats.historySize, + sessionId, + timestamp: thoughtData.timestamp, + }; - if (input.branchFromThought && input.branchId) { - if (!this.branches[input.branchId]) { - this.branches[input.branchId] = []; + if (treeResult) { + responseData.nodeId = treeResult.nodeId; + responseData.parentNodeId = treeResult.parentNodeId; + responseData.treeStats = treeResult.treeStats; + if (treeResult.modeGuidance) { + responseData.modeGuidance = treeResult.modeGuidance; } - this.branches[input.branchId].push(input); } - if (!this.disableThoughtLogging) { - const formattedThought = this.formatThought(input); - console.error(formattedThought); + // Enrich with revision context when applicable + if (thoughtData.isRevision && thoughtData.revisesThought) { + const history = storage.getHistory(); + const original = history.find( + (t) => t.thoughtNumber === thoughtData.revisesThought && t.sessionId === sessionId, + ); + if (original) { + responseData.revisionContext = { + originalThought: original.thought, + originalThoughtNumber: original.thoughtNumber, + }; + } } - return { + // Enrich with branch context when applicable + if (thoughtData.branchId) { + const branchThoughts = storage.getBranchThoughts(thoughtData.branchId); + // Exclude the thought we just added to show only prior context + const prior = branchThoughts + .filter((t) => t !== thoughtData && t.thoughtNumber !== thoughtData.thoughtNumber) + .map((t) => ({ thoughtNumber: t.thoughtNumber, thought: t.thought })); + if (prior.length > 0) { + responseData.branchContext = { + branchId: thoughtData.branchId, + existingThoughts: prior, + }; + } + } + + const response = { content: [{ - type: "text" as const, - text: JSON.stringify({ - thoughtNumber: input.thoughtNumber, - totalThoughts: input.totalThoughts, - nextThoughtNeeded: input.nextThoughtNeeded, - branches: Object.keys(this.branches), - thoughtHistoryLength: this.thoughtHistory.length - }, null, 2) - }] + type: 'text' as const, + text: JSON.stringify(responseData, null, 2), + }], }; + + if (config.logging.enableThoughtLogging) { + logger.logThought(sessionId, thoughtData); + try { + console.error(formatter.format(thoughtData)); + } catch { + console.error(`[Thought] ${thoughtData.thoughtNumber}/${thoughtData.totalThoughts}`); + } + } + + const duration = Date.now() - startTime; + metrics.recordRequest(duration, true); + metrics.recordThoughtProcessed(thoughtData); + return response; + } catch (error) { + const duration = Date.now() - startTime; + metrics.recordRequest(duration, false); + metrics.recordError(error as Error); + throw error; + } + } + + public async processThought(input: ProcessThoughtRequest): Promise { + try { + // Validate input first + this.validateInput(input); + + // Process with services + return await this.processWithServices(input); + + } catch (error) { + // Handle errors using composite error handler + return this.errorHandler.handle(error as Error); + } + } + + // Health check method + public async getHealthStatus(): Promise { + try { + const container = this.app.getContainer(); + const healthChecker = container.get('healthChecker'); + return await healthChecker.checkHealth(); } catch (error) { return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: error instanceof Error ? error.message : String(error), - status: 'failed' - }, null, 2) - }], - isError: true + status: 'unhealthy', + summary: 'Health check failed', + checks: { + memory: { status: 'unhealthy', message: 'Health check failed', responseTime: 0, timestamp: new Date() }, + responseTime: { status: 'unhealthy', message: 'Health check failed', responseTime: 0, timestamp: new Date() }, + errorRate: { status: 'unhealthy', message: 'Health check failed', responseTime: 0, timestamp: new Date() }, + storage: { status: 'unhealthy', message: 'Health check failed', responseTime: 0, timestamp: new Date() }, + security: { status: 'unhealthy', message: 'Health check failed', responseTime: 0, timestamp: new Date() }, + }, + uptime: process.uptime(), + timestamp: new Date(), + }; + } + } + + // Metrics method + public getMetrics(): { + requests: RequestMetrics; + thoughts: ThoughtMetrics; + system: SystemMetrics; + } { + const container = this.app.getContainer(); + const metrics = container.get('metrics'); + return metrics.getMetrics(); + } + + // Cleanup method (idempotent — safe to call multiple times) + private destroyed = false; + + public destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + + try { + this.app.destroy(); + } catch (error) { + console.error('Error during cleanup:', error); + } + } + + // MCTS tree operations + public async backtrack(sessionId: string, nodeId: string): Promise { + try { + const { thoughtTreeManager } = this.getServices(); + const result = thoughtTreeManager.backtrack(sessionId, nodeId); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return this.errorHandler.handle(error as Error); + } + } + + public async evaluateThought(sessionId: string, nodeId: string, value: number): Promise { + try { + if (value < 0 || value > 1) { + throw new ValidationError('value must be between 0 and 1'); + } + const { thoughtTreeManager } = this.getServices(); + const result = thoughtTreeManager.evaluate(sessionId, nodeId, value); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return this.errorHandler.handle(error as Error); + } + } + + public async suggestNextThought(sessionId: string, strategy?: 'explore' | 'exploit' | 'balanced'): Promise { + try { + const { thoughtTreeManager } = this.getServices(); + const result = thoughtTreeManager.suggest(sessionId, strategy); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return this.errorHandler.handle(error as Error); + } + } + + public async getThinkingSummary(sessionId: string, maxDepth?: number): Promise { + try { + const { thoughtTreeManager } = this.getServices(); + const result = thoughtTreeManager.getSummary(sessionId, maxDepth); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], }; + } catch (error) { + return this.errorHandler.handle(error as Error); + } + } + + // Set thinking mode for a session + public async setThinkingMode(sessionId: string, mode: string): Promise { + try { + const validModes = ['fast', 'expert', 'deep']; + if (!validModes.includes(mode)) { + throw new ValidationError(`Invalid thinking mode: "${mode}". Must be one of: ${validModes.join(', ')}`); + } + const { thoughtTreeManager } = this.getServices(); + const config = thoughtTreeManager.setMode(sessionId, mode as ThinkingMode); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ + sessionId, + mode: config.mode, + config: { + explorationConstant: config.explorationConstant, + suggestStrategy: config.suggestStrategy, + maxBranchingFactor: config.maxBranchingFactor, + targetDepth: `${config.targetDepthMin}-${config.targetDepthMax}`, + autoEvaluate: config.autoEvaluate, + enableBacktracking: config.enableBacktracking, + convergenceThreshold: config.convergenceThreshold, + }, + }, null, 2) }], + }; + } catch (error) { + return this.errorHandler.handle(error as Error); + } + } + + // Filtered history for the get_thought_history tool + public getFilteredHistory(options: { + sessionId: string; + branchId?: string; + limit?: number; + }): ThoughtData[] { + try { + const container = this.app.getContainer(); + const storage = container.get('storage'); + + if (options.branchId) { + const branchThoughts = storage.getBranchThoughts(options.branchId); + const filtered = branchThoughts.filter((t) => t.sessionId === options.sessionId); + if (options.limit && options.limit > 0) { + return filtered.slice(-options.limit); + } + return filtered; + } + + const history = storage.getHistory(); + const filtered = history.filter((t) => t.sessionId === options.sessionId); + if (options.limit && options.limit > 0) { + return filtered.slice(-options.limit); + } + return filtered; + } catch (error) { + console.error('Warning: failed to get filtered history:', error); + return []; + } + } + + // Legacy compatibility methods + public getThoughtHistory(limit?: number): ThoughtData[] { + try { + const container = this.app.getContainer(); + const storage = container.get('storage'); + return storage.getHistory(limit); + } catch (error) { + console.error('Warning: failed to get thought history:', error); + return []; + } + } + + public getBranches(): string[] { + try { + const container = this.app.getContainer(); + const storage = container.get('storage'); + return storage.getBranches(); + } catch (error) { + console.error('Warning: failed to get branches:', error); + return []; } } } diff --git a/src/sequentialthinking/logger.ts b/src/sequentialthinking/logger.ts new file mode 100644 index 0000000000..fb1f119f9f --- /dev/null +++ b/src/sequentialthinking/logger.ts @@ -0,0 +1,168 @@ +import type { AppConfig, Logger, ThoughtData } from './interfaces.js'; + +interface LogEntry { + timestamp: string; + level: string; + message: string; + service: string; + meta?: unknown; +} + +export class StructuredLogger implements Logger { + private readonly sensitiveFields = [ + 'password', + 'token', + 'secret', + 'key', + 'auth', + 'authorization', + 'credential', + 'apikey', + 'accesskey', + 'privatekey', + 'sessiontoken', + ]; + + constructor(private readonly config: AppConfig['logging']) {} + + private shouldLog(level: string): boolean { + const levels = ['debug', 'info', 'warn', 'error']; + const currentLevelIndex = levels.indexOf(this.config.level); + const messageLevelIndex = levels.indexOf(level); + return messageLevelIndex >= currentLevelIndex; + } + + private sanitize( + obj: unknown, + depth: number = 0, + visited: WeakSet = new WeakSet(), + ): unknown { + if (!obj || typeof obj !== 'object') { + return obj; + } + + if (depth > 10) { + return '[Object]'; + } + + if (visited.has(obj)) { + return '[Circular]'; + } + + visited.add(obj); + + if (Array.isArray(obj)) { + return obj.map(item => this.sanitize(item, depth + 1, visited)); + } + + const record = obj as Record; + const sanitized: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (this.isSensitiveField(key)) { + sanitized[key] = '[REDACTED]'; + } else if (typeof value === 'object' && value !== null) { + sanitized[key] = this.sanitize(value, depth + 1, visited); + } else { + sanitized[key] = value; + } + } + + return sanitized; + } + + private isSensitiveField(fieldName: string): boolean { + const segments = this.splitFieldName(fieldName); + return this.sensitiveFields.some(sensitive => + segments.some(segment => segment === sensitive), + ); + } + + private splitFieldName(fieldName: string): string[] { + // Split on common separators: underscore, hyphen, dot + // Then split camelCase segments + return fieldName + .split(/[_\-.]/) + .flatMap(part => part.replace(/([a-z])([A-Z])/g, '$1\0$2').split('\0')) + .map(s => s.toLowerCase()); + } + + private createLogEntry( + level: string, + message: string, + meta?: unknown, + ): LogEntry { + const entry: LogEntry = { + timestamp: new Date().toISOString(), + level, + message, + service: 'sequential-thinking-server', + ...(meta ? { meta: this.sanitize(meta) } : {}), + }; + + return entry; + } + + private output(entry: LogEntry): void { + // All output to stderr — MCP reserves stdout for JSON-RPC protocol + console.error(JSON.stringify(entry)); + } + + info(message: string, meta?: unknown): void { + if (!this.shouldLog('info')) return; + + const entry = this.createLogEntry('info', message, meta); + this.output(entry); + } + + error(message: string, error?: unknown): void { + if (!this.shouldLog('error')) return; + + let meta: Record | undefined; + if (error instanceof Error) { + meta = { + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + }; + } else if (error) { + meta = { error }; + } + + const entry = this.createLogEntry('error', message, meta); + this.output(entry); + } + + debug(message: string, meta?: unknown): void { + if (!this.shouldLog('debug')) return; + + const entry = this.createLogEntry('debug', message, meta); + this.output(entry); + } + + warn(message: string, meta?: unknown): void { + if (!this.shouldLog('warn')) return; + + const entry = this.createLogEntry('warn', message, meta); + this.output(entry); + } + + // Context-specific logging methods + logThought(sessionId: string, thought: ThoughtData): void { + if (!this.shouldLog('debug')) return; + + const logEntry = { + sessionId, + thoughtNumber: thought.thoughtNumber, + totalThoughts: thought.totalThoughts, + isRevision: thought.isRevision, + branchId: thought.branchId, + thoughtLength: thought.thought.length, + hasContent: !!thought.thought, + }; + + this.debug('Thought processed', logEntry); + } + +} diff --git a/src/sequentialthinking/mcts.ts b/src/sequentialthinking/mcts.ts new file mode 100644 index 0000000000..cc1c548104 --- /dev/null +++ b/src/sequentialthinking/mcts.ts @@ -0,0 +1,153 @@ +import type { ThoughtTree, ThoughtNode } from './thought-tree.js'; +import type { TreeStats, TreeNodeInfo } from './interfaces.js'; + +const STRATEGY_CONSTANTS: Record = { + explore: 2.0, + exploit: 0.5, + balanced: Math.SQRT2, +}; + +export class MCTSEngine { + private readonly defaultC: number; + + constructor(explorationConstant: number = Math.SQRT2) { + this.defaultC = explorationConstant; + } + + computeUCB1(nodeVisits: number, nodeValue: number, parentVisits: number, C: number): number { + if (nodeVisits === 0) return Infinity; + const exploitation = nodeValue / nodeVisits; + const exploration = C * Math.sqrt(Math.log(parentVisits) / nodeVisits); + return exploitation + exploration; + } + + backpropagate(tree: ThoughtTree, nodeId: string, value: number): number { + let updated = 0; + const path = tree.getAncestorPath(nodeId); + + for (const node of path) { + node.totalValue += value; + node.visitCount++; + updated++; + } + + return updated; + } + + suggestNext(tree: ThoughtTree, strategy: 'explore' | 'exploit' | 'balanced' = 'balanced'): { + suggestion: { nodeId: string; thoughtNumber: number; thought: string; ucb1Score: number; reason: string } | null; + alternatives: Array<{ nodeId: string; thoughtNumber: number; ucb1Score: number }>; + } { + const C = STRATEGY_CONSTANTS[strategy] ?? this.defaultC; + const expandable = tree.getExpandableNodes(); + + if (expandable.length === 0) { + return { suggestion: null, alternatives: [] }; + } + + // Compute total visits across tree for parent context + const totalVisits = Math.max(1, expandable.reduce((sum, n) => sum + n.visitCount, 0)); + + const scored = expandable.map(node => ({ + node, + ucb1: this.computeUCB1(node.visitCount, node.totalValue, totalVisits, C), + })); + + // Sort descending by UCB1 score + scored.sort((a, b) => b.ucb1 - a.ucb1); + + const best = scored[0]; + const reason = best.node.visitCount === 0 + ? 'Unexplored node — never evaluated' + : `UCB1 score ${best.ucb1.toFixed(4)} (${strategy} strategy)`; + + return { + suggestion: { + nodeId: best.node.nodeId, + thoughtNumber: best.node.thoughtNumber, + thought: best.node.thought, + ucb1Score: best.ucb1, + reason, + }, + alternatives: scored.slice(1, 4).map(s => ({ + nodeId: s.node.nodeId, + thoughtNumber: s.node.thoughtNumber, + ucb1Score: s.ucb1, + })), + }; + } + + extractBestPath(tree: ThoughtTree): TreeNodeInfo[] { + const root = tree.root; + if (!root) return []; + + const path: TreeNodeInfo[] = []; + let current: ThoughtNode | undefined = root; + + while (current) { + path.push(this.toNodeInfo(current)); + + if (current.children.length === 0) break; + + // Follow highest average value child + let bestChild: ThoughtNode | undefined; + let bestAvg = -Infinity; + + for (const childId of current.children) { + const child = tree.getNode(childId); + if (!child) continue; + const avg = child.visitCount > 0 ? child.totalValue / child.visitCount : 0; + if (avg > bestAvg) { + bestAvg = avg; + bestChild = child; + } + } + + current = bestChild; + } + + return path; + } + + getTreeStats(tree: ThoughtTree): TreeStats { + const allNodes = tree.getAllNodes(); + if (allNodes.length === 0) { + return { totalNodes: 0, maxDepth: 0, unexploredCount: 0, averageValue: 0, terminalCount: 0 }; + } + + let maxDepth = 0; + let unexploredCount = 0; + let totalValue = 0; + let totalVisits = 0; + let terminalCount = 0; + + for (const node of allNodes) { + if (node.depth > maxDepth) maxDepth = node.depth; + if (node.visitCount === 0) unexploredCount++; + totalValue += node.totalValue; + totalVisits += node.visitCount; + if (node.isTerminal) terminalCount++; + } + + return { + totalNodes: allNodes.length, + maxDepth, + unexploredCount, + averageValue: totalVisits > 0 ? totalValue / totalVisits : 0, + terminalCount, + }; + } + + toNodeInfo(node: ThoughtNode): TreeNodeInfo { + return { + nodeId: node.nodeId, + thoughtNumber: node.thoughtNumber, + thought: node.thought, + depth: node.depth, + visitCount: node.visitCount, + averageValue: node.visitCount > 0 ? node.totalValue / node.visitCount : 0, + childCount: node.children.length, + isTerminal: node.isTerminal, + }; + } +} diff --git a/src/sequentialthinking/metrics.ts b/src/sequentialthinking/metrics.ts new file mode 100644 index 0000000000..a79414115a --- /dev/null +++ b/src/sequentialthinking/metrics.ts @@ -0,0 +1,137 @@ +import type { MetricsCollector, ThoughtData, RequestMetrics, ThoughtMetrics, SystemMetrics, ThoughtStorage } from './interfaces.js'; +import { CircularBuffer } from './circular-buffer.js'; +import type { SessionTracker } from './session-tracker.js'; + +export class BasicMetricsCollector implements MetricsCollector { + private readonly requestMetrics: RequestMetrics = { + totalRequests: 0, + successfulRequests: 0, + failedRequests: 0, + averageResponseTime: 0, + lastRequestTime: null, + requestsPerMinute: 0, + }; + + private readonly thoughtMetrics: ThoughtMetrics = { + totalThoughts: 0, + averageThoughtLength: 0, + thoughtsPerMinute: 0, + revisionCount: 0, + branchCount: 0, + activeSessions: 0, + }; + + private readonly responseTimes = new CircularBuffer(100); + private readonly requestTimestamps = new CircularBuffer(1000); + private readonly thoughtTimestamps = new CircularBuffer(1000); + private readonly sessionTracker: SessionTracker; + private readonly storage: ThoughtStorage; + + constructor(sessionTracker: SessionTracker, storage: ThoughtStorage) { + this.sessionTracker = sessionTracker; + this.storage = storage; + } + + recordRequest(duration: number, success: boolean): void { + const now = Date.now(); + + this.requestMetrics.totalRequests++; + this.requestMetrics.lastRequestTime = new Date(now); + + if (success) { + this.requestMetrics.successfulRequests++; + } else { + this.requestMetrics.failedRequests++; + } + + // Update response time metrics using circular buffer + this.responseTimes.add(duration); + + const allTimes = this.responseTimes.getAll(); + this.requestMetrics.averageResponseTime = + allTimes.reduce((sum, time) => sum + time, 0) / allTimes.length; + + // Update requests per minute + this.requestTimestamps.add(now); + const cutoff = now - 60 * 1000; + this.requestMetrics.requestsPerMinute = + this.requestTimestamps.getAll().filter(ts => ts > cutoff).length; + } + + recordError(_error: Error): void { + // No-op: the caller (lib.ts) already calls recordRequest(duration, false) + // before calling recordError, so we don't double-count. + } + + recordThoughtProcessed(thought: ThoughtData): void { + const now = Date.now(); + + this.thoughtMetrics.totalThoughts++; + this.thoughtTimestamps.add(now); + + // Update average thought length + const prevTotal = + this.thoughtMetrics.averageThoughtLength * + (this.thoughtMetrics.totalThoughts - 1); + const totalLength = prevTotal + thought.thought.length; + this.thoughtMetrics.averageThoughtLength = + Math.round(totalLength / this.thoughtMetrics.totalThoughts); + + // Track revisions + if (thought.isRevision) { + this.thoughtMetrics.revisionCount++; + } + + // Branch count is queried from storage (single source of truth) + this.thoughtMetrics.branchCount = this.storage.getBranches().length; + + // Update thoughts per minute + const cutoff = now - 60 * 1000; + this.thoughtMetrics.thoughtsPerMinute = + this.thoughtTimestamps.getAll().filter(ts => ts > cutoff).length; + + // Session tracking now handled by unified SessionTracker + this.thoughtMetrics.activeSessions = + this.sessionTracker.getActiveSessionCount(); + } + + getMetrics(): { + requests: RequestMetrics; + thoughts: ThoughtMetrics; + system: SystemMetrics; + } { + return { + requests: { ...this.requestMetrics }, + thoughts: { ...this.thoughtMetrics }, + system: this.getSystemMetrics(), + }; + } + + private getSystemMetrics(): SystemMetrics { + return { + memoryUsage: process.memoryUsage(), + cpuUsage: process.cpuUsage(), + uptime: process.uptime(), + timestamp: new Date(), + }; + } + + destroy(): void { + this.responseTimes.clear(); + this.requestTimestamps.clear(); + this.thoughtTimestamps.clear(); + this.requestMetrics.totalRequests = 0; + this.requestMetrics.successfulRequests = 0; + this.requestMetrics.failedRequests = 0; + this.requestMetrics.averageResponseTime = 0; + this.requestMetrics.lastRequestTime = null; + this.requestMetrics.requestsPerMinute = 0; + this.thoughtMetrics.totalThoughts = 0; + this.thoughtMetrics.averageThoughtLength = 0; + this.thoughtMetrics.thoughtsPerMinute = 0; + this.thoughtMetrics.revisionCount = 0; + this.thoughtMetrics.branchCount = 0; + this.thoughtMetrics.activeSessions = 0; + } + +} diff --git a/src/sequentialthinking/package.json b/src/sequentialthinking/package.json index da24ad3e9e..15d1ecb2cb 100644 --- a/src/sequentialthinking/package.json +++ b/src/sequentialthinking/package.json @@ -9,7 +9,8 @@ "bugs": "https://github.com/modelcontextprotocol/servers/issues", "repository": { "type": "git", - "url": "https://github.com/modelcontextprotocol/servers.git" + "url": "https://github.com/modelcontextprotocol/servers.git", + "directory": "src/sequentialthinking" }, "type": "module", "bin": { @@ -22,19 +23,30 @@ "build": "tsc && shx chmod +x dist/*.js", "prepare": "npm run build", "watch": "tsc --watch", - "test": "vitest run --coverage" + "test": "vitest run", + "test:unit": "vitest run __tests__/unit", + "test:integration": "vitest run __tests__/integration", + "test:e2e": "vitest run __tests__/e2e", + "test:all": "npm run test:unit && npm run test:integration && npm run test:e2e", + "lint": "eslint --config .eslintrc.cjs \"*.ts\"", + "lint:fix": "eslint --config .eslintrc.cjs \"*.ts\" --fix", + "type-check": "tsc --noEmit" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", - "chalk": "^5.3.0", - "yargs": "^17.7.2" + "chalk": "^5.0.0", + "zod": "^3.22.4" }, "devDependencies": { "@types/node": "^22", - "@types/yargs": "^17.0.32", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", "@vitest/coverage-v8": "^2.1.8", + "eslint": "^8.0.0", + "eslint-config-prettier": "^9.0.0", + "prettier": "^3.0.0", "shx": "^0.3.4", "typescript": "^5.3.3", "vitest": "^2.1.8" } -} \ No newline at end of file +} diff --git a/src/sequentialthinking/security-service.ts b/src/sequentialthinking/security-service.ts new file mode 100644 index 0000000000..f77d7a02f2 --- /dev/null +++ b/src/sequentialthinking/security-service.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; +import type { SecurityService } from './interfaces.js'; +import { SecurityError } from './errors.js'; +import type { SessionTracker } from './session-tracker.js'; + +// eslint-disable-next-line no-script-url +const JS_PROTOCOL = 'javascript:'; + +export const SecurityServiceConfigSchema = z.object({ + maxThoughtLength: z.number().default(5000), + maxThoughtsPerMinute: z.number().default(60), + blockedPatterns: z.array(z.string()).default([ + 'test-block', + 'forbidden', + JS_PROTOCOL, + 'eval(', + 'Function(', + ]), +}); + +type SecurityServiceConfig = z.infer; + +export class SecureThoughtSecurity implements SecurityService { + private readonly config: SecurityServiceConfig; + private readonly compiledPatterns: RegExp[]; + private readonly sessionTracker: SessionTracker; + + constructor( + config: SecurityServiceConfig = SecurityServiceConfigSchema.parse({}), + sessionTracker: SessionTracker, + ) { + this.config = config; + this.sessionTracker = sessionTracker; + this.compiledPatterns = []; + for (const pattern of this.config.blockedPatterns) { + try { + this.compiledPatterns.push(new RegExp(pattern, 'i')); + } catch { + // Skip malformed regex patterns + } + } + } + + validateThought( + thought: string, + sessionId: string = '', + ): void { + // Check for blocked patterns (length validation happens in lib.ts) + for (const regex of this.compiledPatterns) { + if (regex.test(thought)) { + throw new SecurityError( + `Thought contains prohibited content in session ${sessionId}`, + ); + } + } + + // Rate limiting: check AND record atomically to prevent race conditions + if (sessionId) { + const withinLimit = this.sessionTracker.checkRateLimit( + sessionId, + this.config.maxThoughtsPerMinute, + ); + if (!withinLimit) { + throw new SecurityError('Rate limit exceeded'); + } + // IMMEDIATELY record the thought to prevent race condition + // between validation and storage + this.sessionTracker.recordThought(sessionId); + } + } + + sanitizeContent(content: string): string { + return content + .replace(/]*>.*?<\/script>/gi, '') + .replace(/javascript:/gi, '') + .replace(/eval\(/gi, '') + .replace(/Function\(/gi, '') + .replace(/on\w+=/gi, ''); + } + + generateSessionId(): string { + return crypto.randomUUID(); + } + + validateSession(sessionId: string): boolean { + return sessionId.length > 0 && sessionId.length <= 100; + } + + getSecurityStatus( + _sessionId?: string, + ): Record { + return { + status: 'healthy', + activeSessions: this.sessionTracker.getActiveSessionCount(), + ipConnections: 0, + blockedPatterns: this.config.blockedPatterns.length, + }; + } +} diff --git a/src/sequentialthinking/session-tracker.ts b/src/sequentialthinking/session-tracker.ts new file mode 100644 index 0000000000..4a30dc5d49 --- /dev/null +++ b/src/sequentialthinking/session-tracker.ts @@ -0,0 +1,151 @@ +import { SESSION_EXPIRY_MS } from './config.js'; + +interface SessionData { + lastAccess: number; + thoughtCount: number; + rateTimestamps: number[]; // For rate limiting (60s window) +} + +const RATE_LIMIT_WINDOW_MS = 60000; +const MAX_TRACKED_SESSIONS = 10000; + +/** + * Centralized session tracking for state, security, and metrics. + * Replaces three separate Maps with unified expiry logic. + */ +export class SessionTracker { + private readonly sessions = new Map(); + private cleanupTimer: NodeJS.Timeout | null = null; + + constructor(cleanupInterval: number = 60000) { + if (cleanupInterval > 0) { + this.startCleanupTimer(cleanupInterval); + } + } + + /** + * Record a thought for a session. Updates timestamp and count. + */ + recordThought(sessionId: string): void { + const now = Date.now(); + const session = this.sessions.get(sessionId) ?? { + lastAccess: now, + thoughtCount: 0, + rateTimestamps: [], + }; + + session.lastAccess = now; + session.thoughtCount++; + session.rateTimestamps.push(now); + + this.sessions.set(sessionId, session); + + // Proactive cleanup when approaching limit + if (this.sessions.size > MAX_TRACKED_SESSIONS * 0.9) { + this.cleanup(); + } + } + + /** + * Check if session exceeds rate limit for given window. + * Returns true if within limit, throws if exceeded. + */ + checkRateLimit(sessionId: string, maxRequests: number): boolean { + const now = Date.now(); + const cutoff = now - RATE_LIMIT_WINDOW_MS; + + const session = this.sessions.get(sessionId); + if (!session) { + return true; // New session, no history + } + + // Prune old timestamps from rate window + while (session.rateTimestamps.length > 0 && session.rateTimestamps[0] < cutoff) { + session.rateTimestamps.shift(); + } + + return session.rateTimestamps.length < maxRequests; + } + + /** + * Get count of active sessions (accessed within expiry window). + */ + getActiveSessionCount(): number { + const now = Date.now(); + const cutoff = now - SESSION_EXPIRY_MS; + let count = 0; + + for (const session of this.sessions.values()) { + if (session.lastAccess >= cutoff) { + count++; + } + } + + return count; + } + + + /** + * Clean up expired sessions (older than 1 hour). + */ + cleanup(): void { + const now = Date.now(); + const cutoff = now - SESSION_EXPIRY_MS; + const rateCutoff = now - RATE_LIMIT_WINDOW_MS; + + for (const [id, session] of this.sessions.entries()) { + // Remove sessions with no activity in 1 hour + if (session.lastAccess < cutoff) { + this.sessions.delete(id); + continue; + } + + // Prune old rate timestamps + while (session.rateTimestamps.length > 0 && session.rateTimestamps[0] < rateCutoff) { + session.rateTimestamps.shift(); + } + } + + // If still at capacity, remove oldest sessions (FIFO) + if (this.sessions.size >= MAX_TRACKED_SESSIONS) { + const entriesToRemove = this.sessions.size - MAX_TRACKED_SESSIONS + 100; + const sortedSessions = Array.from(this.sessions.entries()) + .sort((a, b) => a[1].lastAccess - b[1].lastAccess) + .slice(0, entriesToRemove); + + for (const [id] of sortedSessions) { + this.sessions.delete(id); + } + } + } + + /** + * Clear all session data. + */ + clear(): void { + this.sessions.clear(); + } + + private startCleanupTimer(interval: number): void { + this.cleanupTimer = setInterval(() => { + try { + this.cleanup(); + } catch (error) { + console.error('Session cleanup error:', error); + } + }, interval); + this.cleanupTimer.unref(); + } + + stopCleanupTimer(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + } + + destroy(): void { + this.stopCleanupTimer(); + this.clear(); + } +} diff --git a/src/sequentialthinking/state-manager.ts b/src/sequentialthinking/state-manager.ts new file mode 100644 index 0000000000..5aeb1c7878 --- /dev/null +++ b/src/sequentialthinking/state-manager.ts @@ -0,0 +1,194 @@ +import type { ThoughtData } from './circular-buffer.js'; +import type { ThoughtStorage } from './interfaces.js'; +import { CircularBuffer } from './circular-buffer.js'; +import { StateError } from './errors.js'; +import type { SessionTracker } from './session-tracker.js'; + +class BranchData { + private thoughts: ThoughtData[] = []; + private lastAccessed: Date = new Date(); + + addThought(thought: ThoughtData): void { + this.thoughts.push(thought); + } + + updateLastAccessed(): void { + this.lastAccessed = new Date(); + } + + isExpired(maxAge: number): boolean { + return Date.now() - this.lastAccessed.getTime() > maxAge; + } + + cleanup(maxThoughts: number): void { + if (this.thoughts.length > maxThoughts) { + this.thoughts = this.thoughts.slice(-maxThoughts); + } + } + + getThoughtCount(): number { + return this.thoughts.length; + } + + getThoughts(): ThoughtData[] { + return [...this.thoughts]; + } +} + +interface StateConfig { + maxHistorySize: number; + maxBranchAge: number; + maxThoughtLength: number; + maxThoughtsPerBranch: number; + cleanupInterval: number; +} + +export class BoundedThoughtManager implements ThoughtStorage { + private readonly thoughtHistory: CircularBuffer; + private readonly branches: Map; + private readonly config: StateConfig; + private cleanupTimer: NodeJS.Timeout | null = null; + private readonly sessionTracker: SessionTracker; + + constructor(config: StateConfig, sessionTracker: SessionTracker) { + this.config = config; + this.sessionTracker = sessionTracker; + this.thoughtHistory = new CircularBuffer(config.maxHistorySize); + this.branches = new Map(); + this.startCleanupTimer(); + } + + addThought(thought: ThoughtData): void { + // Length validation happens in lib.ts before reaching here + // Work on a shallow copy to avoid mutating the caller's object + const entry = { ...thought }; + + // Ensure session ID for tracking + if (!entry.sessionId) { + entry.sessionId = 'anonymous-' + crypto.randomUUID(); + } + + entry.timestamp = Date.now(); + + // Session recording now happens atomically in security validation + // to prevent race conditions + + // Add to main history + this.thoughtHistory.add(entry); + + // Handle branch management + if (entry.branchId) { + const branch = this.getOrCreateBranch(entry.branchId); + branch.addThought(entry); + branch.updateLastAccessed(); + + // Enforce per-branch limits + if (branch.getThoughtCount() > this.config.maxThoughtsPerBranch) { + branch.cleanup(this.config.maxThoughtsPerBranch); + } + } + } + + private getOrCreateBranch(branchId: string): BranchData { + let branch = this.branches.get(branchId); + if (!branch) { + branch = new BranchData(); + this.branches.set(branchId, branch); + } + return branch; + } + + getHistory(limit?: number): ThoughtData[] { + return this.thoughtHistory.getAll(limit); + } + + getBranches(): string[] { + return Array.from(this.branches.keys()); + } + + getBranchThoughts(branchId: string): ThoughtData[] { + const branch = this.branches.get(branchId); + if (!branch) return []; + branch.updateLastAccessed(); + return branch.getThoughts(); + } + + getBranch(branchId: string): BranchData | undefined { + const branch = this.branches.get(branchId); + if (branch) { + branch.updateLastAccessed(); + } + return branch; + } + + clearHistory(): void { + this.thoughtHistory.clear(); + this.branches.clear(); + } + + cleanup(): void { + try { + // Clean up expired branches + const expiredBranches: string[] = []; + + for (const [branchId, branch] of this.branches.entries()) { + if (branch.isExpired(this.config.maxBranchAge)) { + expiredBranches.push(branchId); + } else { + // Cleanup old thoughts within active branches + branch.cleanup(this.config.maxThoughtsPerBranch); + } + } + + // Remove expired branches + for (const branchId of expiredBranches) { + this.branches.delete(branchId); + } + + // Session cleanup is now handled by SessionTracker + + } catch (error) { + throw new StateError('Cleanup operation failed', { error }); + } + } + + private startCleanupTimer(): void { + if (this.config.cleanupInterval > 0) { + this.cleanupTimer = setInterval(() => { + try { + this.cleanup(); + } catch (error) { + console.error('Cleanup timer error:', error); + } + }, this.config.cleanupInterval); + // Don't prevent clean process exit + this.cleanupTimer.unref(); + } + } + + stopCleanupTimer(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + } + + getStats(): { + historySize: number; + historyCapacity: number; + branchCount: number; + sessionCount: number; + } { + return { + historySize: this.thoughtHistory.currentSize, + historyCapacity: this.config.maxHistorySize, + branchCount: this.branches.size, + sessionCount: this.sessionTracker.getActiveSessionCount(), + }; + } + + destroy(): void { + this.stopCleanupTimer(); + this.clearHistory(); + } +} diff --git a/src/sequentialthinking/thinking-modes.ts b/src/sequentialthinking/thinking-modes.ts new file mode 100644 index 0000000000..c0ed4e2d61 --- /dev/null +++ b/src/sequentialthinking/thinking-modes.ts @@ -0,0 +1,694 @@ +import type { ThoughtTree } from './thought-tree.js'; +import type { MCTSEngine } from './mcts.js'; +import type { TreeStats, TreeNodeInfo } from './interfaces.js'; + +export type ThinkingMode = 'fast' | 'expert' | 'deep'; + +export interface ThinkingModeConfig { + mode: ThinkingMode; + explorationConstant: number; + suggestStrategy: 'explore' | 'exploit' | 'balanced'; + maxBranchingFactor: number; + targetDepthMin: number; + targetDepthMax: number; + autoEvaluate: boolean; + autoEvalValue: number; + enableBacktracking: boolean; + minEvaluationsBeforeConverge: number; + convergenceThreshold: number; + progressOverviewInterval: number; + maxThoughtDisplayLength: number; + enableCritique: boolean; +} + +export interface ModeGuidance { + mode: ThinkingMode; + currentPhase: 'exploring' | 'evaluating' | 'converging' | 'concluded'; + recommendedAction: 'continue' | 'branch' | 'evaluate' | 'backtrack' | 'conclude'; + reasoning: string; + targetTotalThoughts: number; + convergenceStatus: { + isConverged: boolean; + score: number; + bestPathValue: number; + } | null; + branchingSuggestion: { + shouldBranch: boolean; + fromNodeId: string; + reason: string; + } | null; + backtrackSuggestion: { + shouldBacktrack: boolean; + toNodeId: string; + reason: string; + } | null; + thoughtPrompt: string; + progressOverview: string | null; + critique: string | null; +} + +const PRESETS: Record = { + fast: { + mode: 'fast', + explorationConstant: 0.5, + suggestStrategy: 'exploit', + maxBranchingFactor: 1, + targetDepthMin: 3, + targetDepthMax: 5, + autoEvaluate: true, + autoEvalValue: 0.7, + enableBacktracking: false, + minEvaluationsBeforeConverge: 0, + convergenceThreshold: 0, + progressOverviewInterval: 3, + maxThoughtDisplayLength: 150, + enableCritique: false, + }, + expert: { + mode: 'expert', + explorationConstant: Math.SQRT2, + suggestStrategy: 'balanced', + maxBranchingFactor: 3, + targetDepthMin: 5, + targetDepthMax: 10, + autoEvaluate: false, + autoEvalValue: 0, + enableBacktracking: true, + minEvaluationsBeforeConverge: 3, + convergenceThreshold: 0.7, + progressOverviewInterval: 4, + maxThoughtDisplayLength: 250, + enableCritique: true, + }, + deep: { + mode: 'deep', + explorationConstant: 2.0, + suggestStrategy: 'explore', + maxBranchingFactor: 5, + targetDepthMin: 10, + targetDepthMax: 20, + autoEvaluate: false, + autoEvalValue: 0, + enableBacktracking: true, + minEvaluationsBeforeConverge: 5, + convergenceThreshold: 0.85, + progressOverviewInterval: 5, + maxThoughtDisplayLength: 300, + enableCritique: true, + }, +}; + +interface TemplateParams { + thoughtNumber: number; + currentDepth: number; + targetDepthMin: number; + targetDepthMax: number; + totalNodes: number; + unexploredCount: number; + leafCount: number; + terminalCount: number; + progress: string; + cursorValue: string; + bestPathValue: string; + convergenceScore: string; + branchCount: number; + maxBranches: number; + convergenceThreshold: number; + currentThought: string; + parentThought: string; + bestPathSummary: string; + branchFromNodeId: string; + backtrackToNodeId: string; + backtrackDepth: number; +} + +const TEMPLATES: Record = { + fast_continue: 'Step {{thoughtNumber}} of ~{{targetDepthMax}}. Build on: "{{currentThought}}". Next logical step — no alternatives, stay linear.', + fast_conclude: 'Reached target depth ({{currentDepth}}/{{targetDepthMax}}). Synthesize your {{totalNodes}} steps into a direct, concise answer.', + fast_evaluate: 'Assess quality at step {{thoughtNumber}} (depth {{currentDepth}}/{{targetDepthMax}}). Current value: {{cursorValue}}.', + + expert_continue: 'Step {{thoughtNumber}}, depth {{currentDepth}}/{{targetDepthMax}}. {{unexploredCount}} paths unexplored. Building on: "{{currentThought}}". What follows logically?', + expert_branch: 'Decision point at node {{branchFromNodeId}}. {{branchCount}}/{{maxBranches}} perspectives explored. Current path: "{{currentThought}}". Branch with a different angle, method, or assumption.', + expert_evaluate: '{{unexploredCount}} paths need scoring. Use evaluate_thought to rate quality and guide exploration. Best path so far: {{bestPathSummary}}.', + expert_backtrack: 'Path scoring {{cursorValue}} — below threshold. Backtrack to node {{backtrackToNodeId}} (depth {{backtrackDepth}}). What assumption led astray?', + expert_conclude: 'Convergence reached (score {{convergenceScore}}, threshold {{convergenceThreshold}}). Best path: {{bestPathSummary}}. Synthesize the strongest path into a final answer.', + + deep_continue: 'Depth {{currentDepth}}/{{targetDepthMax}}, {{totalNodes}} nodes, {{unexploredCount}} unscored. Building on: "{{currentThought}}". What nuance, edge case, or deeper implication?', + deep_branch: '{{branchCount}}/{{maxBranches}} alternatives explored from node {{branchFromNodeId}}. Branch with a contrarian, lateral, or adversarial perspective on: "{{currentThought}}".', + deep_evaluate: '{{unexploredCount}} paths unscored across {{leafCount}} leaves. Score before convergence check. Best path: {{bestPathSummary}}.', + deep_backtrack: 'Path scoring {{cursorValue}}. Backtrack to node {{backtrackToNodeId}} (depth {{backtrackDepth}}). Find the weakest link in the reasoning and explore the opposite.', + deep_conclude: 'Deep convergence (score {{convergenceScore}}, threshold {{convergenceThreshold}}, {{totalNodes}} nodes). Summarize findings, address counterarguments, and state confidence level.', +}; + +const FALLBACK_TEMPLATE = '{{recommendedAction}} at step {{thoughtNumber}} (depth {{currentDepth}}/{{targetDepthMax}}). {{totalNodes}} nodes explored.'; + +export class ThinkingModeEngine { + getPreset(mode: ThinkingMode): ThinkingModeConfig { + return { ...PRESETS[mode] }; + } + + getAutoEvalValue(config: ThinkingModeConfig): number | null { + return config.autoEvaluate ? config.autoEvalValue : null; + } + + generateGuidance(config: ThinkingModeConfig, tree: ThoughtTree, engine: MCTSEngine): ModeGuidance { + const stats = engine.getTreeStats(tree); + const bestPath = engine.extractBestPath(tree); + const currentDepth = stats.maxDepth; + const totalEvaluated = stats.totalNodes - stats.unexploredCount; + + // Compute convergence status + const convergenceStatus = this.computeConvergenceStatus(config, bestPath, totalEvaluated); + + // Determine current phase + const currentPhase = this.determinePhase(config, currentDepth, totalEvaluated, convergenceStatus); + + // Determine recommended action + reasoning + suggestions + const { recommendedAction, reasoning, branchingSuggestion, backtrackSuggestion } = + this.determineAction(config, tree, engine, currentPhase, currentDepth, convergenceStatus); + + const templateParams = this.buildTemplateParams( + config, tree, stats, bestPath, convergenceStatus, branchingSuggestion, backtrackSuggestion, + ); + const template = this.selectTemplate(config.mode, recommendedAction); + const thoughtPrompt = this.renderTemplate(template, { ...templateParams, recommendedAction }); + + const progressOverview = this.generateProgressOverview(config, tree, stats, bestPath); + const critique = this.generateCritique(config, tree, bestPath, stats); + + return { + mode: config.mode, + currentPhase, + recommendedAction, + reasoning, + targetTotalThoughts: config.targetDepthMax, + convergenceStatus, + branchingSuggestion, + backtrackSuggestion, + thoughtPrompt, + progressOverview, + critique, + }; + } + + private selectTemplate(mode: ThinkingMode, action: ModeGuidance['recommendedAction']): string { + return TEMPLATES[`${mode}_${action}`] ?? FALLBACK_TEMPLATE; + } + + private renderTemplate(template: string, params: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => { + const val = params[key as keyof typeof params]; + return val !== undefined && val !== null ? String(val) : ''; + }); + } + + private buildTemplateParams( + config: ThinkingModeConfig, + tree: ThoughtTree, + stats: TreeStats, + bestPath: TreeNodeInfo[], + convergenceStatus: ModeGuidance['convergenceStatus'], + branchingSuggestion: ModeGuidance['branchingSuggestion'], + backtrackSuggestion: ModeGuidance['backtrackSuggestion'], + ): TemplateParams { + const cursor = tree.cursor; + const cursorDepth = cursor?.depth ?? 0; + const cursorAvg = cursor && cursor.visitCount > 0 + ? (cursor.totalValue / cursor.visitCount).toFixed(2) + : 'unscored'; + + const bestPathValue = bestPath.length > 0 + ? bestPath[bestPath.length - 1].averageValue.toFixed(2) + : '0.00'; + + const bestPathSummary = bestPath.length > 0 + ? bestPath.map(n => n.thoughtNumber).join(' -> ') + : '(none)'; + + const leaves = tree.getLeafNodes(); + + const maxLen = config.maxThoughtDisplayLength; + const currentThought = cursor ? this.compressThought(cursor.thought, maxLen) : '(none)'; + + let parentThought = '(root)'; + if (cursor?.parentId) { + const parent = tree.getNode(cursor.parentId); + if (parent) { + parentThought = this.compressThought(parent.thought, maxLen); + } + } + + const backtrackTarget = backtrackSuggestion?.toNodeId + ? tree.getNode(backtrackSuggestion.toNodeId) + : undefined; + + return { + thoughtNumber: cursor?.thoughtNumber ?? 0, + currentDepth: cursorDepth, + targetDepthMin: config.targetDepthMin, + targetDepthMax: config.targetDepthMax, + totalNodes: stats.totalNodes, + unexploredCount: stats.unexploredCount, + leafCount: leaves.length, + terminalCount: stats.terminalCount, + progress: config.targetDepthMax > 0 + ? (cursorDepth / config.targetDepthMax).toFixed(2) + : '0.00', + cursorValue: cursorAvg, + bestPathValue, + convergenceScore: convergenceStatus + ? convergenceStatus.score.toFixed(2) + : 'N/A', + branchCount: cursor?.children.length ?? 0, + maxBranches: config.maxBranchingFactor, + convergenceThreshold: config.convergenceThreshold, + currentThought, + parentThought, + bestPathSummary, + branchFromNodeId: branchingSuggestion?.fromNodeId ?? '', + backtrackToNodeId: backtrackSuggestion?.toNodeId ?? '', + backtrackDepth: backtrackTarget?.depth ?? 0, + }; + } + + private computeConvergenceStatus( + config: ThinkingModeConfig, + bestPath: Array<{ visitCount: number; averageValue: number }>, + totalEvaluated: number, + ): ModeGuidance['convergenceStatus'] { + if (config.convergenceThreshold === 0) { + return null; + } + + const bestPathValue = bestPath.length > 0 + ? bestPath[bestPath.length - 1].averageValue + : 0; + + // Average value across best path nodes that have been visited + const visitedNodes = bestPath.filter(n => n.visitCount > 0); + const score = visitedNodes.length > 0 + ? visitedNodes.reduce((sum, n) => sum + n.averageValue, 0) / visitedNodes.length + : 0; + + const isConverged = + totalEvaluated >= config.minEvaluationsBeforeConverge && + score >= config.convergenceThreshold; + + return { isConverged, score, bestPathValue }; + } + + private determinePhase( + config: ThinkingModeConfig, + currentDepth: number, + totalEvaluated: number, + convergenceStatus: ModeGuidance['convergenceStatus'], + ): ModeGuidance['currentPhase'] { + // Already converged → concluded + if (convergenceStatus?.isConverged) { + return 'concluded'; + } + + // Fast mode: conclude when at target depth + if (config.mode === 'fast' && currentDepth >= config.targetDepthMax) { + return 'concluded'; + } + + // Check if enough evaluations for convergence phase + if (config.convergenceThreshold > 0 && totalEvaluated >= config.minEvaluationsBeforeConverge) { + return 'converging'; + } + + // If we have some evaluations, we're evaluating + if (totalEvaluated > 0 && currentDepth >= config.targetDepthMin) { + return 'evaluating'; + } + + return 'exploring'; + } + + private determineAction( + config: ThinkingModeConfig, + tree: ThoughtTree, + engine: MCTSEngine, + currentPhase: ModeGuidance['currentPhase'], + currentDepth: number, + convergenceStatus: ModeGuidance['convergenceStatus'], + ): { + recommendedAction: ModeGuidance['recommendedAction']; + reasoning: string; + branchingSuggestion: ModeGuidance['branchingSuggestion']; + backtrackSuggestion: ModeGuidance['backtrackSuggestion']; + } { + switch (config.mode) { + case 'fast': + return this.determineFastAction(config, currentPhase, currentDepth); + case 'expert': + return this.determineExpertAction(config, tree, engine, currentPhase, currentDepth, convergenceStatus); + case 'deep': + return this.determineDeepAction(config, tree, engine, currentPhase, currentDepth, convergenceStatus); + } + } + + private determineFastAction( + config: ThinkingModeConfig, + currentPhase: ModeGuidance['currentPhase'], + currentDepth: number, + ) { + if (currentPhase === 'concluded' || currentDepth >= config.targetDepthMax) { + return { + recommendedAction: 'conclude' as const, + reasoning: `Target depth reached (${currentDepth}/${config.targetDepthMax}). Fast mode — conclude now.`, + branchingSuggestion: null, + backtrackSuggestion: null, + }; + } + + return { + recommendedAction: 'continue' as const, + reasoning: `Fast mode — continue linear exploration (${currentDepth}/${config.targetDepthMax}).`, + branchingSuggestion: null, + backtrackSuggestion: null, + }; + } + + private determineExpertAction( + config: ThinkingModeConfig, + tree: ThoughtTree, + engine: MCTSEngine, + currentPhase: ModeGuidance['currentPhase'], + currentDepth: number, + convergenceStatus: ModeGuidance['convergenceStatus'], + ) { + // Concluded + if (currentPhase === 'concluded') { + return { + recommendedAction: 'conclude' as const, + reasoning: `Convergence reached (score: ${convergenceStatus?.score?.toFixed(2)}). Expert mode — conclude.`, + branchingSuggestion: null, + backtrackSuggestion: null, + }; + } + + const cursor = tree.cursor; + if (!cursor) { + return { + recommendedAction: 'continue' as const, + reasoning: 'No cursor — submit a thought to begin.', + branchingSuggestion: null, + backtrackSuggestion: null, + }; + } + + // Check for backtracking: current path scores low + if (config.enableBacktracking && cursor.visitCount > 0) { + const cursorAvg = cursor.totalValue / cursor.visitCount; + if (cursorAvg < 0.4 && currentDepth > 1) { + const ancestor = this.findBestAncestorForBacktrack(tree, engine, cursor.nodeId); + if (ancestor) { + return { + recommendedAction: 'backtrack' as const, + reasoning: `Current path scoring low (${cursorAvg.toFixed(2)}). Backtrack to explore alternatives.`, + branchingSuggestion: null, + backtrackSuggestion: { + shouldBacktrack: true, + toNodeId: ancestor.nodeId, + reason: `Node at depth ${ancestor.depth} has better potential for branching.`, + }, + }; + } + } + } + + // Check for branching: cursor has few children relative to max + if (cursor.children.length < config.maxBranchingFactor && !cursor.isTerminal && currentDepth >= 2) { + return { + recommendedAction: 'branch' as const, + reasoning: `Decision point — ${cursor.children.length}/${config.maxBranchingFactor} branches explored. Consider alternative approaches.`, + branchingSuggestion: { + shouldBranch: true, + fromNodeId: cursor.nodeId, + reason: `Node has capacity for ${config.maxBranchingFactor - cursor.children.length} more branches.`, + }, + backtrackSuggestion: null, + }; + } + + // Check for evaluation: leaves need scoring + const leaves = tree.getLeafNodes(); + const unevaluated = leaves.filter(l => l.visitCount === 0); + if (unevaluated.length > 0) { + return { + recommendedAction: 'evaluate' as const, + reasoning: `${unevaluated.length} leaf node(s) unevaluated. Score them to guide exploration.`, + branchingSuggestion: null, + backtrackSuggestion: null, + }; + } + + return { + recommendedAction: 'continue' as const, + reasoning: `Expert mode — continue exploring (depth ${currentDepth}/${config.targetDepthMax}).`, + branchingSuggestion: null, + backtrackSuggestion: null, + }; + } + + private determineDeepAction( + config: ThinkingModeConfig, + tree: ThoughtTree, + engine: MCTSEngine, + currentPhase: ModeGuidance['currentPhase'], + currentDepth: number, + convergenceStatus: ModeGuidance['convergenceStatus'], + ) { + // Concluded + if (currentPhase === 'concluded') { + return { + recommendedAction: 'conclude' as const, + reasoning: `High convergence reached (score: ${convergenceStatus?.score?.toFixed(2)}, threshold: ${config.convergenceThreshold}). Deep mode — conclude.`, + branchingSuggestion: null, + backtrackSuggestion: null, + }; + } + + const cursor = tree.cursor; + if (!cursor) { + return { + recommendedAction: 'continue' as const, + reasoning: 'No cursor — submit a thought to begin.', + branchingSuggestion: null, + backtrackSuggestion: null, + }; + } + + // Deep mode: aggressive backtracking to visit alternatives + if (config.enableBacktracking && cursor.visitCount > 0 && cursor.children.length > 0) { + const cursorAvg = cursor.totalValue / cursor.visitCount; + if (cursorAvg < 0.5) { + const ancestor = this.findBestAncestorForBacktrack(tree, engine, cursor.nodeId); + if (ancestor) { + return { + recommendedAction: 'backtrack' as const, + reasoning: `Deep exploration — current path at ${cursorAvg.toFixed(2)}. Backtrack to explore more alternatives.`, + branchingSuggestion: null, + backtrackSuggestion: { + shouldBacktrack: true, + toNodeId: ancestor.nodeId, + reason: `Revisit node at depth ${ancestor.depth} for wider exploration.`, + }, + }; + } + } + } + + // Deep mode: aggressive branching + if (cursor.children.length < config.maxBranchingFactor && !cursor.isTerminal) { + // Use MCTS suggestion for best branching point + const suggestion = engine.suggestNext(tree, config.suggestStrategy); + const branchFrom = suggestion.suggestion ? suggestion.suggestion.nodeId : cursor.nodeId; + + return { + recommendedAction: 'branch' as const, + reasoning: `Deep mode — aggressively branch (${cursor.children.length}/${config.maxBranchingFactor}). Explore diverse perspectives.`, + branchingSuggestion: { + shouldBranch: true, + fromNodeId: branchFrom, + reason: `Wide exploration: up to ${config.maxBranchingFactor} branches per node.`, + }, + backtrackSuggestion: null, + }; + } + + // Evaluate unevaluated leaves + const leaves = tree.getLeafNodes(); + const unevaluated = leaves.filter(l => l.visitCount === 0); + if (unevaluated.length > 0) { + return { + recommendedAction: 'evaluate' as const, + reasoning: `${unevaluated.length} unevaluated leaf node(s). Score them before convergence check.`, + branchingSuggestion: null, + backtrackSuggestion: null, + }; + } + + return { + recommendedAction: 'continue' as const, + reasoning: `Deep mode — continue exploration (depth ${currentDepth}/${config.targetDepthMax}).`, + branchingSuggestion: null, + backtrackSuggestion: null, + }; + } + + private compressThought(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + + const sentences = text.split(/(?<=[.!?])\s+/); + + if (sentences.length < 2) { + // Single sentence or no boundaries: word-boundary truncate + const cutoff = maxLen - 3; + const lastSpace = text.lastIndexOf(' ', cutoff); + const breakAt = lastSpace > 0 ? lastSpace : cutoff; + return text.substring(0, breakAt) + '...'; + } + + const first = sentences[0]; + const last = sentences[sentences.length - 1]; + const combined = `${first} [...] ${last}`; + if (combined.length <= maxLen) return combined; + + const firstOnly = `${first} [...]`; + if (firstOnly.length <= maxLen) return firstOnly; + + // First sentence alone is too long — word-boundary truncate it + const cutoff = maxLen - 3; + const lastSpace = first.lastIndexOf(' ', cutoff); + const breakAt = lastSpace > 0 ? lastSpace : cutoff; + return first.substring(0, breakAt) + '...'; + } + + private extractFirstSentence(text: string): string { + const match = text.match(/^(.+?[.!?])(?:\s|$)/); + if (match) return match[1]; + // No sentence boundary found — compress to 50 chars + if (text.length <= 50) return text; + const lastSpace = text.lastIndexOf(' ', 47); + const breakAt = lastSpace > 0 ? lastSpace : 47; + return text.substring(0, breakAt) + '...'; + } + + private generateProgressOverview( + config: ThinkingModeConfig, + tree: ThoughtTree, + stats: TreeStats, + bestPath: TreeNodeInfo[], + ): string | null { + const interval = config.progressOverviewInterval; + if (interval <= 0 || stats.totalNodes <= 0 || stats.totalNodes % interval !== 0) { + return null; + } + + const totalEvaluated = stats.totalNodes - stats.unexploredCount; + const leaves = tree.getLeafNodes(); + const leafCount = leaves.length; + + const bestPathSummary = bestPath.length > 0 + ? bestPath.map(n => this.extractFirstSentence(n.thought)).join(' \u2192 ') + : '(none)'; + const bestPathScore = bestPath.length > 0 + ? bestPath[bestPath.length - 1].averageValue.toFixed(2) + : '0.00'; + + // Count single-child non-leaf nodes on best path as "branch points to expand" + let singleChildBranchPoints = 0; + for (const node of bestPath) { + if (node.childCount === 1) { + singleChildBranchPoints++; + } + } + + return `PROGRESS [${stats.totalNodes} thoughts, depth ${stats.maxDepth}/${config.targetDepthMax}]: Evaluated ${totalEvaluated}/${stats.totalNodes} | Leaves ${leafCount} | Terminal ${stats.terminalCount}.\nBest path (score ${bestPathScore}): ${bestPathSummary}.\nGaps: ${stats.unexploredCount} unscored, ${singleChildBranchPoints} single-child branch points to expand.`; + } + + private generateCritique( + config: ThinkingModeConfig, + tree: ThoughtTree, + bestPath: TreeNodeInfo[], + stats: TreeStats, + ): string | null { + if (!config.enableCritique || bestPath.length < 2) { + return null; + } + + // Find weakest link: lowest averageValue on bestPath among visited nodes + let weakestNode: TreeNodeInfo | null = null; + let weakestValue = Infinity; + for (const node of bestPath) { + if (node.visitCount > 0 && node.averageValue < weakestValue) { + weakestValue = node.averageValue; + weakestNode = node; + } + } + + // Unchallenged steps: bestPath nodes whose parent has only 1 child + let unchallengedCount = 0; + for (let i = 1; i < bestPath.length; i++) { + const parentNode = tree.getNode(bestPath[i - 1].nodeId); + if (parentNode && parentNode.children.length === 1) { + unchallengedCount++; + } + } + + // Branch coverage: actual children across bestPath / theoretical max + let totalChildren = 0; + for (const node of bestPath) { + totalChildren += node.childCount; + } + const theoreticalMax = bestPath.length * config.maxBranchingFactor; + const coveragePercent = theoreticalMax > 0 + ? Math.round((totalChildren / theoreticalMax) * 100) + : 0; + + // Balance: bestPath.length / totalNodes ratio + const balanceRatio = stats.totalNodes > 0 + ? bestPath.length / stats.totalNodes + : 0; + const balancePercent = Math.round(balanceRatio * 100); + let balanceLabel: string; + if (balanceRatio > 0.8) { + balanceLabel = 'one-sided'; + } else if (balanceRatio > 0.5) { + balanceLabel = 'moderate'; + } else { + balanceLabel = 'well-balanced'; + } + + const weakestInfo = weakestNode + ? `Weakest: step ${weakestNode.thoughtNumber} (score ${weakestValue.toFixed(2)}) \u2014 "${this.compressThought(weakestNode.thought, 60)}".` + : 'Weakest: N/A (no scored nodes).'; + + return `CRITIQUE: ${weakestInfo}\nUnchallenged: ${unchallengedCount}/${bestPath.length - 1} steps have no alternatives. Coverage: ${totalChildren}/${theoreticalMax} branches (${coveragePercent}%).\nBalance: ${balanceLabel} \u2014 ${balancePercent}% of nodes on best path.`; + } + + private findBestAncestorForBacktrack( + tree: ThoughtTree, + engine: MCTSEngine, + nodeId: string, + ): { nodeId: string; depth: number } | null { + const path = tree.getAncestorPath(nodeId); + if (path.length <= 1) return null; + + // Find ancestor with capacity for more children (skip root, skip current) + for (let i = path.length - 2; i >= 0; i--) { + const ancestor = path[i]; + if (ancestor.children.length > 1 || !ancestor.isTerminal) { + return { nodeId: ancestor.nodeId, depth: ancestor.depth }; + } + } + + // Fallback: return root's first child or root + return path.length > 1 + ? { nodeId: path[0].nodeId, depth: path[0].depth } + : null; + } +} diff --git a/src/sequentialthinking/thought-tree-manager.ts b/src/sequentialthinking/thought-tree-manager.ts new file mode 100644 index 0000000000..5aff08d9f7 --- /dev/null +++ b/src/sequentialthinking/thought-tree-manager.ts @@ -0,0 +1,193 @@ +import type { ThoughtData } from './circular-buffer.js'; +import type { + MCTSConfig, + ThoughtTreeService, + ThoughtTreeRecordResult, + MCTSService, + TreeStats, + BacktrackResult, + EvaluateResult, + SuggestResult, + ThinkingSummary, +} from './interfaces.js'; +import { ThoughtTree } from './thought-tree.js'; +import { MCTSEngine } from './mcts.js'; +import { TreeError } from './errors.js'; +import { ThinkingModeEngine } from './thinking-modes.js'; +import type { ThinkingMode, ThinkingModeConfig } from './thinking-modes.js'; + +const MAX_CONCURRENT_TREES = 100; +const CLEANUP_INTERVAL_MS = 300000; // 5 minutes + +export class ThoughtTreeManager implements ThoughtTreeService, MCTSService { + private readonly trees = new Map(); + private readonly engine: MCTSEngine; + private readonly config: MCTSConfig; + private readonly modes = new Map(); + private readonly modeEngine = new ThinkingModeEngine(); + private cleanupTimer: NodeJS.Timeout | null = null; + + constructor(config: MCTSConfig) { + this.config = config; + this.engine = new MCTSEngine(config.explorationConstant); + this.startCleanupTimer(); + } + + recordThought(data: ThoughtData): ThoughtTreeRecordResult | null { + if (!this.config.enableAutoTree) return null; + + const sessionId = data.sessionId; + if (!sessionId) return null; + + const tree = this.getOrCreateTree(sessionId); + const node = tree.addThought(data); + + // Auto-evaluate in fast mode + const modeConfig = this.modes.get(sessionId); + if (modeConfig) { + const autoVal = this.modeEngine.getAutoEvalValue(modeConfig); + if (autoVal !== null) { + this.engine.backpropagate(tree, node.nodeId, autoVal); + } + } + + const treeStats = this.engine.getTreeStats(tree); + + const result: ThoughtTreeRecordResult = { + nodeId: node.nodeId, + parentNodeId: node.parentId, + treeStats, + }; + + // Generate mode guidance if mode is active + if (modeConfig) { + result.modeGuidance = this.modeEngine.generateGuidance(modeConfig, tree, this.engine); + } + + return result; + } + + backtrack(sessionId: string, nodeId: string): BacktrackResult { + const tree = this.getTree(sessionId); + const node = tree.setCursor(nodeId); + const children = tree.getChildren(nodeId); + + return { + node: this.engine.toNodeInfo(node), + children: children.map(c => this.engine.toNodeInfo(c)), + treeStats: this.engine.getTreeStats(tree), + }; + } + + evaluate(sessionId: string, nodeId: string, value: number): EvaluateResult { + const tree = this.getTree(sessionId); + const node = tree.getNode(nodeId); + if (!node) { + throw new TreeError(`Node not found: ${nodeId}`); + } + + const nodesUpdated = this.engine.backpropagate(tree, nodeId, value); + + return { + nodeId, + newVisitCount: node.visitCount, + newAverageValue: node.visitCount > 0 ? node.totalValue / node.visitCount : 0, + nodesUpdated, + treeStats: this.engine.getTreeStats(tree), + }; + } + + suggest(sessionId: string, strategy: 'explore' | 'exploit' | 'balanced' = 'balanced'): SuggestResult { + const tree = this.getTree(sessionId); + const result = this.engine.suggestNext(tree, strategy); + + return { + suggestion: result.suggestion, + alternatives: result.alternatives, + treeStats: this.engine.getTreeStats(tree), + }; + } + + getSummary(sessionId: string, maxDepth?: number): ThinkingSummary { + const tree = this.getTree(sessionId); + + return { + bestPath: this.engine.extractBestPath(tree), + treeStructure: tree.toJSON(maxDepth), + treeStats: this.engine.getTreeStats(tree), + }; + } + + setMode(sessionId: string, mode: ThinkingMode): ThinkingModeConfig { + const config = this.modeEngine.getPreset(mode); + this.modes.set(sessionId, config); + // Ensure tree exists for this session + this.getOrCreateTree(sessionId); + return config; + } + + getMode(sessionId: string): ThinkingModeConfig | null { + return this.modes.get(sessionId) ?? null; + } + + cleanup(): void { + const now = Date.now(); + + // Remove expired trees and their mode configs + for (const [sessionId, tree] of this.trees.entries()) { + if (now - tree.lastAccessed > this.config.maxTreeAge) { + this.trees.delete(sessionId); + this.modes.delete(sessionId); + } + } + + // Cap at MAX_CONCURRENT_TREES, evict LRU + if (this.trees.size > MAX_CONCURRENT_TREES) { + const sorted = Array.from(this.trees.entries()) + .sort((a, b) => a[1].lastAccessed - b[1].lastAccessed); + + const toRemove = this.trees.size - MAX_CONCURRENT_TREES; + for (let i = 0; i < toRemove; i++) { + this.trees.delete(sorted[i][0]); + } + } + } + + destroy(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + this.trees.clear(); + this.modes.clear(); + } + + private getOrCreateTree(sessionId: string): ThoughtTree { + let tree = this.trees.get(sessionId); + if (!tree) { + tree = new ThoughtTree(sessionId, this.config.maxNodesPerTree); + this.trees.set(sessionId, tree); + } + return tree; + } + + private getTree(sessionId: string): ThoughtTree { + const tree = this.trees.get(sessionId); + if (!tree) { + throw new TreeError(`No thought tree found for session: ${sessionId}`); + } + tree.lastAccessed = Date.now(); + return tree; + } + + private startCleanupTimer(): void { + this.cleanupTimer = setInterval(() => { + try { + this.cleanup(); + } catch (error) { + console.error('Tree cleanup error:', error); + } + }, CLEANUP_INTERVAL_MS); + this.cleanupTimer.unref(); + } +} diff --git a/src/sequentialthinking/thought-tree.ts b/src/sequentialthinking/thought-tree.ts new file mode 100644 index 0000000000..7ab759b665 --- /dev/null +++ b/src/sequentialthinking/thought-tree.ts @@ -0,0 +1,314 @@ +import type { ThoughtData } from './circular-buffer.js'; + +export interface ThoughtNode { + nodeId: string; + parentId: string | null; + children: string[]; + depth: number; + visitCount: number; + totalValue: number; + isTerminal: boolean; + thoughtNumber: number; + thought: string; + sessionId: string; + branchId?: string; + isRevision?: boolean; + revisesThought?: number; + branchFromThought?: number; + createdAt: number; +} + +export class ThoughtTree { + private readonly nodes = new Map(); + private readonly thoughtNumberIndex = new Map(); + private rootId: string | null = null; + private cursorId: string | null = null; + private readonly maxNodes: number; + readonly sessionId: string; + lastAccessed: number; + + constructor(sessionId: string, maxNodes: number) { + this.sessionId = sessionId; + this.maxNodes = maxNodes; + this.lastAccessed = Date.now(); + } + + get size(): number { + return this.nodes.size; + } + + get root(): ThoughtNode | undefined { + return this.rootId ? this.nodes.get(this.rootId) : undefined; + } + + get cursor(): ThoughtNode | undefined { + return this.cursorId ? this.nodes.get(this.cursorId) : undefined; + } + + getNode(nodeId: string): ThoughtNode | undefined { + return this.nodes.get(nodeId); + } + + addThought(data: ThoughtData): ThoughtNode { + this.lastAccessed = Date.now(); + const nodeId = this.generateNodeId(); + + let parentId: string | null = null; + let depth = 0; + + if (this.rootId === null) { + // First node becomes root + parentId = null; + depth = 0; + } else if (data.branchFromThought) { + // Branch: child of the node at branchFromThought + const branchParent = this.findNodeByThoughtNumber(data.branchFromThought); + if (branchParent) { + parentId = branchParent.nodeId; + depth = branchParent.depth + 1; + } else { + // Fallback to cursor if branch target not found + parentId = this.cursorId; + depth = this.cursor ? this.cursor.depth + 1 : 0; + } + } else if (data.isRevision && data.revisesThought) { + // Revision: sibling of the revised node (child of revised node's parent) + const revisedNode = this.findNodeByThoughtNumber(data.revisesThought); + if (revisedNode) { + if (revisedNode.parentId === null) { + // Revising root: new node becomes child of root + parentId = revisedNode.nodeId; + depth = revisedNode.depth + 1; + } else { + parentId = revisedNode.parentId; + const parent = this.nodes.get(revisedNode.parentId); + depth = parent ? parent.depth + 1 : 0; + } + } else { + // Fallback to cursor + parentId = this.cursorId; + depth = this.cursor ? this.cursor.depth + 1 : 0; + } + } else { + // Sequential: child of cursor + parentId = this.cursorId; + depth = this.cursor ? this.cursor.depth + 1 : 0; + } + + const node: ThoughtNode = { + nodeId, + parentId, + children: [], + depth, + visitCount: 0, + totalValue: 0, + isTerminal: !data.nextThoughtNeeded, + thoughtNumber: data.thoughtNumber, + thought: data.thought, + sessionId: data.sessionId ?? this.sessionId, + branchId: data.branchId, + isRevision: data.isRevision, + revisesThought: data.revisesThought, + branchFromThought: data.branchFromThought, + createdAt: Date.now(), + }; + + this.nodes.set(nodeId, node); + + // Update parent's children list + if (parentId !== null) { + const parent = this.nodes.get(parentId); + if (parent) { + parent.children.push(nodeId); + } + } + + // Update thought number index + const existing = this.thoughtNumberIndex.get(data.thoughtNumber) ?? []; + existing.push(nodeId); + this.thoughtNumberIndex.set(data.thoughtNumber, existing); + + // Set root if first node + if (this.rootId === null) { + this.rootId = nodeId; + } + + // Move cursor to new node + this.cursorId = nodeId; + + // Prune if over capacity + if (this.nodes.size > this.maxNodes) { + this.prune(); + } + + return node; + } + + setCursor(nodeId: string): ThoughtNode { + const node = this.nodes.get(nodeId); + if (!node) { + throw new Error(`Node not found: ${nodeId}`); + } + this.cursorId = nodeId; + this.lastAccessed = Date.now(); + return node; + } + + findNodeByThoughtNumber(thoughtNumber: number): ThoughtNode | undefined { + const nodeIds = this.thoughtNumberIndex.get(thoughtNumber); + if (!nodeIds || nodeIds.length === 0) return undefined; + + if (nodeIds.length === 1) { + return this.nodes.get(nodeIds[0]); + } + + // Multiple nodes with same thoughtNumber: prefer cursor's ancestor + if (this.cursorId) { + const ancestorIds = new Set(this.getAncestorPath(this.cursorId).map(n => n.nodeId)); + for (const id of nodeIds) { + if (ancestorIds.has(id)) { + return this.nodes.get(id); + } + } + } + + // Fallback: return the first one + return this.nodes.get(nodeIds[0]); + } + + getAncestorPath(nodeId: string): ThoughtNode[] { + const path: ThoughtNode[] = []; + let current = this.nodes.get(nodeId); + while (current) { + path.unshift(current); + if (current.parentId === null) break; + current = this.nodes.get(current.parentId); + } + return path; + } + + getChildren(nodeId: string): ThoughtNode[] { + const node = this.nodes.get(nodeId); + if (!node) return []; + return node.children + .map(id => this.nodes.get(id)) + .filter((n): n is ThoughtNode => n !== undefined); + } + + getLeafNodes(): ThoughtNode[] { + const leaves: ThoughtNode[] = []; + for (const node of this.nodes.values()) { + if (node.children.length === 0) { + leaves.push(node); + } + } + return leaves; + } + + getExpandableNodes(): ThoughtNode[] { + const expandable: ThoughtNode[] = []; + for (const node of this.nodes.values()) { + if (!node.isTerminal) { + expandable.push(node); + } + } + return expandable; + } + + getAllNodes(): ThoughtNode[] { + return Array.from(this.nodes.values()); + } + + toJSON(maxDepth?: number): unknown { + if (!this.rootId) return null; + return this.serializeNode(this.rootId, 0, maxDepth); + } + + private serializeNode(nodeId: string, currentDepth: number, maxDepth?: number): unknown { + const node = this.nodes.get(nodeId); + if (!node) return null; + + const result: Record = { + nodeId: node.nodeId, + thoughtNumber: node.thoughtNumber, + thought: node.thought.substring(0, 100) + (node.thought.length > 100 ? '...' : ''), + depth: node.depth, + visitCount: node.visitCount, + averageValue: node.visitCount > 0 ? node.totalValue / node.visitCount : 0, + isTerminal: node.isTerminal, + isCursor: node.nodeId === this.cursorId, + childCount: node.children.length, + }; + + if (maxDepth !== undefined && currentDepth >= maxDepth) { + if (node.children.length > 0) { + result.children = `[${node.children.length} children truncated]`; + } + return result; + } + + if (node.children.length > 0) { + result.children = node.children + .map(id => this.serializeNode(id, currentDepth + 1, maxDepth)) + .filter(n => n !== null); + } + + return result; + } + + prune(): void { + while (this.nodes.size > this.maxNodes) { + const leaves = this.getLeafNodes(); + + // Find the lowest-value leaf that isn't root or cursor + let worstLeaf: ThoughtNode | null = null; + let worstValue = Infinity; + + for (const leaf of leaves) { + if (leaf.nodeId === this.rootId || leaf.nodeId === this.cursorId) continue; + const avgValue = leaf.visitCount > 0 ? leaf.totalValue / leaf.visitCount : 0; + if (avgValue < worstValue) { + worstValue = avgValue; + worstLeaf = leaf; + } + } + + if (!worstLeaf) break; // Nothing safe to prune + + this.removeNode(worstLeaf.nodeId); + } + } + + private removeNode(nodeId: string): void { + const node = this.nodes.get(nodeId); + if (!node) return; + + // Remove from parent's children + if (node.parentId) { + const parent = this.nodes.get(node.parentId); + if (parent) { + parent.children = parent.children.filter(id => id !== nodeId); + } + } + + // Remove from thought number index + const indexIds = this.thoughtNumberIndex.get(node.thoughtNumber); + if (indexIds) { + const filtered = indexIds.filter(id => id !== nodeId); + if (filtered.length === 0) { + this.thoughtNumberIndex.delete(node.thoughtNumber); + } else { + this.thoughtNumberIndex.set(node.thoughtNumber, filtered); + } + } + + this.nodes.delete(nodeId); + } + + private nodeCounter = 0; + + private generateNodeId(): string { + this.nodeCounter++; + return `node_${this.nodeCounter}_${Date.now().toString(36)}`; + } +} diff --git a/src/sequentialthinking/vitest.config.ts b/src/sequentialthinking/vitest.config.ts index d414ec8f52..e3d3c3ed76 100644 --- a/src/sequentialthinking/vitest.config.ts +++ b/src/sequentialthinking/vitest.config.ts @@ -4,7 +4,8 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['**/__tests__/**/*.test.ts'], + include: ['**/__tests__/**/**/*.test.ts'], + setupFiles: ['./__tests__/helpers/mocks.ts'], coverage: { provider: 'v8', include: ['**/*.ts'],