From 029384c427f50d990c51c9f3472b3d84c0cf1440 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sat, 13 Dec 2025 14:53:53 +0800 Subject: [PATCH] feat: Implement SQLite storage for CLI execution history - Introduced a new SQLite-based storage backend for managing CLI execution history. - Added `CliHistoryStore` class to handle conversation records and turns with efficient queries. - Migrated existing JSON history files to the new SQLite format. - Updated CLI executor to use asynchronous and synchronous methods for saving and loading conversations. - Enhanced execution history retrieval with support for filtering by tool, status, and search terms. - Added prompt concatenation utilities to build multi-turn prompts in various formats (plain, YAML, JSON). - Implemented batch deletion of conversations and improved error handling for database operations. --- ccw/package-lock.json | 350 +++++++++ ccw/package.json | 2 + ccw/src/core/server.ts | 38 +- ccw/src/templates/dashboard-css/10-cli.css | 425 ++++++++++- .../dashboard-js/components/cli-history.js | 325 +++++++-- .../dashboard-js/components/cli-status.js | 61 ++ .../templates/dashboard-js/views/history.js | 245 +++++-- ccw/src/tools/cli-executor.ts | 685 ++++++++++++++---- ccw/src/tools/cli-history-store.ts | 528 ++++++++++++++ 9 files changed, 2380 insertions(+), 279 deletions(-) create mode 100644 ccw/src/tools/cli-history-store.ts diff --git a/ccw/package-lock.json b/ccw/package-lock.json index f9d2e1bd..399ddc05 100644 --- a/ccw/package-lock.json +++ b/ccw/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", + "better-sqlite3": "^11.7.0", "boxen": "^7.1.0", "chalk": "^5.3.0", "commander": "^11.0.0", @@ -26,6 +27,7 @@ "ccw-mcp": "bin/ccw-mcp.js" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.12", "@types/gradient-string": "^1.1.6", "@types/inquirer": "^9.0.9", "@types/node": "^25.0.1", @@ -572,6 +574,16 @@ "node": ">=14" } }, + "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/gradient-string": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@types/gradient-string/-/gradient-string-1.1.6.tgz", @@ -792,6 +804,17 @@ ], "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -801,6 +824,15 @@ "node": ">=0.6" } }, + "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": "5.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", @@ -986,6 +1018,12 @@ "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", "license": "MIT" }, + "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/cli-boxes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", @@ -1154,6 +1192,30 @@ } } }, + "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/default-browser": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-4.0.0.tgz", @@ -1221,6 +1283,15 @@ "node": ">= 0.8" } }, + "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/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1262,6 +1333,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/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1399,6 +1479,15 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "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/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -1503,6 +1592,12 @@ "node": ">=20" } }, + "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", @@ -1558,6 +1653,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", @@ -1644,6 +1745,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "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/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -1824,6 +1931,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/inquirer": { "version": "9.3.8", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.3.8.tgz", @@ -2334,6 +2447,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -2349,6 +2474,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -2358,6 +2492,12 @@ "node": ">=16 || 14 >=14.17" } }, + "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", @@ -2373,6 +2513,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "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", @@ -2382,6 +2528,18 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/npm-run-path": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", @@ -2589,6 +2747,32 @@ "node": ">=16.20.0" } }, + "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==", + "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/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2602,6 +2786,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -2641,6 +2835,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/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -2890,6 +3099,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", @@ -3038,6 +3259,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3179,6 +3445,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -3191,6 +3466,69 @@ "node": ">=8" } }, + "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/tar-stream/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/tar-stream/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/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -3254,6 +3592,18 @@ "fsevents": "~2.3.3" } }, + "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-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", diff --git a/ccw/package.json b/ccw/package.json index c048ed6f..7f7b6350 100644 --- a/ccw/package.json +++ b/ccw/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", + "better-sqlite3": "^11.7.0", "boxen": "^7.1.0", "chalk": "^5.3.0", "commander": "^11.0.0", @@ -54,6 +55,7 @@ "url": "https://github.com/claude-code-workflow/ccw" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.12", "@types/gradient-string": "^1.1.6", "@types/inquirer": "^9.0.9", "@types/node": "^25.0.1", diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index 4b4cd6dc..10ab89eb 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -8,7 +8,7 @@ import { createHash } from 'crypto'; import { scanSessions } from './session-scanner.js'; import { aggregateData } from './data-aggregator.js'; import { resolvePath, getRecentPaths, trackRecentPath, removeRecentPath, normalizePathForDisplay, getWorkflowDir } from '../utils/path-resolver.js'; -import { getCliToolsStatus, getExecutionHistory, getExecutionDetail, getConversationDetail, deleteExecution, executeCliTool } from '../tools/cli-executor.js'; +import { getCliToolsStatus, getExecutionHistory, getExecutionHistoryAsync, getExecutionDetail, getConversationDetail, deleteExecution, deleteExecutionAsync, batchDeleteExecutionsAsync, executeCliTool } from '../tools/cli-executor.js'; import { getAllManifests } from './manifest.js'; import { checkVenvStatus, bootstrapVenv, executeCodexLens, checkSemanticStatus, installSemantic } from '../tools/codex-lens.js'; import { listTools } from '../tools/index.js'; @@ -635,6 +635,27 @@ export async function startServer(options: ServerOptions = {}): Promise { + const { storageBackend: backend } = body as { storageBackend?: string }; + + if (backend && (backend === 'sqlite' || backend === 'json')) { + // Import and set storage backend dynamically + try { + const { setStorageBackend } = await import('../tools/cli-executor.js'); + setStorageBackend(backend as 'sqlite' | 'json'); + return { success: true, storageBackend: backend }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + } + + return { success: true, message: 'No changes' }; + }); + return; + } + // API: CLI Execution History if (pathname === '/api/cli/history') { const projectPath = url.searchParams.get('path') || initialPath; @@ -686,6 +707,21 @@ export async function startServer(options: ServerOptions = {}): Promise { + const { path: projectPath, ids } = body; + + if (!ids || !Array.isArray(ids) || ids.length === 0) { + return { error: 'ids array is required', status: 400 }; + } + + const basePath = projectPath || initialPath; + return await batchDeleteExecutionsAsync(basePath, ids); + }); + return; + } + // API: Execute CLI Tool if (pathname === '/api/cli/execute' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { diff --git a/ccw/src/templates/dashboard-css/10-cli.css b/ccw/src/templates/dashboard-css/10-cli.css index 846e3d17..3370b18e 100644 --- a/ccw/src/templates/dashboard-css/10-cli.css +++ b/ccw/src/templates/dashboard-css/10-cli.css @@ -2127,6 +2127,148 @@ border-top: 1px solid hsl(var(--border)); } +/* ======================================== + * Batch Delete & Multi-Select Styles + * ======================================== */ + +/* Delete Dropdown */ +.history-delete-dropdown { + position: relative; + display: inline-block; +} + +.delete-dropdown-menu { + display: none; + position: absolute; + top: 100%; + right: 0; + z-index: 50; + min-width: 180px; + padding: 0.375rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + box-shadow: 0 4px 16px hsl(var(--foreground) / 0.1); + margin-top: 0.25rem; +} + +.delete-dropdown-menu.show { + display: block; +} + +.delete-dropdown-menu button { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.625rem; + border: none; + background: transparent; + color: hsl(var(--foreground)); + font-size: 0.75rem; + text-align: left; + cursor: pointer; + border-radius: 0.375rem; + transition: all 0.15s ease; +} + +.delete-dropdown-menu button:hover { + background: hsl(var(--hover)); +} + +.delete-dropdown-menu button i { + color: hsl(var(--muted-foreground)); +} + +.delete-dropdown-menu .delete-all-btn { + color: hsl(var(--destructive)); +} + +.delete-dropdown-menu .delete-all-btn i { + color: hsl(var(--destructive)); +} + +.delete-dropdown-menu .delete-all-btn:hover { + background: hsl(var(--destructive) / 0.1); +} + +.dropdown-divider { + height: 1px; + margin: 0.375rem 0; + background: hsl(var(--border)); +} + +/* Batch Actions Bar */ +.history-batch-actions { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.75rem 1rem; + background: hsl(var(--primary) / 0.08); + border: 1px solid hsl(var(--primary) / 0.2); + border-radius: 0.5rem; + margin-bottom: 1rem; +} + +.batch-select-count { + font-size: 0.8125rem; + font-weight: 600; + color: hsl(var(--primary)); + margin-right: auto; +} + +.btn-danger { + background: hsl(var(--destructive)); + color: hsl(var(--destructive-foreground)); + border-color: hsl(var(--destructive)); +} + +.btn-danger:hover:not(:disabled) { + opacity: 0.9; +} + +.btn-danger:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Multi-Select Checkbox */ +.history-checkbox-wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + margin-right: 0.75rem; + flex-shrink: 0; +} + +.history-checkbox { + width: 1.125rem; + height: 1.125rem; + cursor: pointer; + accent-color: hsl(var(--primary)); +} + +/* Selected Item State */ +.history-item-selected { + background: hsl(var(--primary) / 0.08) !important; + border-color: hsl(var(--primary) / 0.3) !important; +} + +/* Turn Badge for History List */ +.history-turn-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.625rem; + font-weight: 600; + padding: 0.1875rem 0.5rem; + background: hsl(var(--primary) / 0.12); + color: hsl(var(--primary)); + border-radius: 9999px; +} + /* ======================================== * Multi-Turn Conversation Styles * ======================================== */ @@ -2199,7 +2341,7 @@ color: hsl(var(--muted-foreground)); } -/* Turn Divider */ +/* Turn Divider (legacy) */ .cli-turn-divider { border: none; border-top: 1px dashed hsl(var(--border)); @@ -2210,3 +2352,284 @@ .cli-detail-error-section .cli-detail-error { max-height: 100px; } + +/* ======================================== + * Enhanced Multi-Turn Display + * ======================================== */ + +/* Turn Section */ +.cli-turn-section { + padding: 0.75rem; + border-radius: 0.5rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); +} + +.cli-turn-section.cli-turn-latest { + border-color: hsl(var(--primary) / 0.3); + background: hsl(var(--primary) / 0.03); +} + +/* Turn Header */ +.cli-turn-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid hsl(var(--border)); + flex-wrap: wrap; + gap: 0.5rem; +} + +.cli-turn-marker { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.cli-turn-number { + font-size: 0.8125rem; + font-weight: 600; + color: hsl(var(--primary)); +} + +.cli-turn-latest-badge { + font-size: 0.5625rem; + font-weight: 600; + padding: 0.125rem 0.375rem; + background: hsl(var(--success) / 0.12); + color: hsl(var(--success)); + border-radius: 9999px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.cli-turn-meta { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); +} + +.cli-turn-meta span { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.cli-turn-time i, +.cli-turn-duration i { + color: hsl(var(--muted-foreground) / 0.7); +} + +/* Turn Body */ +.cli-turn-body { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Section Labels */ +.cli-prompt-section h4 { + color: hsl(var(--primary)); +} + +.cli-prompt-section h4 i { + color: hsl(var(--primary)); +} + +.cli-output-section h4 { + color: hsl(var(--success)); +} + +.cli-output-section h4 i { + color: hsl(var(--success)); +} + +/* Turn Connector (visual line between turns) */ +.cli-turn-connector { + display: flex; + justify-content: center; + padding: 0.25rem 0; +} + +.cli-turn-line { + width: 2px; + height: 1.5rem; + background: linear-gradient( + to bottom, + hsl(var(--border)), + hsl(var(--primary) / 0.3), + hsl(var(--border)) + ); + border-radius: 1px; +} + +/* Truncated Notice */ +.cli-truncated-notice { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + color: hsl(var(--warning)); + margin-top: 0.5rem; + padding: 0.375rem 0.625rem; + background: hsl(var(--warning) / 0.08); + border-radius: 0.25rem; +} + +.cli-truncated-notice i { + flex-shrink: 0; +} + +/* Turn Badge with Icon */ +.cli-turn-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.cli-turn-badge i { + width: 12px; + height: 12px; +} + +/* ======================================== + * Conversation View Toggle + * ======================================== */ + +/* View Toggle Bar */ +.cli-view-toggle { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + padding: 0.5rem; + background: hsl(var(--muted) / 0.3); + border-radius: 0.5rem; +} + +.cli-view-toggle .btn { + flex: 1; + justify-content: center; +} + +.cli-view-toggle .btn.active { + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + border-color: hsl(var(--primary)); +} + +/* Concatenated Prompt Section */ +.cli-concat-section { + margin-top: 1rem; +} + +.cli-concat-format-selector { + display: flex; + gap: 0.375rem; + margin-bottom: 0.75rem; +} + +.cli-concat-format-selector .btn-xs { + padding: 0.25rem 0.625rem; + font-size: 0.6875rem; +} + +.cli-concat-output { + max-height: 400px; + overflow-y: auto; + font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-word; +} + +/* Button Sizes */ +.btn-xs { + padding: 0.25rem 0.5rem; + font-size: 0.6875rem; + border-radius: 0.25rem; +} + +/* ======================================== + * CLI Settings Section + * ======================================== */ + +.cli-settings-section { + margin-top: 1.5rem; + padding-top: 1.25rem; + border-top: 1px solid hsl(var(--border)); +} + +.cli-settings-header { + margin-bottom: 1rem; +} + +.cli-settings-header h4 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.cli-settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.cli-setting-item { + padding: 0.875rem; + background: hsl(var(--muted) / 0.3); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; +} + +.cli-setting-label { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin-bottom: 0.5rem; +} + +.cli-setting-label i { + color: hsl(var(--primary)); +} + +.cli-setting-control { + margin-bottom: 0.375rem; +} + +.cli-setting-select { + width: 100%; + padding: 0.5rem 0.625rem; + font-size: 0.75rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + color: hsl(var(--foreground)); + cursor: pointer; + transition: all 0.15s ease; +} + +.cli-setting-select:hover { + border-color: hsl(var(--primary) / 0.5); +} + +.cli-setting-select:focus { + outline: none; + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1); +} + +.cli-setting-desc { + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); + line-height: 1.4; +} diff --git a/ccw/src/templates/dashboard-js/components/cli-history.js b/ccw/src/templates/dashboard-js/components/cli-history.js index 38b2142c..c8dd5668 100644 --- a/ccw/src/templates/dashboard-js/components/cli-history.js +++ b/ccw/src/templates/dashboard-js/components/cli-history.js @@ -180,66 +180,106 @@ async function showExecutionDetail(executionId, sourceDir) { const latestStatus = isConversation ? conversation.latest_status : conversation.status; const createdAt = isConversation ? conversation.created_at : conversation.timestamp; - // Build turns HTML + // Build turns HTML with improved multi-turn display let turnsHtml = ''; if (isConversation && conversation.turns.length > 0) { - turnsHtml = conversation.turns.map((turn, idx) => ` -
-
- Turn ${turn.turn} - ${turn.status} - ${formatDuration(turn.duration_ms)} -
-
-

