diff --git a/package-lock.json b/package-lock.json index 8cbba04..21cdc40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@types/blessed": "^0.1.27", + "better-sqlite3": "^12.6.2", "blessed": "^0.1.81", "chalk": "^4.1.2", "commander": "^12.0.0", @@ -24,6 +25,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", "@types/node": "^20.11.0", "@types/react": "^19.2.14", @@ -1621,6 +1623,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/blessed": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/@types/blessed/-/blessed-0.1.27.tgz", @@ -2026,6 +2038,26 @@ "js-tokens": "^10.0.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -2039,6 +2071,20 @@ "node": ">=6.0.0" } }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -2049,6 +2095,26 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/blessed": { "version": "0.1.81", "resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz", @@ -2119,6 +2185,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2204,6 +2294,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2370,6 +2466,30 @@ "dev": true, "license": "MIT" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2389,6 +2509,15 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -2433,6 +2562,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -2560,6 +2698,15 @@ "node": ">= 0.6" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2631,6 +2778,12 @@ } } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -2670,6 +2823,12 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2741,6 +2900,12 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2870,6 +3035,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2886,6 +3071,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3142,6 +3333,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -3152,6 +3355,21 @@ "node": ">=4" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3177,6 +3395,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -3186,6 +3410,18 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -3372,6 +3608,33 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -3415,6 +3678,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3464,6 +3737,21 @@ "node": ">= 0.10" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -3505,6 +3793,20 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -3590,6 +3892,26 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3620,7 +3942,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3759,6 +4080,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3792,6 +4158,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -3805,6 +4180,15 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3824,6 +4208,34 @@ "dev": true, "license": "MIT" }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3923,6 +4335,18 @@ "node": ">=20" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -4007,6 +4431,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 82e2975..805c3bf 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", "@types/node": "^20.11.0", "@types/react": "^19.2.14", @@ -55,6 +56,7 @@ }, "dependencies": { "@types/blessed": "^0.1.27", + "better-sqlite3": "^12.6.2", "blessed": "^0.1.81", "chalk": "^4.1.2", "commander": "^12.0.0", diff --git a/src/historicalStore.test.ts b/src/historicalStore.test.ts new file mode 100644 index 0000000..a5c3e9a --- /dev/null +++ b/src/historicalStore.test.ts @@ -0,0 +1,442 @@ +/** + * Tests for FABRIC Historical Store + * + * Tests SQLite-based persistent storage for historical session analytics. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + HistoricalStore, + SessionRecord, + TaskMetricsRecord, + ErrorHistoryRecord, +} from './historicalStore.js'; + +// Test database path +const TEST_DB_DIR = path.join(os.tmpdir(), 'fabric-test-' + Date.now()); +const TEST_DB_PATH = path.join(TEST_DB_DIR, 'test-fabric.db'); + +describe('HistoricalStore', () => { + let store: HistoricalStore; + + beforeEach(() => { + // Create test directory + if (!fs.existsSync(TEST_DB_DIR)) { + fs.mkdirSync(TEST_DB_DIR, { recursive: true }); + } + + // Create fresh store instance + store = new HistoricalStore(TEST_DB_PATH); + }); + + afterEach(() => { + // Close and cleanup + store.close(); + if (fs.existsSync(TEST_DB_DIR)) { + fs.rmSync(TEST_DB_DIR, { recursive: true, force: true }); + } + }); + + describe('Session Management', () => { + it('should start a new session', () => { + const sessionId = store.startSession('test-session-1'); + expect(sessionId).toBe('test-session-1'); + expect(store.getCurrentSessionId()).toBe('test-session-1'); + }); + + it('should generate session ID if not provided', () => { + const sessionId = store.startSession(); + expect(sessionId).toMatch(/^session-/); + expect(store.getCurrentSessionId()).toBe(sessionId); + }); + + it('should end session with metrics', () => { + store.startSession('test-session-2'); + store.endSession({ + workerCount: 3, + taskCount: 10, + totalCost: 0.5, + totalTokens: 5000, + }); + + const session = store.getSession('test-session-2'); + expect(session).not.toBeNull(); + expect(session!.worker_count).toBe(3); + expect(session!.task_count).toBe(10); + expect(session!.total_cost).toBe(0.5); + expect(session!.total_tokens).toBe(5000); + }); + + it('should retrieve sessions within time range', () => { + const now = Date.now(); + + // Create multiple sessions + store.startSession('session-1'); + store.endSession({ workerCount: 1, taskCount: 1, totalCost: 0.1, totalTokens: 100 }); + + store.startSession('session-2'); + store.endSession({ workerCount: 2, taskCount: 2, totalCost: 0.2, totalTokens: 200 }); + + const sessions = store.getSessions({ startTime: now - 1000, endTime: now + 10000 }); + expect(sessions.length).toBe(2); + expect(sessions[0].id).toBe('session-2'); // Most recent first + }); + }); + + describe('Task Metrics', () => { + beforeEach(() => { + store.startSession('metrics-test-session'); + }); + + it('should record task completion', () => { + const taskId = store.recordTask({ + workerId: 'worker-1', + taskType: 'bead', + startedAt: Date.now() - 60000, + endedAt: Date.now(), + cost: 0.05, + tokensIn: 500, + tokensOut: 200, + success: true, + retryCount: 0, + }); + + expect(taskId).toMatch(/metrics-test-session-task-\d+/); + + const tasks = store.getTaskMetrics({ sessionId: 'metrics-test-session' }); + expect(tasks.length).toBe(1); + expect(tasks[0].worker_id).toBe('worker-1'); + expect(tasks[0].success).toBe(1); // SQLite stores boolean as 0/1 + expect(tasks[0].duration_ms).toBe(60000); + }); + + it('should record failed tasks', () => { + store.recordTask({ + workerId: 'worker-2', + taskType: 'bead', + startedAt: Date.now() - 30000, + endedAt: Date.now(), + cost: 0.02, + tokensIn: 200, + tokensOut: 50, + success: false, + retryCount: 3, + }); + + const tasks = store.getTaskMetrics({ workerId: 'worker-2' }); + expect(tasks.length).toBe(1); + expect(tasks[0].success).toBe(0); + expect(tasks[0].retry_count).toBe(3); + }); + + it('should filter tasks by worker', () => { + store.recordTask({ + workerId: 'worker-a', + taskType: 'bead', + startedAt: Date.now(), + endedAt: Date.now() + 1000, + cost: 0, + tokensIn: 0, + tokensOut: 0, + success: true, + retryCount: 0, + }); + + store.recordTask({ + workerId: 'worker-b', + taskType: 'bead', + startedAt: Date.now(), + endedAt: Date.now() + 1000, + cost: 0, + tokensIn: 0, + tokensOut: 0, + success: true, + retryCount: 0, + }); + + const workerATasks = store.getTaskMetrics({ workerId: 'worker-a' }); + expect(workerATasks.length).toBe(1); + expect(workerATasks[0].worker_id).toBe('worker-a'); + }); + }); + + describe('Error History', () => { + beforeEach(() => { + store.startSession('error-test-session'); + }); + + it('should record errors', () => { + const errorId = store.recordError({ + workerId: 'worker-1', + errorType: 'network', + errorMessage: 'ECONNREFUSED: Connection refused', + filePath: '/src/api.ts', + timestamp: Date.now(), + }); + + expect(errorId).toBeGreaterThan(0); + + const errors = store.getErrorHistory({ sessionId: 'error-test-session' }); + expect(errors.length).toBe(1); + expect(errors[0].error_type).toBe('network'); + expect(errors[0].file_path).toBe('/src/api.ts'); + }); + + it('should update error resolution', () => { + const errorId = store.recordError({ + workerId: 'worker-1', + errorType: 'permission', + errorMessage: 'EACCES: Permission denied', + timestamp: Date.now(), + }); + + store.updateErrorResolution(errorId, 'Fixed file permissions with chmod', true); + + const errors = store.getErrorHistory({ errorType: 'permission' }); + expect(errors[0].resolution).toBe('Fixed file permissions with chmod'); + expect(errors[0].resolution_successful).toBe(1); + }); + + it('should filter resolved errors', () => { + const errorId = store.recordError({ + workerId: 'worker-1', + errorType: 'timeout', + errorMessage: 'Request timeout', + timestamp: Date.now(), + }); + + store.updateErrorResolution(errorId, 'Increased timeout', true); + + store.recordError({ + workerId: 'worker-2', + errorType: 'timeout', + errorMessage: 'Another timeout', + timestamp: Date.now(), + }); + + const resolvedOnly = store.getErrorHistory({ resolvedOnly: true }); + expect(resolvedOnly.length).toBe(1); + expect(resolvedOnly[0].resolution_successful).toBe(1); + }); + + it('should search for similar errors', () => { + store.recordError({ + workerId: 'worker-1', + errorType: 'network', + errorMessage: 'ECONNREFUSED connection to localhost refused', + timestamp: Date.now() - 10000, + }); + + store.recordError({ + workerId: 'worker-2', + errorType: 'network', + errorMessage: 'ETIMEDOUT connection timeout waiting for response', + timestamp: Date.now() - 5000, + }); + + const similar = store.findSimilarErrors('ECONNREFUSED connection refused', 10); + expect(similar.length).toBeGreaterThan(0); + expect(similar[0].similarity).toBeGreaterThan(0); + }); + }); + + describe('Worker Comparison', () => { + beforeEach(() => { + // Create multiple sessions with tasks for a worker + store.startSession('compare-sess-1'); + store.recordTask({ + workerId: 'test-worker', + taskType: 'bead', + startedAt: Date.now() - 10000, + endedAt: Date.now() - 5000, + cost: 0.1, + tokensIn: 1000, + tokensOut: 500, + success: true, + retryCount: 0, + }); + store.endSession({ workerCount: 1, taskCount: 1, totalCost: 0.1, totalTokens: 1500 }); + + store.startSession('compare-sess-2'); + store.recordTask({ + workerId: 'test-worker', + taskType: 'bead', + startedAt: Date.now() - 4000, + endedAt: Date.now() - 2000, + cost: 0.05, + tokensIn: 500, + tokensOut: 250, + success: true, + retryCount: 0, + }); + store.recordTask({ + workerId: 'test-worker', + taskType: 'bead', + startedAt: Date.now() - 2000, + endedAt: Date.now() - 1000, + cost: 0.08, + tokensIn: 800, + tokensOut: 400, + success: false, + retryCount: 1, + }); + store.endSession({ workerCount: 1, taskCount: 2, totalCost: 0.13, totalTokens: 1950 }); + }); + + it('should get worker comparison metrics', () => { + const metrics = store.getWorkerComparisonMetrics('test-worker'); + + expect(metrics).not.toBeNull(); + expect(metrics!.workerId).toBe('test-worker'); + expect(metrics!.sessionsCount).toBe(2); + expect(metrics!.totalBeadsCompleted).toBe(2); // 2 successful tasks + expect(metrics!.totalErrors).toBe(1); + expect(metrics!.totalCostUsd).toBeCloseTo(0.23, 2); + }); + + it('should return null for unknown worker', () => { + const metrics = store.getWorkerComparisonMetrics('unknown-worker'); + expect(metrics).toBeNull(); + }); + }); + + describe('Learned Recoveries', () => { + beforeEach(() => { + store.startSession('learn-sess'); + }); + + it('should learn from error resolutions', () => { + // Record resolved errors + const error1 = store.recordError({ + workerId: 'w1', + errorType: 'network', + errorMessage: 'ECONNREFUSED connection refused', + timestamp: Date.now() - 5000, + }); + store.updateErrorResolution(error1, 'Retry with exponential backoff', true); + + const error2 = store.recordError({ + workerId: 'w2', + errorType: 'network', + errorMessage: 'ECONNREFUSED connection timeout', + timestamp: Date.now(), + }); + store.updateErrorResolution(error2, 'Retry with exponential backoff', true); + + const learned = store.getLearnedRecoveries(); + expect(learned.length).toBeGreaterThan(0); + expect(learned[0].errorType).toBe('network'); + expect(learned[0].resolution).toBe('Retry with exponential backoff'); + expect(learned[0].occurrenceCount).toBe(2); + expect(learned[0].successRate).toBe(1); + }); + }); + + describe('Aggregated Analytics', () => { + beforeEach(() => { + store.startSession('agg-sess-1'); + store.recordTask({ + workerId: 'worker-a', + taskType: 'bead', + startedAt: Date.now() - 10000, + endedAt: Date.now() - 5000, + cost: 0.1, + tokensIn: 1000, + tokensOut: 500, + success: true, + retryCount: 0, + }); + store.endSession({ workerCount: 1, taskCount: 1, totalCost: 0.1, totalTokens: 1500 }); + }); + + it('should get aggregated analytics', () => { + const analytics = store.getAggregatedAnalytics(); + + expect(analytics.totalWorkers).toBeGreaterThanOrEqual(1); + expect(analytics.totalBeadsCompleted).toBeGreaterThanOrEqual(1); + expect(analytics.totalCostUsd).toBeGreaterThanOrEqual(0.1); + }); + + it('should filter by time range', () => { + const now = Date.now(); + const analytics = store.getAggregatedAnalytics({ + startTime: now + 10000, // Future time - should be empty + endTime: now + 20000, + }); + + expect(analytics.totalBeadsCompleted).toBe(0); + }); + }); + + describe('Database Statistics', () => { + it('should return database stats', () => { + store.startSession('stats-sess'); + store.recordTask({ + workerId: 'w1', + taskType: 'bead', + startedAt: Date.now(), + endedAt: Date.now() + 1000, + cost: 0.01, + tokensIn: 100, + tokensOut: 50, + success: true, + retryCount: 0, + }); + store.recordError({ + workerId: 'w1', + errorType: 'test', + errorMessage: 'Test error', + timestamp: Date.now(), + }); + + const stats = store.getStats(); + + expect(stats.sessionsCount).toBeGreaterThanOrEqual(1); + expect(stats.tasksCount).toBeGreaterThanOrEqual(1); + expect(stats.errorsCount).toBeGreaterThanOrEqual(1); + expect(stats.dbSizeBytes).toBeGreaterThan(0); + }); + }); + + describe('Clear and Reset', () => { + it('should clear all data', () => { + store.startSession('clear-sess'); + store.recordTask({ + workerId: 'w1', + taskType: 'bead', + startedAt: Date.now(), + endedAt: Date.now() + 1000, + cost: 0.01, + tokensIn: 100, + tokensOut: 50, + success: true, + retryCount: 0, + }); + + store.clear(); + + const stats = store.getStats(); + expect(stats.sessionsCount).toBe(0); + expect(stats.tasksCount).toBe(0); + expect(stats.errorsCount).toBe(0); + }); + }); + + describe('Database Path', () => { + it('should return database path', () => { + const dbPath = store.getDatabasePath(); + expect(dbPath).toBe(TEST_DB_PATH); + }); + + it('should use default path if not specified', () => { + const defaultStore = new HistoricalStore(); + const dbPath = defaultStore.getDatabasePath(); + expect(dbPath).toContain('.needle'); + expect(dbPath).toContain('fabric.db'); + defaultStore.close(); + }); + }); +}); diff --git a/src/historicalStore.ts b/src/historicalStore.ts new file mode 100644 index 0000000..c23fc38 --- /dev/null +++ b/src/historicalStore.ts @@ -0,0 +1,968 @@ +/** + * FABRIC Historical Analytics Storage + * + * SQLite-based persistent storage for historical session analytics. + * Enables worker comparison across sessions and recovery playbook learning. + * + * Schema matches plan.md lines 1016-1124 + */ + +import Database from 'better-sqlite3'; +import * as path from 'path'; +import * as os from 'os'; +import * as fs from 'fs'; +import { + WorkerMetrics, + ErrorCategory, + AggregatedAnalytics, +} from './types.js'; + +// ============================================ +// Type Definitions +// ============================================ + +/** + * Session summary record + */ +export interface SessionRecord { + id: string; + started_at: number; + ended_at: number; + worker_count: number; + task_count: number; + total_cost: number; + total_tokens: number; +} + +/** + * Task metrics record + */ +export interface TaskMetricsRecord { + id: string; + session_id: string; + worker_id: string; + task_type: string; + started_at: number; + ended_at: number; + duration_ms: number; + cost: number; + tokens_in: number; + tokens_out: number; + success: boolean; + retry_count: number; +} + +/** + * Error history record + */ +export interface ErrorHistoryRecord { + id: number; + session_id: string; + worker_id: string; + error_type: string; + error_message: string; + file_path: string | null; + timestamp: number; + resolution: string | null; + resolution_successful: boolean | null; +} + +/** + * Options for querying historical data + */ +export interface HistoricalQueryOptions { + /** Start time (Unix timestamp in ms) */ + startTime?: number; + /** End time (Unix timestamp in ms) */ + endTime?: number; + /** Limit number of results */ + limit?: number; + /** Filter by worker ID */ + workerId?: string; + /** Filter by session ID */ + sessionId?: string; + /** Filter by error type */ + errorType?: string; + /** Filter by error category */ + errorCategory?: ErrorCategory; + /** Include only resolved errors */ + resolvedOnly?: boolean; +} + +/** + * Worker comparison metrics across sessions + */ +export interface WorkerComparisonMetrics { + workerId: string; + sessionsCount: number; + totalBeadsCompleted: number; + avgBeadsPerSession: number; + avgBeadsPerHour: number; + totalErrors: number; + avgErrorRate: number; + totalCostUsd: number; + avgCostPerBead: number; + totalTokens: number; + avgCompletionTimeMs: number; + bestSession: SessionRecord | null; + worstSession: SessionRecord | null; +} + +/** + * Recovery playbook learned entry + */ +export interface LearnedRecoveryEntry { + errorType: string; + errorPattern: string; + resolution: string; + successRate: number; + occurrenceCount: number; + avgResolutionTime: number; + lastSeen: number; +} + +// ============================================ +// Database Schema +// ============================================ + +const SCHEMA_VERSION = 1; + +const CREATE_SESSIONS_TABLE = ` +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + started_at INTEGER NOT NULL, + ended_at INTEGER NOT NULL, + worker_count INTEGER NOT NULL DEFAULT 0, + task_count INTEGER NOT NULL DEFAULT 0, + total_cost REAL NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at); +CREATE INDEX IF NOT EXISTS idx_sessions_ended ON sessions(ended_at); +`; + +const CREATE_TASK_METRICS_TABLE = ` +CREATE TABLE IF NOT EXISTS task_metrics ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + task_type TEXT NOT NULL, + started_at INTEGER NOT NULL, + ended_at INTEGER NOT NULL, + duration_ms INTEGER NOT NULL DEFAULT 0, + cost REAL NOT NULL DEFAULT 0, + tokens_in INTEGER NOT NULL DEFAULT 0, + tokens_out INTEGER NOT NULL DEFAULT 0, + success INTEGER NOT NULL DEFAULT 1, + retry_count INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (session_id) REFERENCES sessions(id) +); + +CREATE INDEX IF NOT EXISTS idx_task_metrics_session ON task_metrics(session_id); +CREATE INDEX IF NOT EXISTS idx_task_metrics_worker ON task_metrics(worker_id); +CREATE INDEX IF NOT EXISTS idx_task_metrics_started ON task_metrics(started_at); +`; + +const CREATE_ERROR_HISTORY_TABLE = ` +CREATE TABLE IF NOT EXISTS error_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + error_type TEXT NOT NULL, + error_message TEXT NOT NULL, + file_path TEXT, + timestamp INTEGER NOT NULL, + resolution TEXT, + resolution_successful INTEGER, + FOREIGN KEY (session_id) REFERENCES sessions(id) +); + +CREATE INDEX IF NOT EXISTS idx_error_history_session ON error_history(session_id); +CREATE INDEX IF NOT EXISTS idx_error_history_worker ON error_history(worker_id); +CREATE INDEX IF NOT EXISTS idx_error_history_type ON error_history(error_type); +CREATE INDEX IF NOT EXISTS idx_error_history_timestamp ON error_history(timestamp); +`; + +const CREATE_SCHEMA_VERSION_TABLE = ` +CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY +); +`; + +// ============================================ +// Historical Store Class +// ============================================ + +/** + * SQLite-backed historical analytics storage + */ +export class HistoricalStore { + private db: Database.Database; + private dbPath: string; + private currentSessionId: string | null = null; + private sessionStartTime: number = 0; + private taskCounter: number = 0; + private errorCounter: number = 0; + + /** + * Create or open the historical store + */ + constructor(dbPath?: string) { + // Default to ~/.needle/fabric.db + this.dbPath = dbPath || path.join(os.homedir(), '.needle', 'fabric.db'); + + // Ensure directory exists + const dbDir = path.dirname(this.dbPath); + if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); + } + + // Open database + this.db = new Database(this.dbPath); + + // Enable WAL mode for better concurrency + this.db.pragma('journal_mode = WAL'); + + // Initialize schema + this.initializeSchema(); + } + + /** + * Initialize database schema + */ + private initializeSchema(): void { + // Create schema_version table first (idempotent) + this.db.exec(CREATE_SCHEMA_VERSION_TABLE); + + // Check current schema version + const versionRow = this.db.prepare('SELECT version FROM schema_version').get() as { version: number } | undefined; + const currentVersion = versionRow?.version || 0; + + if (currentVersion < SCHEMA_VERSION) { + // Run schema migrations + this.db.exec(CREATE_SESSIONS_TABLE); + this.db.exec(CREATE_TASK_METRICS_TABLE); + this.db.exec(CREATE_ERROR_HISTORY_TABLE); + + // Update version + this.db.prepare('INSERT OR REPLACE INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION); + } + } + + /** + * Start a new session + */ + startSession(sessionId?: string): string { + this.currentSessionId = sessionId || this.generateSessionId(); + this.sessionStartTime = Date.now(); + this.taskCounter = 0; + this.errorCounter = 0; + + // Insert session record + this.db.prepare(` + INSERT INTO sessions (id, started_at, ended_at, worker_count, task_count, total_cost, total_tokens) + VALUES (?, ?, ?, 0, 0, 0, 0) + `).run(this.currentSessionId, this.sessionStartTime, this.sessionStartTime); + + return this.currentSessionId; + } + + /** + * End the current session and write final metrics + */ + endSession(metrics: { + workerCount: number; + taskCount: number; + totalCost: number; + totalTokens: number; + }): void { + if (!this.currentSessionId) return; + + const endTime = Date.now(); + + this.db.prepare(` + UPDATE sessions + SET ended_at = ?, worker_count = ?, task_count = ?, total_cost = ?, total_tokens = ? + WHERE id = ? + `).run( + endTime, + metrics.workerCount, + metrics.taskCount, + metrics.totalCost, + metrics.totalTokens, + this.currentSessionId + ); + + this.currentSessionId = null; + this.sessionStartTime = 0; + } + + /** + * Record a task completion + */ + recordTask(task: { + workerId: string; + taskType: string; + startedAt: number; + endedAt: number; + cost: number; + tokensIn: number; + tokensOut: number; + success: boolean; + retryCount: number; + }): string { + if (!this.currentSessionId) { + this.startSession(); + } + + const taskId = `${this.currentSessionId}-task-${++this.taskCounter}`; + const durationMs = task.endedAt - task.startedAt; + + this.db.prepare(` + INSERT INTO task_metrics ( + id, session_id, worker_id, task_type, started_at, ended_at, + duration_ms, cost, tokens_in, tokens_out, success, retry_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + taskId, + this.currentSessionId, + task.workerId, + task.taskType, + task.startedAt, + task.endedAt, + durationMs, + task.cost, + task.tokensIn, + task.tokensOut, + task.success ? 1 : 0, + task.retryCount + ); + + // Update session task count + this.db.prepare(` + UPDATE sessions SET task_count = task_count + 1 WHERE id = ? + `).run(this.currentSessionId); + + return taskId; + } + + /** + * Record an error occurrence + */ + recordError(error: { + workerId: string; + errorType: string; + errorMessage: string; + filePath?: string; + timestamp: number; + }): number { + if (!this.currentSessionId) { + this.startSession(); + } + + const result = this.db.prepare(` + INSERT INTO error_history ( + session_id, worker_id, error_type, error_message, file_path, timestamp + ) VALUES (?, ?, ?, ?, ?, ?) + `).run( + this.currentSessionId, + error.workerId, + error.errorType, + error.errorMessage, + error.filePath || null, + error.timestamp + ); + + return result.lastInsertRowid as number; + } + + /** + * Update error resolution + */ + updateErrorResolution( + errorId: number, + resolution: string, + successful: boolean + ): void { + this.db.prepare(` + UPDATE error_history + SET resolution = ?, resolution_successful = ? + WHERE id = ? + `).run(resolution, successful ? 1 : 0, errorId); + } + + // ============================================ + // Query Methods + // ============================================ + + /** + * Get sessions within a time range + */ + getSessions(options: HistoricalQueryOptions = {}): SessionRecord[] { + const { startTime = 0, endTime = Date.now(), limit = 100 } = options; + + const rows = this.db.prepare(` + SELECT * FROM sessions + WHERE started_at >= ? AND ended_at <= ? + ORDER BY started_at DESC + LIMIT ? + `).all(startTime, endTime, limit) as SessionRecord[]; + + return rows; + } + + /** + * Get a specific session by ID + */ + getSession(sessionId: string): SessionRecord | null { + const row = this.db.prepare(` + SELECT * FROM sessions WHERE id = ? + `).get(sessionId) as SessionRecord | undefined; + + return row || null; + } + + /** + * Get task metrics for a session or worker + */ + getTaskMetrics(options: HistoricalQueryOptions = {}): TaskMetricsRecord[] { + const { sessionId, workerId, startTime, endTime, limit = 1000 } = options; + + let query = 'SELECT * FROM task_metrics WHERE 1=1'; + const params: (string | number)[] = []; + + if (sessionId) { + query += ' AND session_id = ?'; + params.push(sessionId); + } + + if (workerId) { + query += ' AND worker_id = ?'; + params.push(workerId); + } + + if (startTime) { + query += ' AND started_at >= ?'; + params.push(startTime); + } + + if (endTime) { + query += ' AND ended_at <= ?'; + params.push(endTime); + } + + query += ' ORDER BY started_at DESC LIMIT ?'; + params.push(limit); + + const rows = this.db.prepare(query).all(...params) as TaskMetricsRecord[]; + + return rows; + } + + /** + * Get error history + */ + getErrorHistory(options: HistoricalQueryOptions = {}): ErrorHistoryRecord[] { + const { + sessionId, + workerId, + errorType, + startTime, + endTime, + resolvedOnly, + limit = 1000, + } = options; + + let query = 'SELECT * FROM error_history WHERE 1=1'; + const params: (string | number | null)[] = []; + + if (sessionId) { + query += ' AND session_id = ?'; + params.push(sessionId); + } + + if (workerId) { + query += ' AND worker_id = ?'; + params.push(workerId); + } + + if (errorType) { + query += ' AND error_type = ?'; + params.push(errorType); + } + + if (startTime) { + query += ' AND timestamp >= ?'; + params.push(startTime); + } + + if (endTime) { + query += ' AND timestamp <= ?'; + params.push(endTime); + } + + if (resolvedOnly) { + query += ' AND resolution_successful = 1'; + } + + query += ' ORDER BY timestamp DESC LIMIT ?'; + params.push(limit); + + const rows = this.db.prepare(query).all(...params) as ErrorHistoryRecord[]; + + return rows; + } + + /** + * Get worker comparison metrics across sessions + */ + getWorkerComparisonMetrics(workerId: string): WorkerComparisonMetrics | null { + // Get all task metrics for this worker + const tasks = this.db.prepare(` + SELECT * FROM task_metrics WHERE worker_id = ? + `).all(workerId) as TaskMetricsRecord[]; + + if (tasks.length === 0) { + return null; + } + + // Get unique sessions + const sessionIds = [...new Set(tasks.map(t => t.session_id))]; + const sessions = this.db.prepare(` + SELECT * FROM sessions WHERE id IN (${sessionIds.map(() => '?').join(',')}) + `).all(...sessionIds) as SessionRecord[]; + + // Calculate metrics + const sessionsMap = new Map(sessions.map(s => [s.id, s])); + + let totalBeadsCompleted = 0; + let totalErrors = 0; + let totalCostUsd = 0; + let totalTokens = 0; + let totalDurationMs = 0; + let successCount = 0; + + for (const task of tasks) { + if (task.success) { + totalBeadsCompleted++; + totalDurationMs += task.duration_ms; + successCount++; + } else { + totalErrors++; + } + totalCostUsd += task.cost; + totalTokens += task.tokens_in + task.tokens_out; + } + + // Find best and worst sessions + let bestSession: SessionRecord | null = null; + let worstSession: SessionRecord | null = null; + let bestTaskCount = 0; + let worstTaskCount = Infinity; + + for (const session of sessions) { + const sessionTasks = tasks.filter(t => t.session_id === session.id && t.success); + if (sessionTasks.length > bestTaskCount) { + bestTaskCount = sessionTasks.length; + bestSession = session; + } + if (sessionTasks.length < worstTaskCount && sessionTasks.length > 0) { + worstTaskCount = sessionTasks.length; + worstSession = session; + } + } + + const avgBeadsPerSession = sessions.length > 0 ? totalBeadsCompleted / sessions.length : 0; + + // Calculate average time span per session + let totalTimeHours = 0; + for (const session of sessions) { + totalTimeHours += (session.ended_at - session.started_at) / 3600000; + } + const avgBeadsPerHour = totalTimeHours > 0 ? totalBeadsCompleted / totalTimeHours : 0; + + const avgErrorRate = totalBeadsCompleted + totalErrors > 0 + ? totalErrors / (totalBeadsCompleted + totalErrors) + : 0; + + const avgCostPerBead = totalBeadsCompleted > 0 ? totalCostUsd / totalBeadsCompleted : 0; + + const avgCompletionTimeMs = successCount > 0 ? totalDurationMs / successCount : 0; + + return { + workerId, + sessionsCount: sessions.length, + totalBeadsCompleted, + avgBeadsPerSession, + avgBeadsPerHour, + totalErrors, + avgErrorRate, + totalCostUsd, + avgCostPerBead, + totalTokens, + avgCompletionTimeMs, + bestSession, + worstSession, + }; + } + + /** + * Get learned recovery patterns from error history + */ + getLearnedRecoveries(): LearnedRecoveryEntry[] { + const rows = this.db.prepare(` + SELECT + error_type, + error_message, + resolution, + resolution_successful, + timestamp + FROM error_history + WHERE resolution IS NOT NULL + ORDER BY timestamp DESC + `).all() as { + error_type: string; + error_message: string; + resolution: string; + resolution_successful: number; + timestamp: number; + }[]; + + // Group by error type and resolution + const grouped = new Map(); + + for (const row of rows) { + const key = `${row.error_type}::${row.resolution}`; + const existing = grouped.get(key); + if (existing) { + existing.entries.push(row); + if (row.resolution_successful) { + existing.successCount++; + } + } else { + grouped.set(key, { + entries: [row], + successCount: row.resolution_successful ? 1 : 0, + totalResolutionTime: 0, + }); + } + } + + // Convert to learned entries + const learned: LearnedRecoveryEntry[] = []; + + for (const [key, data] of grouped) { + const [errorType, resolution] = key.split('::'); + const successRate = data.entries.length > 0 + ? data.successCount / data.entries.length + : 0; + + // Extract error pattern (simplified - first 50 chars) + const errorPattern = data.entries[0].error_message.slice(0, 50); + + learned.push({ + errorType, + errorPattern, + resolution, + successRate, + occurrenceCount: data.entries.length, + avgResolutionTime: 0, // Would need additional tracking for this + lastSeen: Math.max(...data.entries.map(e => e.timestamp)), + }); + } + + // Sort by occurrence count (most common first) + return learned.sort((a, b) => b.occurrenceCount - a.occurrenceCount); + } + + /** + * Search for similar errors in history + */ + findSimilarErrors( + errorMessage: string, + limit: number = 10 + ): (ErrorHistoryRecord & { similarity: number })[] { + // Simple substring matching - could be enhanced with fuzzy matching + const searchTerms = errorMessage.toLowerCase().split(/\s+/).filter(t => t.length > 3); + + if (searchTerms.length === 0) { + return []; + } + + const rows = this.db.prepare(` + SELECT * FROM error_history + WHERE ${searchTerms.map(() => 'LOWER(error_message) LIKE ?').join(' OR ')} + ORDER BY timestamp DESC + LIMIT ? + `).all( + ...searchTerms.map(t => `%${t}%`), + limit + ) as ErrorHistoryRecord[]; + + // Calculate simple similarity score + return rows.map(row => { + const lowerMsg = row.error_message.toLowerCase(); + const matchCount = searchTerms.filter(t => lowerMsg.includes(t)).length; + const similarity = matchCount / searchTerms.length; + + return { ...row, similarity }; + }).sort((a, b) => b.similarity - a.similarity); + } + + /** + * Get aggregated analytics for a time period + */ + getAggregatedAnalytics(options: HistoricalQueryOptions = {}): AggregatedAnalytics { + const { startTime = 0, endTime = Date.now() } = options; + + // Get sessions in range + const sessions = this.getSingsInRange(startTime, endTime); + + // Get task metrics in range + const tasks = this.db.prepare(` + SELECT * FROM task_metrics + WHERE started_at >= ? AND ended_at <= ? + `).all(startTime, endTime) as TaskMetricsRecord[]; + + // Calculate aggregated metrics + const workerMap = new Map(); + + let totalBeadsCompleted = 0; + let totalErrors = 0; + let totalCostUsd = 0; + let totalTokens = 0; + let totalCompletionTime = 0; + let successCount = 0; + + for (const task of tasks) { + let worker = workerMap.get(task.worker_id); + if (!worker) { + worker = { + tasksCompleted: 0, + errors: 0, + cost: 0, + tokens: 0, + completionTimes: [], + }; + workerMap.set(task.worker_id, worker); + } + + if (task.success) { + totalBeadsCompleted++; + totalCompletionTime += task.duration_ms; + successCount++; + worker.tasksCompleted++; + worker.completionTimes.push(task.duration_ms); + } else { + totalErrors++; + worker.errors++; + } + + totalCostUsd += task.cost; + totalTokens += task.tokens_in + task.tokens_out; + worker.cost += task.cost; + worker.tokens += task.tokens_in + task.tokens_out; + } + + const totalTimeMs = endTime - startTime; + const totalTimeHours = totalTimeMs / 3600000; + const avgBeadsPerHour = totalTimeHours > 0 ? totalBeadsCompleted / totalTimeHours : 0; + const avgCompletionTimeMs = successCount > 0 ? totalCompletionTime / successCount : 0; + const overallErrorRate = totalBeadsCompleted + totalErrors > 0 + ? totalErrors / (totalBeadsCompleted + totalErrors) + : 0; + const avgCostPerBead = totalBeadsCompleted > 0 ? totalCostUsd / totalBeadsCompleted : 0; + + // Build top performers list + const topPerformers: WorkerMetrics[] = []; + for (const [workerId, data] of workerMap) { + const avgCompletion = data.completionTimes.length > 0 + ? data.completionTimes.reduce((a, b) => a + b, 0) / data.completionTimes.length + : 0; + + topPerformers.push({ + workerId, + periodStart: startTime, + periodEnd: endTime, + beadsCompleted: data.tasksCompleted, + beadsPerHour: totalTimeHours > 0 ? data.tasksCompleted / totalTimeHours : 0, + avgCompletionTimeMs: avgCompletion, + errorCount: data.errors, + errorRate: data.tasksCompleted + data.errors > 0 + ? data.errors / (data.tasksCompleted + data.errors) + : 0, + totalCostUsd: data.cost, + costPerBead: data.tasksCompleted > 0 ? data.cost / data.tasksCompleted : 0, + activeTimeMs: totalTimeMs, + idleTimeMs: 0, + idlePercentage: 0, + totalEvents: data.tasksCompleted + data.errors, + totalTokens: data.tokens, + tokensPerBead: data.tasksCompleted > 0 ? data.tokens / data.tasksCompleted : 0, + efficiencyScore: data.tasksCompleted > 0 ? 1 : 0, + }); + } + + // Sort by beads completed + topPerformers.sort((a, b) => b.beadsCompleted - a.beadsCompleted); + + return { + periodStart: startTime, + periodEnd: endTime, + totalWorkers: workerMap.size, + totalBeadsCompleted, + avgBeadsPerHour, + avgCompletionTimeMs, + totalErrors, + overallErrorRate, + totalCostUsd, + avgCostPerBead, + topPerformers: topPerformers.slice(0, 10), + highErrorRateWorkers: topPerformers.filter(w => w.errorRate > 0.2).slice(0, 10), + costEfficientWorkers: [...topPerformers] + .sort((a, b) => a.costPerBead - b.costPerBead) + .slice(0, 10), + activeWorkerCount: workerMap.size, + totalTokens, + avgEfficiency: topPerformers.length > 0 + ? topPerformers.reduce((sum, w) => sum + w.efficiencyScore, 0) / topPerformers.length + : 0, + underperformers: [], + }; + } + + /** + * Helper to get sessions in a time range + */ + private getSingsInRange(startTime: number, endTime: number): SessionRecord[] { + return this.db.prepare(` + SELECT * FROM sessions + WHERE started_at >= ? AND ended_at <= ? + ORDER BY started_at ASC + `).all(startTime, endTime) as SessionRecord[]; + } + + // ============================================ + // Utility Methods + // ============================================ + + /** + * Generate a unique session ID + */ + private generateSessionId(): string { + return `session-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; + } + + /** + * Get the current session ID + */ + getCurrentSessionId(): string | null { + return this.currentSessionId; + } + + /** + * Get database path + */ + getDatabasePath(): string { + return this.dbPath; + } + + /** + * Close the database connection + */ + close(): void { + if (this.currentSessionId) { + // Auto-end session if still open + this.endSession({ + workerCount: 0, + taskCount: this.taskCounter, + totalCost: 0, + totalTokens: 0, + }); + } + this.db.close(); + } + + /** + * Clear all historical data + */ + clear(): void { + this.db.exec('DELETE FROM error_history'); + this.db.exec('DELETE FROM task_metrics'); + this.db.exec('DELETE FROM sessions'); + } + + /** + * Get database statistics + */ + getStats(): { + sessionsCount: number; + tasksCount: number; + errorsCount: number; + dbSizeBytes: number; + oldestSession: number | null; + newestSession: number | null; + } { + const sessionsCount = (this.db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number }).count; + const tasksCount = (this.db.prepare('SELECT COUNT(*) as count FROM task_metrics').get() as { count: number }).count; + const errorsCount = (this.db.prepare('SELECT COUNT(*) as count FROM error_history').get() as { count: number }).count; + + const oldestRow = this.db.prepare('SELECT MIN(started_at) as oldest FROM sessions').get() as { oldest: number | null }; + const newestRow = this.db.prepare('SELECT MAX(started_at) as newest FROM sessions').get() as { newest: number | null }; + + // Get file size + let dbSizeBytes = 0; + try { + const stats = fs.statSync(this.dbPath); + dbSizeBytes = stats.size; + } catch { + // Ignore + } + + return { + sessionsCount, + tasksCount, + errorsCount, + dbSizeBytes, + oldestSession: oldestRow.oldest, + newestSession: newestRow.newest, + }; + } +} + +// ============================================ +// Singleton Instance +// ============================================ + +let globalHistoricalStore: HistoricalStore | undefined; + +/** + * Get the global historical store instance + */ +export function getHistoricalStore(): HistoricalStore { + if (!globalHistoricalStore) { + globalHistoricalStore = new HistoricalStore(); + } + return globalHistoricalStore; +} + +/** + * Reset the global historical store + */ +export function resetHistoricalStore(): void { + if (globalHistoricalStore) { + globalHistoricalStore.close(); + globalHistoricalStore = undefined; + } +} diff --git a/src/index.ts b/src/index.ts index 7418194..4d038f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,3 +26,14 @@ export * from './types.js'; export { SessionDigestGenerator, formatDigestAsMarkdown } from './sessionDigest.js'; export { WorkerAnalytics, getWorkerAnalytics, resetWorkerAnalytics } from './workerAnalytics.js'; export { SemanticNarrativeGenerator, getSemanticNarrativeManager } from './semanticNarrative.js'; +export { + HistoricalStore, + getHistoricalStore, + resetHistoricalStore, + SessionRecord, + TaskMetricsRecord, + ErrorHistoryRecord, + HistoricalQueryOptions, + WorkerComparisonMetrics, + LearnedRecoveryEntry, +} from './historicalStore.js'; diff --git a/src/store.ts b/src/store.ts index 97157d6..c60551f 100644 --- a/src/store.ts +++ b/src/store.ts @@ -41,6 +41,7 @@ import { RecoveryManager, getRecoveryManager } from './tui/utils/recoveryPlayboo import { CrossReferenceManager, getCrossReferenceManager } from './crossReferenceManager.js'; import { WorkerAnalytics, getWorkerAnalytics } from './workerAnalytics.js'; import { SemanticNarrativeGenerator, getSemanticNarrativeManager } from './semanticNarrative.js'; +import { HistoricalStore, getHistoricalStore } from './historicalStore.js'; /** Time window (in ms) to consider events as concurrent */ const COLLISION_WINDOW_MS = 5000; @@ -86,10 +87,13 @@ export class InMemoryEventStore implements EventStore { private crossReferenceManager: CrossReferenceManager; private workerAnalytics: WorkerAnalytics; private semanticNarrativeManager: SemanticNarrativeGenerator; + private historicalStore: HistoricalStore; private maxEvents: number; private alertCounter = 0; private batchBuffer: LogEvent[] = []; private batchTimeout: NodeJS.Timeout | null = null; + private sessionStartTime: number = 0; + private taskStartTimes: Map = new Map(); // beadId -> startTime constructor(maxEvents: number = 10000) { this.maxEvents = maxEvents; @@ -98,6 +102,9 @@ export class InMemoryEventStore implements EventStore { this.crossReferenceManager = getCrossReferenceManager(); this.workerAnalytics = getWorkerAnalytics(); this.semanticNarrativeManager = getSemanticNarrativeManager(); + this.historicalStore = getHistoricalStore(); + this.sessionStartTime = Date.now(); + this.historicalStore.startSession(); } /** @@ -111,6 +118,11 @@ export class InMemoryEventStore implements EventStore { this.detectTaskCollision(event); this.trackFileModification(event); + // Track task starts and completions for historical storage + if (event.bead) { + this.trackTaskForHistory(event); + } + // Track errors in error groups if (event.level === 'error') { this.errorGroupManager.addError(event); @@ -205,6 +217,9 @@ export class InMemoryEventStore implements EventStore { * Clear all events */ clear(): void { + // Persist session data before clearing + this.persistSession(); + this.events = []; this.workers.clear(); this.collisions.clear(); @@ -214,12 +229,90 @@ export class InMemoryEventStore implements EventStore { this.errorGroupManager.clear(); this.crossReferenceManager.clear(); this.batchBuffer = []; + this.taskStartTimes.clear(); if (this.batchTimeout) { clearTimeout(this.batchTimeout); this.batchTimeout = null; } } + /** + * Persist current session to historical store + */ + private persistSession(): void { + if (this.events.length === 0) return; + + // Calculate session metrics + const analytics = this.workerAnalytics.getAggregatedAnalytics({ timeWindow: 'all' }); + + // End the historical session + this.historicalStore.endSession({ + workerCount: this.workers.size, + taskCount: analytics.totalBeadsCompleted, + totalCost: analytics.totalCostUsd, + totalTokens: analytics.totalTokens, + }); + + // Record any completed tasks that haven't been recorded yet + for (const [beadId, startTime] of this.taskStartTimes) { + // Find the completion event for this bead + const completionEvent = this.events.find(e => + e.bead === beadId && + (e.msg.toLowerCase().includes('completed') || + e.msg.toLowerCase().includes('finished') || + e.msg.toLowerCase().includes('closed')) + ); + + if (completionEvent) { + // Find which worker worked on this bead + const workerEvents = this.events.filter(e => e.bead === beadId); + const workerId = workerEvents[0]?.worker || 'unknown'; + + // Get cost info for this task + const costSummary = this.workerAnalytics.getAllWorkerMetrics({ workerIds: [workerId] }); + const workerCost = costSummary[0]?.totalCostUsd || 0; + const workerTokens = costSummary[0]?.totalTokens || 0; + + this.historicalStore.recordTask({ + workerId, + taskType: 'bead', + startedAt: startTime, + endedAt: completionEvent.ts, + cost: workerCost, + tokensIn: Math.floor(workerTokens * 0.7), // Estimate + tokensOut: Math.floor(workerTokens * 0.3), // Estimate + success: completionEvent.level !== 'error', + retryCount: 0, + }); + } + } + + // Record errors from error groups + const errorGroups = this.errorGroupManager.getGroups(); + for (const group of errorGroups) { + for (const event of group.events) { + this.historicalStore.recordError({ + workerId: event.worker, + errorType: group.fingerprint.category, + errorMessage: group.fingerprint.sampleMessage, + filePath: event.path, + timestamp: event.ts, + }); + } + } + + // Start a new session + this.sessionStartTime = Date.now(); + this.historicalStore.startSession(); + } + + /** + * Get historical store for queries + */ + getHistoricalStore(): HistoricalStore { + return this.historicalStore; + } + /** * Get all error groups */ @@ -347,6 +440,54 @@ export class InMemoryEventStore implements EventStore { return FILE_MODIFICATION_TOOLS.includes(event.tool); } + /** + * Track task events for historical storage + */ + private trackTaskForHistory(event: LogEvent): void { + const beadId = event.bead!; + + // Track task start + if (!this.taskStartTimes.has(beadId)) { + this.taskStartTimes.set(beadId, event.ts); + } + + // Check for task completion + const msg = event.msg?.toLowerCase() || ''; + if ( + msg.includes('completed') || + msg.includes('finished') || + msg.includes('done') || + msg.includes('success') || + msg.includes('closed') + ) { + const startTime = this.taskStartTimes.get(beadId); + if (startTime) { + const durationMs = event.ts - startTime; + + // Get cost info for this worker + const workerMetrics = this.workerAnalytics.getWorkerMetrics(event.worker); + const cost = workerMetrics?.costPerBead || 0; + const tokensIn = Math.floor((workerMetrics?.totalTokens || 0) * 0.7); + const tokensOut = Math.floor((workerMetrics?.totalTokens || 0) * 0.3); + + this.historicalStore.recordTask({ + workerId: event.worker, + taskType: 'bead', + startedAt: startTime, + endedAt: event.ts, + cost, + tokensIn, + tokensOut, + success: event.level !== 'error', + retryCount: 0, + }); + + // Clean up + this.taskStartTimes.delete(beadId); + } + } + } + /** * Detect collision when a file modification event occurs */ diff --git a/src/tui/utils/recoveryPlaybook.ts b/src/tui/utils/recoveryPlaybook.ts index c6b661c..f0863b8 100644 --- a/src/tui/utils/recoveryPlaybook.ts +++ b/src/tui/utils/recoveryPlaybook.ts @@ -3,6 +3,7 @@ * * Maps error patterns to actionable recovery steps. * Provides suggestions when workers encounter errors. + * Learns from historical error resolutions. */ import { @@ -16,6 +17,7 @@ import { RecoveryStats, RecoveryPriority, } from '../../types.js'; +import { getHistoricalStore, HistoricalStore, LearnedRecoveryEntry } from '../../historicalStore.js'; // ============================================ // Predefined Recovery Actions @@ -889,6 +891,134 @@ export class RecoveryManager { }; } + // ============================================ + // Historical Error Methods + // ============================================ + + /** + * Search for similar historical errors and their resolutions + */ + searchHistoricalErrors(errorMessage: string, limit: number = 5): Array<{ + error: { + id: number; + workerId: string; + errorType: string; + errorMessage: string; + filePath: string | null; + timestamp: number; + resolution: string | null; + resolutionSuccessful: boolean | null; + }; + similarity: number; + }> { + const store = getHistoricalStore(); + const similar = store.findSimilarErrors(errorMessage, limit); + + return similar.map(e => ({ + error: { + id: e.id, + workerId: e.worker_id, + errorType: e.error_type, + errorMessage: e.error_message, + filePath: e.file_path, + timestamp: e.timestamp, + resolution: e.resolution, + resolutionSuccessful: e.resolution_successful !== null + ? Boolean(e.resolution_successful) + : null, + }, + similarity: e.similarity, + })); + } + + /** + * Get learned recovery patterns from historical data + */ + getLearnedRecoveries(): LearnedRecoveryEntry[] { + const store = getHistoricalStore(); + return store.getLearnedRecoveries(); + } + + /** + * Generate recovery suggestion enhanced with historical data + */ + generateEnhancedSuggestion( + errorGroup: ErrorGroup, + options: RecoveryOptions = {} + ): RecoverySuggestion | null { + // First get the standard suggestion + const standardSuggestion = this.generateSuggestion(errorGroup, options); + if (!standardSuggestion) return null; + + // Search for similar historical errors + const historicalMatches = this.searchHistoricalErrors( + errorGroup.fingerprint.sampleMessage, + 3 + ); + + // Get learned recoveries for this error type + const learnedRecoveries = this.getLearnedRecoveries() + .filter(lr => lr.errorType === errorGroup.fingerprint.category) + .slice(0, 3); + + // Add historical context to actions + if (historicalMatches.length > 0 || learnedRecoveries.length > 0) { + // Add learned actions from history + const historicalActions: RecoveryAction[] = learnedRecoveries + .filter(lr => lr.successRate > 0.5) + .map(lr => ({ + id: `action-learned-${Date.now().toString(36)}`, + type: 'fix_config' as RecoveryActionType, + title: `Learned: ${lr.resolution.slice(0, 50)}...`, + description: `Previously resolved ${lr.occurrenceCount} times with ${(lr.successRate * 100).toFixed(0)}% success rate`, + priority: 'high' as RecoveryPriority, + automated: false, + expectedOutcome: lr.resolution, + riskLevel: 'safe' as const, + estimatedTime: 5, + })); + + // Combine standard actions with learned actions + standardSuggestion.actions = [ + ...standardSuggestion.actions.slice(0, 2), + ...historicalActions, + ...standardSuggestion.actions.slice(2), + ].slice(0, 6); + + // Boost confidence based on historical data + if (historicalMatches.some(m => m.error.resolutionSuccessful)) { + standardSuggestion.confidence = Math.min(standardSuggestion.confidence + 0.2, 1.0); + } + } + + return standardSuggestion; + } + + /** + * Get recovery statistics including historical data + */ + getEnhancedStats(): RecoveryStats & { + historicalErrorsCount: number; + learnedRecoveriesCount: number; + avgHistoricalSuccessRate: number; + } { + const baseStats = this.getStats(); + const store = getHistoricalStore(); + const dbStats = store.getStats(); + const learned = store.getLearnedRecoveries(); + + const avgSuccessRate = learned.length > 0 + ? learned.reduce((sum, lr) => sum + lr.successRate, 0) / learned.length + : 0; + + return { + ...baseStats, + historicalErrorsCount: dbStats.errorsCount, + learnedRecoveriesCount: learned.length, + avgHistoricalSuccessRate: avgSuccessRate, + }; + } + /** * Clear all suggestions */ diff --git a/src/workerAnalytics.ts b/src/workerAnalytics.ts index 7905184..90050c7 100644 --- a/src/workerAnalytics.ts +++ b/src/workerAnalytics.ts @@ -21,6 +21,7 @@ import { TimeWindow, } from './types.js'; import { CostTracker } from './tui/utils/costTracking.js'; +import { getHistoricalStore, HistoricalStore, WorkerComparisonMetrics } from './historicalStore.js'; const DEFAULT_OPTIONS: Required = { timeWindow: 'all', @@ -627,6 +628,78 @@ export class WorkerAnalytics implements WorkerAnalyticsStore { underperformers: [], }; } + + // ============================================ + // Historical Data Methods + // ============================================ + + /** + * Get historical store instance + */ + getHistoricalStore(): HistoricalStore { + return getHistoricalStore(); + } + + /** + * Get worker comparison metrics across sessions + */ + getHistoricalWorkerMetrics(workerId: string): WorkerComparisonMetrics | null { + return getHistoricalStore().getWorkerComparisonMetrics(workerId); + } + + /** + * Get historical aggregated analytics + */ + getHistoricalAnalytics(options: { startTime?: number; endTime?: number } = {}): AggregatedAnalytics { + return getHistoricalStore().getAggregatedAnalytics(options); + } + + /** + * Compare current worker performance with historical averages + */ + compareWithHistory(workerId: string): { + current: WorkerMetrics | null; + historical: WorkerComparisonMetrics | null; + comparison: { + beadsPerHourChange: number; + errorRateChange: number; + costPerBeadChange: number; + efficiencyChange: number; + } | null; + } { + const current = this.getWorkerMetrics(workerId) || null; + const historical = getHistoricalStore().getWorkerComparisonMetrics(workerId); + + let comparison = null; + if (current && historical) { + comparison = { + beadsPerHourChange: historical.avgBeadsPerHour > 0 + ? ((current.beadsPerHour - historical.avgBeadsPerHour) / historical.avgBeadsPerHour) * 100 + : 0, + errorRateChange: ((current.errorRate - historical.avgErrorRate) / (historical.avgErrorRate || 0.01)) * 100, + costPerBeadChange: historical.avgCostPerBead > 0 + ? ((current.costPerBead - historical.avgCostPerBead) / historical.avgCostPerBead) * 100 + : 0, + efficiencyChange: ((current.efficiencyScore - (historical.totalBeadsCompleted > 0 ? 1 : 0)) * 100), + }; + } + + return { current, historical, comparison }; + } + + /** + * Get historical database statistics + */ + getHistoricalStats(): { + sessionsCount: number; + tasksCount: number; + errorsCount: number; + dbSizeBytes: number; + oldestSession: number | null; + newestSession: number | null; + } { + return getHistoricalStore().getStats(); + } } /**