diff --git a/.github/workflows/vscode.yml b/.github/workflows/vscode.yml index 51160fe..aa9558a 100644 --- a/.github/workflows/vscode.yml +++ b/.github/workflows/vscode.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: jobs: build: @@ -19,6 +20,12 @@ jobs: - run: npm ci + - name: Build router + run: npm run build -w @retort-plugins/router + + - name: Test router + run: npm run test -w @retort-plugins/router + - name: Build UI run: npm run build -w @retort-plugins/ui diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cdf921 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +out/ +*.vsix +.vite/ diff --git a/package-lock.json b/package-lock.json index d026cdf..3a3354b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "retort-plugins", "license": "MIT", "workspaces": [ + "packages/router", "packages/ui", "packages/state-watcher", "extensions/vscode" @@ -871,6 +872,19 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -959,6 +973,10 @@ "node": ">= 8" } }, + "node_modules/@retort-plugins/router": { + "resolved": "packages/router", + "link": true + }, "node_modules/@retort-plugins/state-watcher": { "resolved": "packages/state-watcher", "link": true @@ -1328,6 +1346,13 @@ "win32" ] }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1656,6 +1681,109 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vscode/test-electron": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", @@ -1696,6 +1824,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -1779,6 +1920,16 @@ "node": ">=8" } }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1867,6 +2018,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1898,6 +2059,25 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1915,6 +2095,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1995,6 +2188,13 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2049,6 +2249,19 @@ } } }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2056,6 +2269,16 @@ "dev": true, "license": "MIT" }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2336,6 +2559,16 @@ "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", @@ -2346,6 +2579,46 @@ "node": ">=0.10.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/execa/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/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2502,6 +2775,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "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": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -2642,6 +2938,16 @@ "node": ">= 14" } }, + "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/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2770,6 +3076,19 @@ "node": ">=8" } }, + "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/is-unicode-supported": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", @@ -2910,6 +3229,23 @@ "immediate": "~3.0.5" } }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2988,6 +3324,16 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2998,6 +3344,23 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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", @@ -3022,6 +3385,19 @@ "node": ">=8.6" } }, + "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", @@ -3051,6 +3427,26 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3100,6 +3496,35 @@ "node": ">=0.10.0" } }, + "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/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3302,6 +3727,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3321,6 +3763,25 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -3360,6 +3821,34 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -3423,6 +3912,13 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3644,6 +4140,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -3677,6 +4180,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -3760,6 +4277,19 @@ "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", @@ -3773,6 +4303,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3793,6 +4343,33 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "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", @@ -3831,6 +4408,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -3858,6 +4445,13 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -3982,6 +4576,95 @@ } } }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3998,6 +4681,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4084,10 +4784,20 @@ } } }, + "packages/router": { + "name": "@retort-plugins/router", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.3.0", + "vitest": "^1.0.0" + } + }, "packages/state-watcher": { "name": "@retort-plugins/state-watcher", "version": "0.1.0", "dependencies": { + "@retort-plugins/router": "*", "chokidar": "^3.6.0", "ws": "^8.16.0" }, diff --git a/package.json b/package.json index abf9953..e7d3bb5 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,14 @@ }, "license": "MIT", "workspaces": [ + "packages/router", "packages/ui", "packages/state-watcher", "extensions/vscode" ], "scripts": { - "build": "npm run build -w @retort-plugins/ui && npm run build -w @retort-plugins/state-watcher && npm run build -w @retort-plugins/vscode", + "build": "npm run build -w @retort-plugins/router && npm run build -w @retort-plugins/ui && npm run build -w @retort-plugins/state-watcher && npm run build -w @retort-plugins/vscode", + "build:router": "npm run build -w @retort-plugins/router", "build:ui": "npm run build -w @retort-plugins/ui", "build:watcher": "npm run build -w @retort-plugins/state-watcher", "build:vscode": "npm run build -w @retort-plugins/vscode", diff --git a/packages/router/package.json b/packages/router/package.json new file mode 100644 index 0000000..2803d1d --- /dev/null +++ b/packages/router/package.json @@ -0,0 +1,20 @@ +{ + "name": "@retort-plugins/router", + "version": "0.1.0", + "private": true, + "description": "Keyword-based team/agent routing for Retort — answers natural-language questions about agent teams", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -p ./", + "watch": "tsc -watch -p ./", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.3.0", + "vitest": "^1.0.0" + } +} diff --git a/packages/router/src/index-builder.ts b/packages/router/src/index-builder.ts new file mode 100644 index 0000000..6ca6e2f --- /dev/null +++ b/packages/router/src/index-builder.ts @@ -0,0 +1,45 @@ +import type { RouterEntry, RouterIndex } from './types.js' + +export interface TeamLike { + id: string + name: string + focus: string + scope: string[] + accepts: string[] +} + +/** + * Builds a keyword index from an array of team objects. + * Phase 1: pure keyword tokenisation — no ML embeddings required. + */ +export function buildIndex(teams: TeamLike[]): RouterIndex { + const entries: RouterEntry[] = teams.map((team) => { + const raw = [ + team.name, + team.focus, + ...team.scope, + ...team.accepts, + ].join(' ') + + return { + type: 'team', + id: team.id, + name: team.name, + keywords: tokenise(raw), + explanation: `The ${team.name} team handles ${team.focus.toLowerCase()}.`, + suggestedCommand: `/team-${team.id}`, + } + }) + + return { entries, indexedAt: new Date().toISOString() } +} + +/** Lowercase, strip punctuation, split on whitespace, deduplicate. */ +export function tokenise(text: string): string[] { + const tokens = text + .toLowerCase() + .replace(/[^\w\s/-]/g, ' ') + .split(/\s+/) + .filter((t) => t.length > 1) + return [...new Set(tokens)] +} diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts new file mode 100644 index 0000000..361bc42 --- /dev/null +++ b/packages/router/src/index.ts @@ -0,0 +1,5 @@ +export { buildIndex, tokenise } from './index-builder.js' +export type { TeamLike } from './index-builder.js' +export { search } from './search.js' +export { route } from './router.js' +export type { RouteResult, RouterEntry, RouterIndex, AskResponse } from './types.js' diff --git a/packages/router/src/router.test.ts b/packages/router/src/router.test.ts new file mode 100644 index 0000000..febb639 --- /dev/null +++ b/packages/router/src/router.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest' +import { buildIndex } from './index-builder.js' +import type { TeamLike } from './index-builder.js' +import { route } from './router.js' +import { search } from './search.js' + +const TEAMS: TeamLike[] = [ + { + id: 'frontend', + name: 'Frontend', + focus: 'UI, components, PWA, video, media, React', + scope: ['apps/web/**'], + accepts: ['feature', 'bug'], + }, + { + id: 'backend', + name: 'Backend', + focus: 'API, services, core logic, database', + scope: ['apps/api/**', 'services/**'], + accepts: ['feature', 'bug', 'performance'], + }, + { + id: 'security', + name: 'Security', + focus: 'Auth, compliance, audit, secrets', + scope: ['auth/**', 'security/**'], + accepts: ['security', 'bug'], + }, + { + id: 'devops', + name: 'DevOps', + focus: 'CI/CD, pipelines, containers, deployment', + scope: ['.github/workflows/**', 'docker/**'], + accepts: ['chore', 'ci'], + }, + { + id: 'product', + name: 'Product', + focus: 'Features, PRDs, roadmap, video content, media production', + scope: ['docs/product/**', 'docs/prd/**'], + accepts: ['feature', 'epic'], + }, +] + +describe('buildIndex', () => { + it('creates one entry per team', () => { + const index = buildIndex(TEAMS) + expect(index.entries).toHaveLength(TEAMS.length) + }) + + it('sets suggestedCommand from team id', () => { + const index = buildIndex(TEAMS) + const frontend = index.entries.find((e) => e.id === 'frontend') + expect(frontend?.suggestedCommand).toBe('/team-frontend') + }) + + it('includes indexedAt timestamp', () => { + const index = buildIndex(TEAMS) + expect(new Date(index.indexedAt).getTime()).toBeGreaterThan(0) + }) +}) + +describe('route — in-scope queries', () => { + const index = buildIndex(TEAMS) + + it('routes "which team handles video content" to frontend or product', () => { + const { results } = route('which team handles video content', index) + const ids = results.map((r) => r.id) + expect(ids.some((id) => id === 'frontend' || id === 'product')).toBe(true) + }) + + it('returns confidence >= 0.6 for a clear match', () => { + const { results } = route('api services backend', index) + expect(results[0].confidence).toBeGreaterThanOrEqual(0.6) + }) + + it('routes "auth compliance audit" to security', () => { + const { results } = route('auth compliance audit', index) + expect(results[0].id).toBe('security') + }) + + it('routes "CI CD pipelines" to devops', () => { + const { results } = route('CI CD pipelines', index) + expect(results[0].id).toBe('devops') + }) + + it('does not set scopeViolation for in-scope queries', () => { + const response = route('which team handles api work', index) + expect(response.scopeViolation).toBeUndefined() + }) +}) + +describe('route — scope violations', () => { + const index = buildIndex(TEAMS) + + it('returns scopeViolation for "write a react component"', () => { + const { results, scopeViolation } = route('write a react component', index) + expect(scopeViolation).toBe(true) + expect(results).toHaveLength(0) + }) + + it('returns scopeViolation for "what is the capital of France"', () => { + const { results, scopeViolation } = route('what is the capital of France', index) + expect(scopeViolation).toBe(true) + expect(results).toHaveLength(0) + }) + + it('returns scopeViolation for "generate a function"', () => { + const { scopeViolation } = route('generate a function', index) + expect(scopeViolation).toBe(true) + }) +}) + +describe('search', () => { + const index = buildIndex(TEAMS) + + it('returns empty array for empty query', () => { + const results = search('', index.entries) + expect(results).toHaveLength(0) + }) + + it('returns results sorted by descending confidence', () => { + const results = search('api backend services', index.entries) + for (let i = 1; i < results.length; i++) { + expect(results[i - 1].confidence).toBeGreaterThanOrEqual(results[i].confidence) + } + }) + + it('excludes entries with zero overlap', () => { + // "xyzzy" won't match anything + const results = search('xyzzy', index.entries) + expect(results).toHaveLength(0) + }) +}) diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts new file mode 100644 index 0000000..a9c0e59 --- /dev/null +++ b/packages/router/src/router.ts @@ -0,0 +1,43 @@ +import { search } from './search.js' +import type { AskResponse, RouterIndex, RouteResult } from './types.js' + +/** + * Patterns that indicate an off-topic query. + * When matched, the router returns an empty result with scopeViolation: true + * and a redirect suggestion rather than attempting to route. + */ +const OFF_TOPIC_PATTERNS: RegExp[] = [ + /\b(write|create|generate|build)\s+(a\s+)?(react|vue|angular|component|function|class|script|code|test)\b/i, + /\b(capital|president|population|history|recipe|weather|sport|movie|music)\b/i, + /\bwho\s+is\b/i, + /\bwhat\s+is\s+(the\s+)?[a-z]+\s+(of|in)\b/i, +] + +function isOffTopic(query: string): boolean { + return OFF_TOPIC_PATTERNS.some((re) => re.test(query)) +} + +/** + * Routes a natural-language query against the pre-built index. + * + * - Off-topic queries return `{ results: [], scopeViolation: true }`. + * - In-scope queries return up to 5 results sorted by confidence. + */ +export function route(query: string, index: RouterIndex): AskResponse { + if (isOffTopic(query)) { + return { + results: [], + query, + indexedAt: index.indexedAt, + scopeViolation: true, + } + } + + const results: RouteResult[] = search(query, index.entries) + + return { + results, + query, + indexedAt: index.indexedAt, + } +} diff --git a/packages/router/src/search.ts b/packages/router/src/search.ts new file mode 100644 index 0000000..56e3b6e --- /dev/null +++ b/packages/router/src/search.ts @@ -0,0 +1,39 @@ +import { tokenise } from './index-builder.js' +import type { RouterEntry, RouteResult } from './types.js' + +const TOP_K = 5 + +/** + * Score a single entry against the query tokens. + * + * Confidence = |query_tokens ∩ entry_keywords| / |query_tokens| + * Clamped to [0, 1]. Entries with zero overlap are excluded. + */ +function score(queryTokens: string[], entry: RouterEntry): number { + if (queryTokens.length === 0) return 0 + const entrySet = new Set(entry.keywords) + const hits = queryTokens.filter((t) => entrySet.has(t)).length + return hits / queryTokens.length +} + +/** + * Returns the top-K RouteResults for a query, sorted by descending confidence. + * Results with confidence === 0 are excluded. + */ +export function search(query: string, entries: RouterEntry[]): RouteResult[] { + const queryTokens = tokenise(query) + + return entries + .map((entry) => ({ entry, confidence: score(queryTokens, entry) })) + .filter(({ confidence }) => confidence > 0) + .sort((a, b) => b.confidence - a.confidence) + .slice(0, TOP_K) + .map(({ entry, confidence }) => ({ + type: entry.type, + id: entry.id, + name: entry.name, + confidence: Math.round(confidence * 100) / 100, + explanation: entry.explanation, + suggestedCommand: entry.suggestedCommand, + })) +} diff --git a/packages/router/src/types.ts b/packages/router/src/types.ts new file mode 100644 index 0000000..bfb4202 --- /dev/null +++ b/packages/router/src/types.ts @@ -0,0 +1,32 @@ +export interface RouteResult { + type: 'team' | 'agent' | 'rule' | 'doc' + id: string + name: string + /** 0–1 keyword overlap score */ + confidence: number + explanation: string + suggestedCommand?: string + configSnippet?: string +} + +export interface RouterEntry { + type: RouteResult['type'] + id: string + name: string + /** Tokenised keywords derived from name + focus + scope + accepts */ + keywords: string[] + explanation: string + suggestedCommand?: string +} + +export interface RouterIndex { + entries: RouterEntry[] + indexedAt: string +} + +export interface AskResponse { + results: RouteResult[] + query: string + indexedAt: string + scopeViolation?: true +} diff --git a/packages/router/tsconfig.json b/packages/router/tsconfig.json new file mode 100644 index 0000000..d19f4e4 --- /dev/null +++ b/packages/router/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "Node16", + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/state-watcher/package.json b/packages/state-watcher/package.json index 03fb6fd..8fa37c9 100644 --- a/packages/state-watcher/package.json +++ b/packages/state-watcher/package.json @@ -11,6 +11,7 @@ "start": "node dist/index.js" }, "dependencies": { + "@retort-plugins/router": "*", "chokidar": "^3.6.0", "ws": "^8.16.0" }, diff --git a/packages/state-watcher/src/index.ts b/packages/state-watcher/src/index.ts index ed2fcfc..109e9c8 100644 --- a/packages/state-watcher/src/index.ts +++ b/packages/state-watcher/src/index.ts @@ -17,6 +17,7 @@ import { createWatcher, type ChangeKind } from './watcher.js' import { createServer } from './server.js' import { createCogmeshPoller } from './cogmesh-poller.js' import type { HostMessage } from './protocol.js' +import { buildIndex } from '@retort-plugins/router' const workspaceRoot = process.argv[2] @@ -43,10 +44,12 @@ function buildSnapshot(): HostMessage & { type: 'snapshot' } { // Start WebSocket server // --------------------------------------------------------------------------- -const { broadcast, close } = createServer( +const { broadcast, close, setRouterIndex } = createServer( (port) => { // Signal VS Code extension that the server is ready process.stdout.write(`PORT:${port}\n`) + // Build initial router index once teams are parsed + setRouterIndex(buildIndex(parseTeams(workspaceRoot))) }, (command, args) => { // Relay command:run from UI to the extension via stdout @@ -101,9 +104,12 @@ function flushPending(): void { } break } - case 'teams': - broadcast({ type: 'teams:updated', teams: parseTeams(workspaceRoot) }) + case 'teams': { + const teams = parseTeams(workspaceRoot) + broadcast({ type: 'teams:updated', teams }) + setRouterIndex(buildIndex(teams)) break + } default: broadcast(buildSnapshot()) } diff --git a/packages/state-watcher/src/server.ts b/packages/state-watcher/src/server.ts index 011f1d7..b9aedbb 100644 --- a/packages/state-watcher/src/server.ts +++ b/packages/state-watcher/src/server.ts @@ -1,16 +1,23 @@ +import * as http from 'http' import { WebSocketServer, WebSocket } from 'ws' import type { HostMessage, ClientMessage } from './protocol.js' +import type { AskResponse, RouterIndex } from '@retort-plugins/router' +import { route } from '@retort-plugins/router' export type CommandHandler = (command: string, args?: string[]) => void /** - * Creates a WebSocket server on a random available port. + * Creates an HTTP + WebSocket server on a random available port. * - * - Calls `onReady(port)` once the server is listening. - * - Calls `onCommand` when a connected client sends a `command:run` message. - * The host (VS Code extension) reads these from stdout as `CMD:`. + * HTTP routes: + * GET /api/ask?q= — semantic team routing + * GET /health — liveness probe * - * Returns a `broadcast` function and a `close` function. + * WebSocket (ws://localhost:/ws): + * Calls `onReady(port)` once listening. + * Calls `onCommand` when a client sends a `command:run` message. + * + * Returns `broadcast`, `close`, and `setRouterIndex` functions. */ export function createServer( onReady: (port: number) => void, @@ -18,15 +25,59 @@ export function createServer( ): { broadcast: (msg: HostMessage) => void close: () => void + setRouterIndex: (index: RouterIndex) => void } { - const wss = new WebSocketServer({ port: 0 }) + let routerIndex: RouterIndex | null = null - wss.on('listening', () => { - const addr = wss.address() - const port = typeof addr === 'object' && addr ? addr.port : 0 - onReady(port) + // --------------------------------------------------------------------------- + // HTTP server — handles /health and /api/ask; upgrades WS connections + // --------------------------------------------------------------------------- + + const httpServer = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://localhost`) + + // CORS headers so the UI WebView can fetch from the same origin + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Content-Type', 'application/json') + + if (url.pathname === '/health') { + res.writeHead(200) + res.end(JSON.stringify({ status: 'ok' })) + return + } + + if (url.pathname === '/api/ask') { + const q = url.searchParams.get('q') ?? '' + if (!q.trim()) { + res.writeHead(400) + res.end(JSON.stringify({ error: 'q parameter is required' })) + return + } + + if (!routerIndex) { + // Index not yet built — return empty results rather than 503 + const empty: AskResponse = { results: [], query: q, indexedAt: '' } + res.writeHead(200) + res.end(JSON.stringify(empty)) + return + } + + const response = route(q, routerIndex) + res.writeHead(200) + res.end(JSON.stringify(response)) + return + } + + res.writeHead(404) + res.end(JSON.stringify({ error: 'not found' })) }) + // --------------------------------------------------------------------------- + // WebSocket server — attached to the same HTTP server + // --------------------------------------------------------------------------- + + const wss = new WebSocketServer({ server: httpServer }) + wss.on('connection', (ws: WebSocket) => { ws.on('message', (data) => { let msg: ClientMessage @@ -39,11 +90,16 @@ export function createServer( if (msg.type === 'command:run') { onCommand(msg.command, msg.args) } - // 'ready' and 'file:open' are handled by the UI / extension layer; - // we only need to relay command:run via stdout. }) }) + // port: 0 asks the OS to pick a free port + httpServer.listen(0, '127.0.0.1', () => { + const addr = httpServer.address() + const port = typeof addr === 'object' && addr ? addr.port : 0 + onReady(port) + }) + const broadcast = (msg: HostMessage): void => { const payload = JSON.stringify(msg) for (const client of wss.clients) { @@ -55,7 +111,12 @@ export function createServer( const close = (): void => { wss.close() + httpServer.close() + } + + const setRouterIndex = (index: RouterIndex): void => { + routerIndex = index } - return { broadcast, close } + return { broadcast, close, setRouterIndex } } diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 67b3332..1db01ae 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { useBridge } from './bridge/useBridge' import { useStore } from './bridge/useStore' import type { ActivePanel } from './bridge/useStore' @@ -7,6 +8,7 @@ import { TeamsPanel } from './panels/TeamsPanel' import { HandoffFeedPanel } from './panels/HandoffFeedPanel' import { CognitiveMeshPanel } from './panels/CognitiveMeshPanel' import { OnboardingPanel } from './panels/OnboardingPanel' +import { AskPanel } from './panels/AskPanel' interface Tab { id: ActivePanel @@ -19,8 +21,17 @@ const TABS: Tab[] = [ { id: 'teams', label: 'Teams' }, { id: 'handoff', label: 'Handoff' }, { id: 'cogmesh', label: 'Mesh' }, + { id: 'ask', label: 'Ask' }, ] +// HTTP and WebSocket share the same port, so derive baseUrl from window.location. +function resolveBaseUrl(): string { + const params = new URLSearchParams(window.location.search) + const port = params.get('port') ?? window.location.port + const host = window.location.hostname || 'localhost' + return `http://${host}:${port}` +} + function SyncIndicator() { const { state, message } = useStore((s) => s.syncStatus) if (state === 'idle') return null @@ -53,6 +64,20 @@ export function App() { // state-watcher hasn't found a .retortconfig in the workspace. const showOnboarding = session === null && teams.length === 0 + // Keyboard shortcut: Ctrl+Shift+? (or Cmd+Shift+?) opens Ask panel + useEffect(() => { + function handleKey(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === '?') { + e.preventDefault() + setActivePanel('ask') + } + } + window.addEventListener('keydown', handleKey) + return () => window.removeEventListener('keydown', handleKey) + }, [setActivePanel]) + + const baseUrl = resolveBaseUrl() + function renderPanel() { if (showOnboarding) return switch (activePanel) { @@ -61,6 +86,7 @@ export function App() { case 'teams': return case 'handoff': return case 'cogmesh': return + case 'ask': return default: return } } diff --git a/packages/ui/src/bridge/useStore.ts b/packages/ui/src/bridge/useStore.ts index dc9a224..a1dbdd7 100644 --- a/packages/ui/src/bridge/useStore.ts +++ b/packages/ui/src/bridge/useStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import type { AgentTask, BacklogItem, CognitiveMeshHealth, HostMessage, SessionState, Team } from './types' -export type ActivePanel = 'fleet' | 'backlog' | 'teams' | 'handoff' | 'cogmesh' | 'onboard' +export type ActivePanel = 'fleet' | 'backlog' | 'teams' | 'handoff' | 'cogmesh' | 'ask' | 'onboard' interface SyncStatus { state: 'idle' | 'running' | 'error' | 'success' diff --git a/packages/ui/src/panels/AskPanel.tsx b/packages/ui/src/panels/AskPanel.tsx new file mode 100644 index 0000000..8cc6701 --- /dev/null +++ b/packages/ui/src/panels/AskPanel.tsx @@ -0,0 +1,138 @@ +import { useState, useEffect, useRef } from 'react' +import type { AskResponse, RouteResult } from '../../../router/src/types' + +interface AskPanelProps { + /** Base URL of the state-watcher HTTP server, e.g. "http://127.0.0.1:4321" */ + baseUrl: string +} + +function ConfidenceBar({ value }: { value: number }) { + const pct = Math.round(value * 100) + return ( +
+
+ {pct}% +
+ ) +} + +function ResultCard({ result }: { result: RouteResult }) { + return ( +
+
+ {result.name} + +
+

{result.explanation}

+ {result.suggestedCommand && ( + {result.suggestedCommand} + )} + {result.configSnippet && ( +
{result.configSnippet}
+ )} +
+ ) +} + +export function AskPanel({ baseUrl }: AskPanelProps) { + const [query, setQuery] = useState('') + const [response, setResponse] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const debounceRef = useRef | undefined>(undefined) + const inputRef = useRef(null) + + useEffect(() => { + inputRef.current?.focus() + }, []) + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current) + if (!query.trim()) { + setResponse(null) + setError(null) + return + } + + debounceRef.current = setTimeout(() => { + void fetchResults(query) + }, 300) + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current) + } + }, [query]) + + async function fetchResults(q: string) { + setLoading(true) + setError(null) + try { + const res = await fetch(`${baseUrl}/api/ask?q=${encodeURIComponent(q)}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = (await res.json()) as AskResponse + setResponse(data) + } catch (err) { + setError(err instanceof Error ? err.message : 'Request failed') + } finally { + setLoading(false) + } + } + + function renderBody() { + if (!query.trim()) { + return ( +

+ Ask about agent teams, rules, or configuration. +
+ e.g. "Which team handles video content?" +

+ ) + } + + if (loading) return

Searching…

+ + if (error) return

Error: {error}

+ + if (!response) return null + + if (response.scopeViolation) { + return ( +

+ That question is outside my scope. Try asking about Retort teams, agents, or configuration. + For implementation help, use /team-frontend or similar commands. +

+ ) + } + + if (response.results.length === 0) { + return

No matching teams or agents found.

+ } + + return ( +
    + {response.results.map((r) => ( +
  • + +
  • + ))} +
+ ) + } + + return ( +
+
+ setQuery(e.target.value)} + aria-label="Ask about agent teams" + /> +
+
{renderBody()}
+
+ ) +}