Prompt

-
${escapeHtml(turn.prompt)}
-
- ${turn.output.stdout ? ` -
-

Output

-
${escapeHtml(turn.output.stdout)}
+ turnsHtml = conversation.turns.map((turn, idx) => { + const isFirst = idx === 0; + const isLast = idx === conversation.turns.length - 1; + const turnTime = new Date(turn.timestamp).toLocaleTimeString(); + const statusIcon = turn.status === 'success' ? 'check-circle' : + turn.status === 'timeout' ? 'clock' : 'x-circle'; + + return ` +
+
+
+ ${isFirst ? '▶' : '↳'} Turn ${turn.turn} + ${isLast ? 'Latest' : ''} +
+
+ ${turnTime} + + ${turn.status} + + ${formatDuration(turn.duration_ms)} +
- ` : ''} - ${turn.output.stderr ? ` -
-

Errors

-
${escapeHtml(turn.output.stderr)}
+
+
+

User Prompt

+
${escapeHtml(turn.prompt)}
+
+ ${turn.output.stdout ? ` +
+

Assistant Response

+
${escapeHtml(turn.output.stdout)}
+
+ ` : ''} + ${turn.output.stderr ? ` +
+

Errors

+
${escapeHtml(turn.output.stderr)}
+
+ ` : ''} + ${turn.output.truncated ? ` +

+ + Output was truncated due to size. +

+ ` : ''}
- ` : ''} - ${turn.output.truncated ? ` -

- - Output was truncated due to size. -

- ` : ''} -
- `).join('
'); +
+ `; + }).join('
'); } else { // Legacy single execution format const detail = conversation; turnsHtml = ` -
-

Prompt

-
${escapeHtml(detail.prompt)}
+
+
+
+

User Prompt

+
${escapeHtml(detail.prompt)}
+
+ ${detail.output.stdout ? ` +
+

Assistant Response

+
${escapeHtml(detail.output.stdout)}
+
+ ` : ''} + ${detail.output.stderr ? ` +
+

Errors

+
${escapeHtml(detail.output.stderr)}
+
+ ` : ''} + ${detail.output.truncated ? ` +

+ + Output was truncated due to size. +

+ ` : ''} +
- ${detail.output.stdout ? ` + `; + } + + // Build concatenated prompt view (for multi-turn conversations) + let concatenatedPromptHtml = ''; + if (isConversation && conversation.turns.length > 1) { + concatenatedPromptHtml = ` + `; } @@ -247,7 +287,7 @@ async function showExecutionDetail(executionId, sourceDir) {
${conversation.tool} - ${turnCount > 1 ? `${turnCount} turns` : ''} + ${turnCount > 1 ? ` ${turnCount} turns` : ''} ${latestStatus} ${formatDuration(totalDuration)}
@@ -255,21 +295,41 @@ async function showExecutionDetail(executionId, sourceDir) { ${conversation.model || 'default'} ${conversation.mode} ${new Date(createdAt).toLocaleString()} + ${executionId.split('-')[0]}
-
+ ${turnCount > 1 ? ` +
+ + +
+ ` : ''} +
${turnsHtml}
+ ${concatenatedPromptHtml}
+ ${turnCount > 1 ? ` + + ` : ''}
`; + // Store conversation data for format switching + window._currentConversation = conversation; + showModal('Conversation Detail', modalContent); } @@ -354,6 +414,169 @@ async function copyConversationId(conversationId) { } } +// ========== Concatenated Prompt Functions ========== + +/** + * Build concatenated prompt from conversation turns + * Formats: plain, yaml, json + */ +function buildConcatenatedPrompt(conversation, format) { + if (!conversation || !conversation.turns || conversation.turns.length === 0) { + return ''; + } + + const turns = conversation.turns; + + switch (format) { + case 'yaml': + return buildYamlPrompt(conversation); + case 'json': + return buildJsonPrompt(conversation); + case 'plain': + default: + return buildPlainPrompt(conversation); + } +} + +function buildPlainPrompt(conversation) { + const parts = []; + parts.push('=== CONVERSATION HISTORY ==='); + parts.push(''); + + for (const turn of conversation.turns) { + parts.push('--- Turn ' + turn.turn + ' ---'); + parts.push('USER:'); + parts.push(turn.prompt); + parts.push(''); + parts.push('ASSISTANT:'); + parts.push(turn.output.stdout || '[No output]'); + parts.push(''); + } + + parts.push('=== NEW REQUEST ==='); + parts.push(''); + parts.push('[Your next prompt here]'); + + return parts.join('\n'); +} + +function buildYamlPrompt(conversation) { + const lines = []; + lines.push('context:'); + lines.push(' tool: ' + conversation.tool); + lines.push(' model: ' + (conversation.model || 'default')); + lines.push(' mode: ' + conversation.mode); + lines.push(''); + lines.push('conversation:'); + + for (const turn of conversation.turns) { + lines.push(' - turn: ' + turn.turn); + lines.push(' timestamp: ' + turn.timestamp); + lines.push(' status: ' + turn.status); + lines.push(' user: |'); + turn.prompt.split('\n').forEach(function(line) { + lines.push(' ' + line); + }); + lines.push(' assistant: |'); + (turn.output.stdout || '[No output]').split('\n').forEach(function(line) { + lines.push(' ' + line); + }); + lines.push(''); + } + + lines.push('new_request: |'); + lines.push(' [Your next prompt here]'); + + return lines.join('\n'); +} + +function buildJsonPrompt(conversation) { + const data = { + context: { + tool: conversation.tool, + model: conversation.model || 'default', + mode: conversation.mode + }, + conversation: conversation.turns.map(function(turn) { + return { + turn: turn.turn, + timestamp: turn.timestamp, + status: turn.status, + user: turn.prompt, + assistant: turn.output.stdout || '[No output]' + }; + }), + new_request: '[Your next prompt here]' + }; + return JSON.stringify(data, null, 2); +} + +/** + * Toggle between per-turn and concatenated views + */ +function toggleConversationView(view) { + var turnsContainer = document.getElementById('turnsContainer'); + var concatSection = document.getElementById('concatPromptSection'); + var buttons = document.querySelectorAll('.cli-view-toggle button'); + + if (view === 'concat') { + if (turnsContainer) turnsContainer.style.display = 'none'; + if (concatSection) concatSection.style.display = 'block'; + buttons.forEach(function(btn, idx) { + btn.classList.toggle('active', idx === 1); + }); + } else { + if (turnsContainer) turnsContainer.style.display = 'block'; + if (concatSection) concatSection.style.display = 'none'; + buttons.forEach(function(btn, idx) { + btn.classList.toggle('active', idx === 0); + }); + } + + if (window.lucide) lucide.createIcons(); +} + +/** + * Switch concatenation format (plain/yaml/json) + */ +function switchConcatFormat(format, executionId) { + var conversation = window._currentConversation; + if (!conversation) return; + + var output = document.getElementById('concatPromptOutput'); + if (output) { + output.textContent = buildConcatenatedPrompt(conversation, format); + } + + // Update button states + var buttons = document.querySelectorAll('.cli-concat-format-selector button'); + buttons.forEach(function(btn) { + var btnFormat = btn.textContent.toLowerCase(); + btn.className = 'btn btn-xs ' + (btnFormat === format ? 'btn-primary' : 'btn-outline'); + }); +} + +/** + * Copy concatenated prompt to clipboard + */ +async function copyConcatenatedPrompt(executionId) { + var conversation = window._currentConversation; + if (!conversation) { + showRefreshToast('Conversation not found', 'error'); + return; + } + + var prompt = buildConcatenatedPrompt(conversation, 'plain'); + if (navigator.clipboard) { + try { + await navigator.clipboard.writeText(prompt); + showRefreshToast('Full prompt copied to clipboard', 'success'); + } catch (err) { + showRefreshToast('Failed to copy', 'error'); + } + } +} + // ========== Helpers ========== function formatDuration(ms) { if (ms >= 60000) { diff --git a/ccw/src/templates/dashboard-js/components/cli-status.js b/ccw/src/templates/dashboard-js/components/cli-status.js index d3fe75d5..604f8234 100644 --- a/ccw/src/templates/dashboard-js/components/cli-status.js +++ b/ccw/src/templates/dashboard-js/components/cli-status.js @@ -6,6 +6,7 @@ let cliToolStatus = { gemini: {}, qwen: {}, codex: {} }; let codexLensStatus = { ready: false }; let semanticStatus = { available: false }; let defaultCliTool = 'gemini'; +let promptConcatFormat = localStorage.getItem('ccw-prompt-format') || 'plain'; // plain, yaml, json // ========== Initialization ========== function initCliStatus() { @@ -207,6 +208,44 @@ function renderCliStatus() {
` : ''; + // CLI Settings section + const settingsHtml = ` +
+
+

Settings

+
+
+
+ +
+ +
+

Format for multi-turn conversation concatenation

+
+
+ +
+ +
+

History storage: SQLite for search, JSON for portability

+
+
+
+ `; + container.innerHTML = `

CLI Tools

@@ -219,6 +258,7 @@ function renderCliStatus() { ${codexLensHtml} ${semanticHtml}
+ ${settingsHtml} `; // Initialize Lucide icons @@ -234,6 +274,27 @@ function setDefaultCliTool(tool) { showRefreshToast(`Default CLI tool set to ${tool}`, 'success'); } +function setPromptFormat(format) { + promptConcatFormat = format; + localStorage.setItem('ccw-prompt-format', format); + showRefreshToast(`Prompt format set to ${format.toUpperCase()}`, 'success'); +} + +function setStorageBackendSetting(backend) { + storageBackend = backend; + localStorage.setItem('ccw-storage-backend', backend); + // Notify server about backend change + fetch('/api/cli/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ storageBackend: backend }) + }).catch(err => console.error('Failed to update backend setting:', err)); + showRefreshToast(`Storage backend set to ${backend === 'sqlite' ? 'SQLite' : 'JSON'}`, 'success'); +} + +// Expose to window for select onchange +window.setStorageBackend = setStorageBackendSetting; + async function refreshAllCliStatus() { await Promise.all([loadCliToolStatus(), loadCodexLensStatus()]); renderCliStatus(); diff --git a/ccw/src/templates/dashboard-js/views/history.js b/ccw/src/templates/dashboard-js/views/history.js index 2d5da4c4..321e92f9 100644 --- a/ccw/src/templates/dashboard-js/views/history.js +++ b/ccw/src/templates/dashboard-js/views/history.js @@ -1,5 +1,9 @@ // CLI History View -// Standalone view for CLI execution history with resume support +// Standalone view for CLI execution history with batch delete support + +// ========== Multi-Select State ========== +var selectedExecutions = new Set(); +var isMultiSelectMode = false; // ========== Rendering ========== async function renderCliHistoryView() { @@ -47,20 +51,32 @@ async function renderCliHistoryView() { exec.status === 'timeout' ? 'warning' : 'error'; var duration = formatDuration(exec.duration_ms); var timeAgo = getTimeAgo(new Date(exec.timestamp)); - var isResume = exec.prompt_preview && exec.prompt_preview.includes('[Resume session'); + var isSelected = selectedExecutions.has(exec.id); + + // Turn count badge for multi-turn conversations + var turnBadge = exec.turn_count && exec.turn_count > 1 + ? ' ' + exec.turn_count + '' + : ''; var sourceDirHtml = exec.sourceDir && exec.sourceDir !== '.' ? ' ' + escapeHtml(exec.sourceDir) + '' : ''; - var resumeBadge = isResume ? '' : ''; + // Multi-select checkbox + var checkboxHtml = isMultiSelectMode + ? '
' + + '' + + '
' + : ''; - historyHtml += '
' + + historyHtml += '
' + + checkboxHtml + '
' + '
' + '' + exec.tool + '' + '' + (exec.mode || 'analysis') + '' + - resumeBadge + + turnBadge + sourceDirHtml + '' + '' + @@ -75,9 +91,6 @@ async function renderCliHistoryView() { '
' + '
' + '
' + - '' + '' + @@ -90,6 +103,26 @@ async function renderCliHistoryView() { historyHtml += '
'; } + // Build batch actions bar + var batchActionsHtml = ''; + if (isMultiSelectMode) { + batchActionsHtml = '
' + + '' + selectedExecutions.size + ' selected' + + '' + + '' + + '' + + '' + + '
'; + } + container.innerHTML = '
' + '
' + '
' + @@ -108,11 +141,36 @@ async function renderCliHistoryView() { '' + '' + '' + + // Batch delete dropdown + '
' + + '' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
' + '' + '
' + '
' + + batchActionsHtml + historyHtml + '
'; @@ -144,79 +202,136 @@ async function refreshCliHistoryView() { showRefreshToast('History refreshed', 'success'); } -// ========== Resume Execution ========== -function promptResumeExecution(executionId, tool) { - var modalContent = '
' + - '

Resume this ' + tool + ' session with an optional continuation prompt:

' + - '' + - '
' + - '' + - '' + - '
' + - '
'; - - showModal('Resume Session', modalContent); +// ========== Multi-Select Functions ========== +function toggleDeleteDropdown(event) { + event.stopPropagation(); + var menu = document.getElementById('deleteDropdownMenu'); + if (menu) { + menu.classList.toggle('show'); + // Close on outside click + if (menu.classList.contains('show')) { + setTimeout(function() { + document.addEventListener('click', closeDeleteDropdown); + }, 0); + } + } } -async function executeResume(executionId, tool) { - var promptInput = document.getElementById('resumePromptInput'); - var additionalPrompt = promptInput ? promptInput.value.trim() : 'Continue from previous session'; +function closeDeleteDropdown() { + var menu = document.getElementById('deleteDropdownMenu'); + if (menu) menu.classList.remove('show'); + document.removeEventListener('click', closeDeleteDropdown); +} - closeModal(); - showRefreshToast('Resuming session...', 'info'); +function enterMultiSelectMode() { + closeDeleteDropdown(); + isMultiSelectMode = true; + selectedExecutions.clear(); + renderCliHistoryView(); +} + +function exitMultiSelectMode() { + isMultiSelectMode = false; + selectedExecutions.clear(); + renderCliHistoryView(); +} + +function toggleExecutionSelection(executionId) { + if (selectedExecutions.has(executionId)) { + selectedExecutions.delete(executionId); + } else { + selectedExecutions.add(executionId); + } + renderCliHistoryView(); +} + +function selectAllExecutions() { + var filteredHistory = cliHistorySearch + ? cliExecutionHistory.filter(function(exec) { + return exec.prompt_preview.toLowerCase().includes(cliHistorySearch.toLowerCase()) || + exec.tool.toLowerCase().includes(cliHistorySearch.toLowerCase()); + }) + : cliExecutionHistory; + + filteredHistory.forEach(function(exec) { + selectedExecutions.add(exec.id); + }); + renderCliHistoryView(); +} + +function clearExecutionSelection() { + selectedExecutions.clear(); + renderCliHistoryView(); +} + +// ========== Batch Delete Functions ========== +function confirmBatchDelete() { + var count = selectedExecutions.size; + if (count === 0) return; + + if (confirm('Delete ' + count + ' selected execution' + (count > 1 ? 's' : '') + '? This action cannot be undone.')) { + batchDeleteExecutions(Array.from(selectedExecutions)); + } +} + +function confirmDeleteByTool(tool) { + closeDeleteDropdown(); + var toolExecutions = cliExecutionHistory.filter(function(exec) { return exec.tool === tool; }); + var count = toolExecutions.length; + + if (count === 0) { + showRefreshToast('No ' + tool + ' executions to delete', 'info'); + return; + } + + if (confirm('Delete all ' + count + ' ' + tool + ' execution' + (count > 1 ? 's' : '') + '? This action cannot be undone.')) { + var ids = toolExecutions.map(function(exec) { return exec.id; }); + batchDeleteExecutions(ids); + } +} + +function confirmDeleteAll() { + closeDeleteDropdown(); + var count = cliExecutionHistory.length; + + if (count === 0) { + showRefreshToast('No executions to delete', 'info'); + return; + } + + if (confirm('Delete ALL ' + count + ' execution' + (count > 1 ? 's' : '') + '? This action cannot be undone.')) { + var ids = cliExecutionHistory.map(function(exec) { return exec.id; }); + batchDeleteExecutions(ids); + } +} + +async function batchDeleteExecutions(ids) { + showRefreshToast('Deleting ' + ids.length + ' executions...', 'info'); try { - var response = await fetch('/api/cli/execute', { + var response = await fetch('/api/cli/batch-delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - tool: tool, - prompt: additionalPrompt, - resume: executionId // execution ID to resume from + path: projectPath, + ids: ids }) }); var result = await response.json(); if (result.success) { - showRefreshToast('Session resumed successfully', 'success'); - // Refresh history to show new execution - await refreshCliHistoryView(); + showRefreshToast('Deleted ' + result.deleted + ' execution' + (result.deleted > 1 ? 's' : ''), 'success'); + // Exit multi-select mode and refresh + isMultiSelectMode = false; + selectedExecutions.clear(); + await loadCliHistory(); + renderCliHistoryView(); } else { - showRefreshToast('Resume failed: ' + (result.error || 'Unknown error'), 'error'); + showRefreshToast('Delete failed: ' + (result.error || 'Unknown error'), 'error'); } } catch (err) { - console.error('Resume failed:', err); - showRefreshToast('Resume failed: ' + err.message, 'error'); - } -} - -async function resumeLastSession(tool) { - showRefreshToast('Resuming last ' + (tool || '') + ' session...', 'info'); - - try { - var response = await fetch('/api/cli/execute', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - tool: tool || 'gemini', - prompt: 'Continue from previous session', - resume: true // true = resume last session - }) - }); - - var result = await response.json(); - - if (result.success) { - showRefreshToast('Session resumed successfully', 'success'); - await refreshCliHistoryView(); - } else { - showRefreshToast('Resume failed: ' + (result.error || 'Unknown error'), 'error'); - } - } catch (err) { - console.error('Resume failed:', err); - showRefreshToast('Resume failed: ' + err.message, 'error'); + console.error('Batch delete failed:', err); + showRefreshToast('Delete failed: ' + err.message, 'error'); } } diff --git a/ccw/src/tools/cli-executor.ts b/ccw/src/tools/cli-executor.ts index d9029ed3..faeb630c 100644 --- a/ccw/src/tools/cli-executor.ts +++ b/ccw/src/tools/cli-executor.ts @@ -12,6 +12,29 @@ import { join, relative } from 'path'; // CLI History storage path const CLI_HISTORY_DIR = join(process.cwd(), '.workflow', '.cli-history'); +// Lazy-loaded SQLite store module +let sqliteStoreModule: typeof import('./cli-history-store.js') | null = null; + +/** + * Get or initialize SQLite store (async) + */ +async function getSqliteStore(baseDir: string) { + if (!sqliteStoreModule) { + sqliteStoreModule = await import('./cli-history-store.js'); + } + return sqliteStoreModule.getHistoryStore(baseDir); +} + +/** + * Get SQLite store (sync - uses cached module) + */ +function getSqliteStoreSync(baseDir: string) { + if (!sqliteStoreModule) { + throw new Error('SQLite store not initialized. Call an async function first.'); + } + return sqliteStoreModule.getHistoryStore(baseDir); +} + // Define Zod schema for validation const ParamsSchema = z.object({ tool: z.enum(['gemini', 'qwen', 'codex']), @@ -240,85 +263,51 @@ function loadHistoryIndex(historyDir: string): HistoryIndex { } /** - * Save conversation to history (create new or append turn) + * Save conversation to SQLite */ -function saveConversation(historyDir: string, conversation: ConversationRecord): void { - // Create date-based subdirectory using created_at date - const dateStr = conversation.created_at.split('T')[0]; - const dateDir = join(historyDir, dateStr); - if (!existsSync(dateDir)) { - mkdirSync(dateDir, { recursive: true }); - } - - // Save conversation record - const filename = `${conversation.id}.json`; - writeFileSync(join(dateDir, filename), JSON.stringify(conversation, null, 2), 'utf8'); - - // Update index - const index = loadHistoryIndex(historyDir); - - // Check if this conversation already exists in index - const existingIdx = index.executions.findIndex(e => e.id === conversation.id); - const latestTurn = conversation.turns[conversation.turns.length - 1]; - - const indexEntry = { - id: conversation.id, - timestamp: conversation.created_at, - updated_at: conversation.updated_at, - tool: conversation.tool, - status: conversation.latest_status, - duration_ms: conversation.total_duration_ms, - turn_count: conversation.turn_count, - prompt_preview: latestTurn.prompt.substring(0, 100) + (latestTurn.prompt.length > 100 ? '...' : '') - }; - - if (existingIdx >= 0) { - // Update existing entry and move to top - index.executions.splice(existingIdx, 1); - index.executions.unshift(indexEntry); - } else { - // Add new entry - index.total_executions++; - index.executions.unshift(indexEntry); - } - - if (index.executions.length > 100) { - index.executions = index.executions.slice(0, 100); - } - - writeFileSync(join(historyDir, 'index.json'), JSON.stringify(index, null, 2), 'utf8'); +async function saveConversationAsync(historyDir: string, conversation: ConversationRecord): Promise { + const baseDir = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, ''); + const store = await getSqliteStore(baseDir); + store.saveConversation(conversation); } /** - * Load existing conversation by ID + * Sync wrapper for saveConversation (uses cached SQLite module) + */ +function saveConversation(historyDir: string, conversation: ConversationRecord): void { + const baseDir = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, ''); + try { + const store = getSqliteStoreSync(baseDir); + store.saveConversation(conversation); + } catch { + // If sync not available, queue for async save + saveConversationAsync(historyDir, conversation).catch(err => { + console.error('[CLI Executor] Failed to save conversation:', err.message); + }); + } +} + +/** + * Load existing conversation by ID from SQLite + */ +async function loadConversationAsync(historyDir: string, conversationId: string): Promise { + const baseDir = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, ''); + const store = await getSqliteStore(baseDir); + return store.getConversation(conversationId); +} + +/** + * Sync wrapper for loadConversation (uses cached SQLite module) */ function loadConversation(historyDir: string, conversationId: string): ConversationRecord | null { - // Search in all date directories - if (existsSync(historyDir)) { - const dateDirs = readdirSync(historyDir).filter(d => { - const dirPath = join(historyDir, d); - return statSync(dirPath).isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(d); - }); - - // Search newest first - for (const dateDir of dateDirs.sort().reverse()) { - const filePath = join(historyDir, dateDir, `${conversationId}.json`); - if (existsSync(filePath)) { - try { - const data = JSON.parse(readFileSync(filePath, 'utf8')); - // Check if it's a conversation record (has turns array) - if (data.turns && Array.isArray(data.turns)) { - return data as ConversationRecord; - } - // Convert legacy ExecutionRecord to ConversationRecord - return convertToConversation(data); - } catch { - continue; - } - } - } + const baseDir = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, ''); + try { + const store = getSqliteStoreSync(baseDir); + return store.getConversation(conversationId); + } catch { + // SQLite not initialized yet, return null + return null; } - return null; } /** @@ -880,7 +869,55 @@ function findCliHistoryDirs(baseDir: string, maxDepth: number = 3): string[] { } /** - * Get execution history + * Get execution history from SQLite + */ +export async function getExecutionHistoryAsync(baseDir: string, options: { + limit?: number; + tool?: string | null; + status?: string | null; + search?: string | null; + recursive?: boolean; +} = {}): Promise<{ + total: number; + count: number; + executions: (HistoryIndex['executions'][0] & { sourceDir?: string })[]; +}> { + const { limit = 50, tool = null, status = null, search = null, recursive = false } = options; + + if (recursive) { + // For recursive, we need to check multiple directories + const historyDirs = findCliHistoryDirs(baseDir); + let allExecutions: (HistoryIndex['executions'][0] & { sourceDir?: string })[] = []; + let totalCount = 0; + + for (const historyDir of historyDirs) { + const dirBase = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, ''); + const store = await getSqliteStore(dirBase); + const result = store.getHistory({ limit: 100, tool, status, search }); + totalCount += result.total; + + const relativeSource = relative(baseDir, dirBase) || '.'; + for (const exec of result.executions) { + allExecutions.push({ ...exec, sourceDir: relativeSource }); + } + } + + // Sort by timestamp (newest first) + allExecutions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + return { + total: totalCount, + count: Math.min(allExecutions.length, limit), + executions: allExecutions.slice(0, limit) + }; + } + + const store = await getSqliteStore(baseDir); + return store.getHistory({ limit, tool, status, search }); +} + +/** + * Get execution history (sync version - uses cached SQLite module) */ export function getExecutionHistory(baseDir: string, options: { limit?: number; @@ -894,54 +931,39 @@ export function getExecutionHistory(baseDir: string, options: { } { const { limit = 50, tool = null, status = null, recursive = false } = options; - let allExecutions: (HistoryIndex['executions'][0] & { sourceDir?: string })[] = []; - let totalCount = 0; + try { + if (recursive) { + const historyDirs = findCliHistoryDirs(baseDir); + let allExecutions: (HistoryIndex['executions'][0] & { sourceDir?: string })[] = []; + let totalCount = 0; - if (recursive) { - // Find all CLI history directories in subdirectories - const historyDirs = findCliHistoryDirs(baseDir); + for (const historyDir of historyDirs) { + const dirBase = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, ''); + const store = getSqliteStoreSync(dirBase); + const result = store.getHistory({ limit: 100, tool, status }); + totalCount += result.total; - for (const historyDir of historyDirs) { - const index = loadHistoryIndex(historyDir); - totalCount += index.total_executions; - - // Add source directory info to each execution - const sourceDir = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, ''); - const relativeSource = relative(baseDir, sourceDir) || '.'; - - for (const exec of index.executions) { - allExecutions.push({ ...exec, sourceDir: relativeSource }); + const relativeSource = relative(baseDir, dirBase) || '.'; + for (const exec of result.executions) { + allExecutions.push({ ...exec, sourceDir: relativeSource }); + } } + + allExecutions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + return { + total: totalCount, + count: Math.min(allExecutions.length, limit), + executions: allExecutions.slice(0, limit) + }; } - // Sort by timestamp (newest first) - allExecutions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); - } else { - // Original behavior - single directory - const historyDir = join(baseDir, '.workflow', '.cli-history'); - const index = loadHistoryIndex(historyDir); - totalCount = index.total_executions; - allExecutions = index.executions; + const store = getSqliteStoreSync(baseDir); + return store.getHistory({ limit, tool, status }); + } catch { + // SQLite not initialized, return empty + return { total: 0, count: 0, executions: [] }; } - - // Filter by tool - if (tool) { - allExecutions = allExecutions.filter(e => e.tool === tool); - } - - // Filter by status - if (status) { - allExecutions = allExecutions.filter(e => e.status === status); - } - - // Limit results - const executions = allExecutions.slice(0, limit); - - return { - total: totalCount, - count: executions.length, - executions - }; } /** @@ -976,38 +998,37 @@ export function getExecutionDetail(baseDir: string, executionId: string): Execut } /** - * Delete execution by ID + * Delete execution by ID (async version) + */ +export async function deleteExecutionAsync(baseDir: string, executionId: string): Promise<{ success: boolean; error?: string }> { + const store = await getSqliteStore(baseDir); + return store.deleteConversation(executionId); +} + +/** + * Delete execution by ID (sync version - uses cached SQLite module) */ export function deleteExecution(baseDir: string, executionId: string): { success: boolean; error?: string } { - const historyDir = join(baseDir, '.workflow', '.cli-history'); - - // Parse date from execution ID - const timestamp = parseInt(executionId.split('-')[0], 10); - const date = new Date(timestamp); - const dateStr = date.toISOString().split('T')[0]; - - const filePath = join(historyDir, dateStr, `${executionId}.json`); - - // Delete the execution file - if (existsSync(filePath)) { - try { - unlinkSync(filePath); - } catch (err) { - return { success: false, error: `Failed to delete file: ${(err as Error).message}` }; - } - } - - // Update index try { - const index = loadHistoryIndex(historyDir); - index.executions = index.executions.filter(e => e.id !== executionId); - index.total_executions = Math.max(0, index.total_executions - 1); - writeFileSync(join(historyDir, 'index.json'), JSON.stringify(index, null, 2), 'utf8'); - } catch (err) { - return { success: false, error: `Failed to update index: ${(err as Error).message}` }; + const store = getSqliteStoreSync(baseDir); + return store.deleteConversation(executionId); + } catch { + return { success: false, error: 'SQLite store not initialized' }; } +} - return { success: true }; +/** + * Batch delete executions (async) + */ +export async function batchDeleteExecutionsAsync(baseDir: string, ids: string[]): Promise<{ + success: boolean; + deleted: number; + total: number; + errors?: string[]; +}> { + const store = await getSqliteStore(baseDir); + const result = store.batchDelete(ids); + return { ...result, total: ids.length }; } /** @@ -1024,31 +1045,367 @@ export async function getCliToolsStatus(): Promise = {}; + + constructor(options: Partial = {}) { + this.options = { + format: options.format || 'plain', + includeMetadata: options.includeMetadata ?? true, + includeTurnMarkers: options.includeTurnMarkers ?? true, + maxOutputLength: options.maxOutputLength || 8192 + }; } - parts.push('=== NEW REQUEST ==='); - parts.push(''); - parts.push(newPrompt); + /** + * Set metadata for the conversation + */ + setMetadata(key: string, value: unknown): this { + this.metadata[key] = value; + return this; + } - return parts.join('\n'); + /** + * Add a user turn + */ + addUserTurn(content: string, options: Partial> = {}): this { + this.turns.push({ + turn: this.turns.length + 1, + role: 'user', + content, + ...options + }); + return this; + } + + /** + * Add an assistant turn + */ + addAssistantTurn(content: string, options: Partial> = {}): this { + // Truncate output if needed + const truncatedContent = content.length > this.options.maxOutputLength! + ? content.substring(0, this.options.maxOutputLength!) + '\n... [truncated]' + : content; + + this.turns.push({ + turn: this.turns.length + 1, + role: 'assistant', + content: truncatedContent, + ...options + }); + return this; + } + + /** + * Add a conversation turn from ConversationTurn + */ + addFromConversationTurn(turn: ConversationTurn, sourceId?: string): this { + this.addUserTurn(turn.prompt, { + turn: turn.turn * 2 - 1, + timestamp: turn.timestamp, + source_id: sourceId + }); + this.addAssistantTurn(turn.output.stdout || '[No output]', { + turn: turn.turn * 2, + timestamp: turn.timestamp, + status: turn.status, + duration_ms: turn.duration_ms, + source_id: sourceId + }); + return this; + } + + /** + * Load turns from an existing conversation + */ + loadConversation(conversation: ConversationRecord): this { + for (const turn of conversation.turns) { + this.addFromConversationTurn(turn); + } + return this; + } + + /** + * Build the final prompt in plain text format + */ + private buildPlainText(newPrompt: string): string { + const parts: string[] = []; + + // Metadata section + if (this.options.includeMetadata && Object.keys(this.metadata).length > 0) { + parts.push('=== CONTEXT ==='); + for (const [key, value] of Object.entries(this.metadata)) { + parts.push(`${key}: ${String(value)}`); + } + parts.push(''); + } + + // Conversation history + if (this.turns.length > 0) { + parts.push('=== CONVERSATION HISTORY ==='); + parts.push(''); + + let currentTurn = 0; + for (let i = 0; i < this.turns.length; i += 2) { + currentTurn++; + const userTurn = this.turns[i]; + const assistantTurn = this.turns[i + 1]; + + if (this.options.includeTurnMarkers) { + const sourceMarker = userTurn.source_id ? ` [${userTurn.source_id}]` : ''; + parts.push(`--- Turn ${currentTurn}${sourceMarker} ---`); + } + + parts.push('USER:'); + parts.push(userTurn.content); + parts.push(''); + + if (assistantTurn) { + parts.push('ASSISTANT:'); + parts.push(assistantTurn.content); + parts.push(''); + } + } + } + + // New request + parts.push('=== NEW REQUEST ==='); + parts.push(''); + parts.push(newPrompt); + + return parts.join('\n'); + } + + /** + * Build the final prompt in YAML format + */ + private buildYaml(newPrompt: string): string { + const yamlLines: string[] = []; + + // Metadata + if (this.options.includeMetadata && Object.keys(this.metadata).length > 0) { + yamlLines.push('context:'); + for (const [key, value] of Object.entries(this.metadata)) { + yamlLines.push(` ${key}: ${this.yamlValue(value)}`); + } + yamlLines.push(''); + } + + // Conversation history + if (this.turns.length > 0) { + yamlLines.push('conversation:'); + + let currentTurn = 0; + for (let i = 0; i < this.turns.length; i += 2) { + currentTurn++; + const userTurn = this.turns[i]; + const assistantTurn = this.turns[i + 1]; + + yamlLines.push(` - turn: ${currentTurn}`); + if (userTurn.source_id) { + yamlLines.push(` source: ${userTurn.source_id}`); + } + if (userTurn.timestamp) { + yamlLines.push(` timestamp: ${userTurn.timestamp}`); + } + + // User message + yamlLines.push(' user: |'); + const userLines = userTurn.content.split('\n'); + for (const line of userLines) { + yamlLines.push(` ${line}`); + } + + // Assistant message + if (assistantTurn) { + if (assistantTurn.status) { + yamlLines.push(` status: ${assistantTurn.status}`); + } + if (assistantTurn.duration_ms) { + yamlLines.push(` duration_ms: ${assistantTurn.duration_ms}`); + } + yamlLines.push(' assistant: |'); + const assistantLines = assistantTurn.content.split('\n'); + for (const line of assistantLines) { + yamlLines.push(` ${line}`); + } + } + yamlLines.push(''); + } + } + + // New request + yamlLines.push('new_request: |'); + const requestLines = newPrompt.split('\n'); + for (const line of requestLines) { + yamlLines.push(` ${line}`); + } + + return yamlLines.join('\n'); + } + + /** + * Build the final prompt in JSON format + */ + private buildJson(newPrompt: string): string { + const data: Record = {}; + + // Metadata + if (this.options.includeMetadata && Object.keys(this.metadata).length > 0) { + data.context = this.metadata; + } + + // Conversation history + if (this.turns.length > 0) { + const conversation: Array<{ + turn: number; + source?: string; + timestamp?: string; + user: string; + assistant?: string; + status?: string; + duration_ms?: number; + }> = []; + + for (let i = 0; i < this.turns.length; i += 2) { + const userTurn = this.turns[i]; + const assistantTurn = this.turns[i + 1]; + + const turnData: typeof conversation[0] = { + turn: Math.ceil((i + 1) / 2), + user: userTurn.content + }; + + if (userTurn.source_id) turnData.source = userTurn.source_id; + if (userTurn.timestamp) turnData.timestamp = userTurn.timestamp; + if (assistantTurn) { + turnData.assistant = assistantTurn.content; + if (assistantTurn.status) turnData.status = assistantTurn.status; + if (assistantTurn.duration_ms) turnData.duration_ms = assistantTurn.duration_ms; + } + + conversation.push(turnData); + } + + data.conversation = conversation; + } + + data.new_request = newPrompt; + + return JSON.stringify(data, null, 2); + } + + /** + * Helper to format YAML values + */ + private yamlValue(value: unknown): string { + if (typeof value === 'string') { + // Quote strings that might be interpreted as other types + if (/[:\[\]{}#&*!|>'"@`]/.test(value) || value === '') { + return `"${value.replace(/"/g, '\\"')}"`; + } + return value; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (value === null || value === undefined) { + return 'null'; + } + return JSON.stringify(value); + } + + /** + * Build the final prompt string + */ + build(newPrompt: string): string { + switch (this.options.format) { + case 'yaml': + return this.buildYaml(newPrompt); + case 'json': + return this.buildJson(newPrompt); + case 'plain': + default: + return this.buildPlainText(newPrompt); + } + } + + /** + * Reset the concatenator for reuse + */ + reset(): this { + this.turns = []; + this.metadata = {}; + return this; + } +} + +/** + * Create a prompt concatenator with specified options + */ +function createPromptConcatenator(options?: Partial): PromptConcatenator { + return new PromptConcatenator(options); +} + +/** + * Quick helper to build a multi-turn prompt in any format + */ +function buildPrompt( + conversation: ConversationRecord, + newPrompt: string, + format: PromptFormat = 'plain' +): string { + return createPromptConcatenator({ format }) + .loadConversation(conversation) + .build(newPrompt); +} + +/** + * Build multi-turn prompt with full conversation history + * Uses the PromptConcatenator with plain text format by default + */ +function buildMultiTurnPrompt( + conversation: ConversationRecord, + newPrompt: string, + format: PromptFormat = 'plain' +): string { + return buildPrompt(conversation, newPrompt, format); } /** @@ -1111,11 +1468,17 @@ export function getLatestExecution(baseDir: string, tool?: string): ExecutionRec } // Export types -export type { ConversationRecord, ConversationTurn, ExecutionRecord }; +export type { ConversationRecord, ConversationTurn, ExecutionRecord, PromptFormat, ConcatOptions }; // Export utility functions and tool definition for backward compatibility export { executeCliTool, checkToolAvailability }; +// Export prompt concatenation utilities +export { PromptConcatenator, createPromptConcatenator, buildPrompt, buildMultiTurnPrompt }; + +// Note: Async storage functions (getExecutionHistoryAsync, deleteExecutionAsync, +// batchDeleteExecutionsAsync, setStorageBackend) are exported at declaration site + // Export tool definition (for legacy imports) - This allows direct calls to execute with onOutput export const cliExecutorTool = { schema, diff --git a/ccw/src/tools/cli-history-store.ts b/ccw/src/tools/cli-history-store.ts new file mode 100644 index 00000000..d0ba6fd3 --- /dev/null +++ b/ccw/src/tools/cli-history-store.ts @@ -0,0 +1,528 @@ +/** + * CLI History Store - SQLite Storage Backend + * Provides persistent storage for CLI execution history with efficient queries + */ + +import Database from 'better-sqlite3'; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, rmdirSync } from 'fs'; +import { join } from 'path'; + +// Types +export interface ConversationTurn { + turn: number; + timestamp: string; + prompt: string; + duration_ms: number; + status: 'success' | 'error' | 'timeout'; + exit_code: number | null; + output: { + stdout: string; + stderr: string; + truncated: boolean; + }; +} + +export interface ConversationRecord { + id: string; + created_at: string; + updated_at: string; + tool: string; + model: string; + mode: string; + total_duration_ms: number; + turn_count: number; + latest_status: 'success' | 'error' | 'timeout'; + turns: ConversationTurn[]; +} + +export interface HistoryQueryOptions { + limit?: number; + offset?: number; + tool?: string | null; + status?: string | null; + search?: string | null; + startDate?: string | null; + endDate?: string | null; +} + +export interface HistoryIndexEntry { + id: string; + timestamp: string; + updated_at?: string; + tool: string; + status: string; + duration_ms: number; + turn_count?: number; + prompt_preview: string; + sourceDir?: string; +} + +/** + * CLI History Store using SQLite + */ +export class CliHistoryStore { + private db: Database.Database; + private dbPath: string; + + constructor(baseDir: string) { + const historyDir = join(baseDir, '.workflow', '.cli-history'); + if (!existsSync(historyDir)) { + mkdirSync(historyDir, { recursive: true }); + } + + this.dbPath = join(historyDir, 'history.db'); + this.db = new Database(this.dbPath); + this.db.pragma('journal_mode = WAL'); + this.db.pragma('synchronous = NORMAL'); + + this.initSchema(); + this.migrateFromJson(historyDir); + } + + /** + * Initialize database schema + */ + private initSchema(): void { + this.db.exec(` + -- Conversations table (conversation metadata) + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + tool TEXT NOT NULL, + model TEXT DEFAULT 'default', + mode TEXT DEFAULT 'analysis', + total_duration_ms INTEGER DEFAULT 0, + turn_count INTEGER DEFAULT 0, + latest_status TEXT DEFAULT 'success', + prompt_preview TEXT + ); + + -- Turns table (individual conversation turns) + CREATE TABLE IF NOT EXISTS turns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL, + turn_number INTEGER NOT NULL, + timestamp TEXT NOT NULL, + prompt TEXT NOT NULL, + duration_ms INTEGER DEFAULT 0, + status TEXT DEFAULT 'success', + exit_code INTEGER, + stdout TEXT, + stderr TEXT, + truncated INTEGER DEFAULT 0, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE, + UNIQUE(conversation_id, turn_number) + ); + + -- Indexes for efficient queries + CREATE INDEX IF NOT EXISTS idx_conversations_tool ON conversations(tool); + CREATE INDEX IF NOT EXISTS idx_conversations_status ON conversations(latest_status); + CREATE INDEX IF NOT EXISTS idx_conversations_updated ON conversations(updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_conversations_created ON conversations(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_turns_conversation ON turns(conversation_id); + + -- Full-text search for prompts + CREATE VIRTUAL TABLE IF NOT EXISTS turns_fts USING fts5( + prompt, + stdout, + content='turns', + content_rowid='id' + ); + + -- Triggers to keep FTS index updated + CREATE TRIGGER IF NOT EXISTS turns_ai AFTER INSERT ON turns BEGIN + INSERT INTO turns_fts(rowid, prompt, stdout) VALUES (new.id, new.prompt, new.stdout); + END; + + CREATE TRIGGER IF NOT EXISTS turns_ad AFTER DELETE ON turns BEGIN + INSERT INTO turns_fts(turns_fts, rowid, prompt, stdout) VALUES('delete', old.id, old.prompt, old.stdout); + END; + + CREATE TRIGGER IF NOT EXISTS turns_au AFTER UPDATE ON turns BEGIN + INSERT INTO turns_fts(turns_fts, rowid, prompt, stdout) VALUES('delete', old.id, old.prompt, old.stdout); + INSERT INTO turns_fts(rowid, prompt, stdout) VALUES (new.id, new.prompt, new.stdout); + END; + `); + } + + /** + * Migrate existing JSON files to SQLite + */ + private migrateFromJson(historyDir: string): void { + const migrationMarker = join(historyDir, '.migrated'); + if (existsSync(migrationMarker)) { + return; // Already migrated + } + + // Find all date directories + const dateDirs = readdirSync(historyDir).filter(d => { + const dirPath = join(historyDir, d); + return statSync(dirPath).isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(d); + }); + + let migratedCount = 0; + + for (const dateDir of dateDirs) { + const dirPath = join(historyDir, dateDir); + const files = readdirSync(dirPath).filter(f => f.endsWith('.json')); + + for (const file of files) { + try { + const filePath = join(dirPath, file); + const data = JSON.parse(readFileSync(filePath, 'utf8')); + + // Convert to conversation record if legacy format + const conversation = this.normalizeRecord(data); + this.saveConversation(conversation); + migratedCount++; + + // Optionally delete the JSON file after migration + // unlinkSync(filePath); + } catch (err) { + console.error(`Failed to migrate ${file}:`, (err as Error).message); + } + } + } + + // Create migration marker + if (migratedCount > 0) { + require('fs').writeFileSync(migrationMarker, new Date().toISOString()); + console.log(`[CLI History] Migrated ${migratedCount} records to SQLite`); + } + } + + /** + * Normalize legacy record to ConversationRecord format + */ + private normalizeRecord(data: any): ConversationRecord { + if (data.turns && Array.isArray(data.turns)) { + return data as ConversationRecord; + } + + // Legacy single execution format + return { + id: data.id, + created_at: data.timestamp, + updated_at: data.timestamp, + tool: data.tool, + model: data.model || 'default', + mode: data.mode || 'analysis', + total_duration_ms: data.duration_ms || 0, + turn_count: 1, + latest_status: data.status || 'success', + turns: [{ + turn: 1, + timestamp: data.timestamp, + prompt: data.prompt, + duration_ms: data.duration_ms || 0, + status: data.status || 'success', + exit_code: data.exit_code, + output: data.output || { stdout: '', stderr: '', truncated: false } + }] + }; + } + + /** + * Save or update a conversation + */ + saveConversation(conversation: ConversationRecord): void { + const promptPreview = conversation.turns.length > 0 + ? conversation.turns[conversation.turns.length - 1].prompt.substring(0, 100) + : ''; + + const upsertConversation = this.db.prepare(` + INSERT INTO conversations (id, created_at, updated_at, tool, model, mode, total_duration_ms, turn_count, latest_status, prompt_preview) + VALUES (@id, @created_at, @updated_at, @tool, @model, @mode, @total_duration_ms, @turn_count, @latest_status, @prompt_preview) + ON CONFLICT(id) DO UPDATE SET + updated_at = @updated_at, + total_duration_ms = @total_duration_ms, + turn_count = @turn_count, + latest_status = @latest_status, + prompt_preview = @prompt_preview + `); + + const upsertTurn = this.db.prepare(` + INSERT INTO turns (conversation_id, turn_number, timestamp, prompt, duration_ms, status, exit_code, stdout, stderr, truncated) + VALUES (@conversation_id, @turn_number, @timestamp, @prompt, @duration_ms, @status, @exit_code, @stdout, @stderr, @truncated) + ON CONFLICT(conversation_id, turn_number) DO UPDATE SET + timestamp = @timestamp, + prompt = @prompt, + duration_ms = @duration_ms, + status = @status, + exit_code = @exit_code, + stdout = @stdout, + stderr = @stderr, + truncated = @truncated + `); + + const transaction = this.db.transaction(() => { + upsertConversation.run({ + id: conversation.id, + created_at: conversation.created_at, + updated_at: conversation.updated_at, + tool: conversation.tool, + model: conversation.model, + mode: conversation.mode, + total_duration_ms: conversation.total_duration_ms, + turn_count: conversation.turn_count, + latest_status: conversation.latest_status, + prompt_preview: promptPreview + }); + + for (const turn of conversation.turns) { + upsertTurn.run({ + conversation_id: conversation.id, + turn_number: turn.turn, + timestamp: turn.timestamp, + prompt: turn.prompt, + duration_ms: turn.duration_ms, + status: turn.status, + exit_code: turn.exit_code, + stdout: turn.output.stdout, + stderr: turn.output.stderr, + truncated: turn.output.truncated ? 1 : 0 + }); + } + }); + + transaction(); + } + + /** + * Get conversation by ID + */ + getConversation(id: string): ConversationRecord | null { + const conv = this.db.prepare(` + SELECT * FROM conversations WHERE id = ? + `).get(id) as any; + + if (!conv) return null; + + const turns = this.db.prepare(` + SELECT * FROM turns WHERE conversation_id = ? ORDER BY turn_number ASC + `).all(id) as any[]; + + return { + id: conv.id, + created_at: conv.created_at, + updated_at: conv.updated_at, + tool: conv.tool, + model: conv.model, + mode: conv.mode, + total_duration_ms: conv.total_duration_ms, + turn_count: conv.turn_count, + latest_status: conv.latest_status, + turns: turns.map(t => ({ + turn: t.turn_number, + timestamp: t.timestamp, + prompt: t.prompt, + duration_ms: t.duration_ms, + status: t.status, + exit_code: t.exit_code, + output: { + stdout: t.stdout || '', + stderr: t.stderr || '', + truncated: !!t.truncated + } + })) + }; + } + + /** + * Query execution history + */ + getHistory(options: HistoryQueryOptions = {}): { + total: number; + count: number; + executions: HistoryIndexEntry[]; + } { + const { limit = 50, offset = 0, tool, status, search, startDate, endDate } = options; + + let whereClause = '1=1'; + const params: any = {}; + + if (tool) { + whereClause += ' AND tool = @tool'; + params.tool = tool; + } + + if (status) { + whereClause += ' AND latest_status = @status'; + params.status = status; + } + + if (startDate) { + whereClause += ' AND created_at >= @startDate'; + params.startDate = startDate; + } + + if (endDate) { + whereClause += ' AND created_at <= @endDate'; + params.endDate = endDate; + } + + // Full-text search + let joinClause = ''; + if (search) { + joinClause = ` + INNER JOIN ( + SELECT DISTINCT conversation_id FROM turns t + INNER JOIN turns_fts ON turns_fts.rowid = t.id + WHERE turns_fts MATCH @search + ) AS matched ON c.id = matched.conversation_id + `; + params.search = search; + } + + const countQuery = this.db.prepare(` + SELECT COUNT(*) as count FROM conversations c ${joinClause} WHERE ${whereClause} + `); + const total = (countQuery.get(params) as any).count; + + const dataQuery = this.db.prepare(` + SELECT c.* FROM conversations c ${joinClause} + WHERE ${whereClause} + ORDER BY c.updated_at DESC + LIMIT @limit OFFSET @offset + `); + + const rows = dataQuery.all({ ...params, limit, offset }) as any[]; + + return { + total, + count: rows.length, + executions: rows.map(r => ({ + id: r.id, + timestamp: r.created_at, + updated_at: r.updated_at, + tool: r.tool, + status: r.latest_status, + duration_ms: r.total_duration_ms, + turn_count: r.turn_count, + prompt_preview: r.prompt_preview || '' + })) + }; + } + + /** + * Delete a conversation + */ + deleteConversation(id: string): { success: boolean; error?: string } { + try { + const result = this.db.prepare('DELETE FROM conversations WHERE id = ?').run(id); + return { success: result.changes > 0 }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + } + + /** + * Batch delete conversations + */ + batchDelete(ids: string[]): { success: boolean; deleted: number; errors?: string[] } { + const deleteStmt = this.db.prepare('DELETE FROM conversations WHERE id = ?'); + const errors: string[] = []; + let deleted = 0; + + const transaction = this.db.transaction(() => { + for (const id of ids) { + try { + const result = deleteStmt.run(id); + if (result.changes > 0) deleted++; + } catch (err) { + errors.push(`${id}: ${(err as Error).message}`); + } + } + }); + + transaction(); + + return { + success: true, + deleted, + errors: errors.length > 0 ? errors : undefined + }; + } + + /** + * Delete conversations by tool + */ + deleteByTool(tool: string): { success: boolean; deleted: number } { + const result = this.db.prepare('DELETE FROM conversations WHERE tool = ?').run(tool); + return { success: true, deleted: result.changes }; + } + + /** + * Delete all conversations + */ + deleteAll(): { success: boolean; deleted: number } { + const count = (this.db.prepare('SELECT COUNT(*) as c FROM conversations').get() as any).c; + this.db.prepare('DELETE FROM conversations').run(); + return { success: true, deleted: count }; + } + + /** + * Get statistics + */ + getStats(): { + total: number; + byTool: Record; + byStatus: Record; + totalDuration: number; + } { + const total = (this.db.prepare('SELECT COUNT(*) as c FROM conversations').get() as any).c; + + const byToolRows = this.db.prepare(` + SELECT tool, COUNT(*) as count FROM conversations GROUP BY tool + `).all() as any[]; + const byTool: Record = {}; + for (const row of byToolRows) { + byTool[row.tool] = row.count; + } + + const byStatusRows = this.db.prepare(` + SELECT latest_status, COUNT(*) as count FROM conversations GROUP BY latest_status + `).all() as any[]; + const byStatus: Record = {}; + for (const row of byStatusRows) { + byStatus[row.latest_status] = row.count; + } + + const totalDuration = (this.db.prepare(` + SELECT COALESCE(SUM(total_duration_ms), 0) as total FROM conversations + `).get() as any).total; + + return { total, byTool, byStatus, totalDuration }; + } + + /** + * Close database connection + */ + close(): void { + this.db.close(); + } +} + +// Singleton instance cache +const storeCache = new Map(); + +/** + * Get or create a store instance for a directory + */ +export function getHistoryStore(baseDir: string): CliHistoryStore { + if (!storeCache.has(baseDir)) { + storeCache.set(baseDir, new CliHistoryStore(baseDir)); + } + return storeCache.get(baseDir)!; +} + +/** + * Close all store instances + */ +export function closeAllStores(): void { + for (const store of storeCache.values()) { + store.close(); + } + storeCache.clear(); +}