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.
This commit is contained in:
catlog22
2025-12-13 14:53:53 +08:00
parent 37417caca2
commit 029384c427
9 changed files with 2380 additions and 279 deletions

350
ccw/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<http.Ser
return;
}
// API: CLI Settings
if (pathname === '/api/cli/settings' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
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<http.Ser
return;
}
// API: Batch Delete CLI Executions
if (pathname === '/api/cli/batch-delete' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
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) => {

View File

@@ -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;
}

View File

@@ -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) => `
<div class="cli-turn-section">
<div class="cli-turn-header">
<span class="cli-turn-number">Turn ${turn.turn}</span>
<span class="cli-turn-status status-${turn.status}">${turn.status}</span>
<span class="cli-turn-duration">${formatDuration(turn.duration_ms)}</span>
</div>
<div class="cli-detail-section">
<h4><i data-lucide="message-square"></i> Prompt</h4>
<pre class="cli-detail-prompt">${escapeHtml(turn.prompt)}</pre>
</div>
${turn.output.stdout ? `
<div class="cli-detail-section">
<h4><i data-lucide="terminal"></i> Output</h4>
<pre class="cli-detail-output">${escapeHtml(turn.output.stdout)}</pre>
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 `
<div class="cli-turn-section ${isLast ? 'cli-turn-latest' : ''}">
<div class="cli-turn-header">
<div class="cli-turn-marker">
<span class="cli-turn-number">${isFirst ? '▶' : '↳'} Turn ${turn.turn}</span>
${isLast ? '<span class="cli-turn-latest-badge">Latest</span>' : ''}
</div>
<div class="cli-turn-meta">
<span class="cli-turn-time"><i data-lucide="clock" class="w-3 h-3"></i> ${turnTime}</span>
<span class="cli-turn-status status-${turn.status}">
<i data-lucide="${statusIcon}" class="w-3 h-3"></i> ${turn.status}
</span>
<span class="cli-turn-duration">${formatDuration(turn.duration_ms)}</span>
</div>
</div>
` : ''}
${turn.output.stderr ? `
<div class="cli-detail-section cli-detail-error-section">
<h4><i data-lucide="alert-triangle"></i> Errors</h4>
<pre class="cli-detail-error">${escapeHtml(turn.output.stderr)}</pre>
<div class="cli-turn-body">
<div class="cli-detail-section cli-prompt-section">
<h4><i data-lucide="user" class="w-3.5 h-3.5"></i> User Prompt</h4>
<pre class="cli-detail-prompt">${escapeHtml(turn.prompt)}</pre>
</div>
${turn.output.stdout ? `
<div class="cli-detail-section cli-output-section">
<h4><i data-lucide="bot" class="w-3.5 h-3.5"></i> Assistant Response</h4>
<pre class="cli-detail-output">${escapeHtml(turn.output.stdout)}</pre>
</div>
` : ''}
${turn.output.stderr ? `
<div class="cli-detail-section cli-detail-error-section">
<h4><i data-lucide="alert-triangle" class="w-3.5 h-3.5"></i> Errors</h4>
<pre class="cli-detail-error">${escapeHtml(turn.output.stderr)}</pre>
</div>
` : ''}
${turn.output.truncated ? `
<p class="cli-truncated-notice">
<i data-lucide="info" class="w-3 h-3"></i>
Output was truncated due to size.
</p>
` : ''}
</div>
` : ''}
${turn.output.truncated ? `
<p class="text-warning" style="font-size: 0.75rem; margin-top: 0.5rem;">
<i data-lucide="info" class="w-3 h-3" style="display: inline;"></i>
Output was truncated due to size.
</p>
` : ''}
</div>
`).join('<hr class="cli-turn-divider">');
</div>
`;
}).join('<div class="cli-turn-connector"><div class="cli-turn-line"></div></div>');
} else {
// Legacy single execution format
const detail = conversation;
turnsHtml = `
<div class="cli-detail-section">
<h4><i data-lucide="message-square"></i> Prompt</h4>
<pre class="cli-detail-prompt">${escapeHtml(detail.prompt)}</pre>
<div class="cli-turn-section">
<div class="cli-turn-body">
<div class="cli-detail-section cli-prompt-section">
<h4><i data-lucide="user" class="w-3.5 h-3.5"></i> User Prompt</h4>
<pre class="cli-detail-prompt">${escapeHtml(detail.prompt)}</pre>
</div>
${detail.output.stdout ? `
<div class="cli-detail-section cli-output-section">
<h4><i data-lucide="bot" class="w-3.5 h-3.5"></i> Assistant Response</h4>
<pre class="cli-detail-output">${escapeHtml(detail.output.stdout)}</pre>
</div>
` : ''}
${detail.output.stderr ? `
<div class="cli-detail-section cli-detail-error-section">
<h4><i data-lucide="alert-triangle" class="w-3.5 h-3.5"></i> Errors</h4>
<pre class="cli-detail-error">${escapeHtml(detail.output.stderr)}</pre>
</div>
` : ''}
${detail.output.truncated ? `
<p class="cli-truncated-notice">
<i data-lucide="info" class="w-3 h-3"></i>
Output was truncated due to size.
</p>
` : ''}
</div>
</div>
${detail.output.stdout ? `
`;
}
// Build concatenated prompt view (for multi-turn conversations)
let concatenatedPromptHtml = '';
if (isConversation && conversation.turns.length > 1) {
concatenatedPromptHtml = `
<div class="cli-concat-section" id="concatPromptSection" style="display: none;">
<div class="cli-detail-section">
<h4><i data-lucide="terminal"></i> Output</h4>
<pre class="cli-detail-output">${escapeHtml(detail.output.stdout)}</pre>
<h4><i data-lucide="layers" class="w-3.5 h-3.5"></i> Concatenated Prompt (sent to CLI)</h4>
<div class="cli-concat-format-selector">
<button class="btn btn-xs ${true ? 'btn-primary' : 'btn-outline'}" onclick="switchConcatFormat('plain', '${executionId}')">Plain</button>
<button class="btn btn-xs btn-outline" onclick="switchConcatFormat('yaml', '${executionId}')">YAML</button>
<button class="btn btn-xs btn-outline" onclick="switchConcatFormat('json', '${executionId}')">JSON</button>
</div>
<pre class="cli-detail-output cli-concat-output" id="concatPromptOutput">${escapeHtml(buildConcatenatedPrompt(conversation, 'plain'))}</pre>
</div>
` : ''}
${detail.output.stderr ? `
<div class="cli-detail-section">
<h4><i data-lucide="alert-triangle"></i> Errors</h4>
<pre class="cli-detail-error">${escapeHtml(detail.output.stderr)}</pre>
</div>
` : ''}
${detail.output.truncated ? `
<p class="text-warning" style="font-size: 0.75rem; margin-top: 0.5rem;">
<i data-lucide="info" class="w-3 h-3" style="display: inline;"></i>
Output was truncated due to size.
</p>
` : ''}
</div>
`;
}
@@ -247,7 +287,7 @@ async function showExecutionDetail(executionId, sourceDir) {
<div class="cli-detail-header">
<div class="cli-detail-info">
<span class="cli-tool-tag cli-tool-${conversation.tool}">${conversation.tool}</span>
${turnCount > 1 ? `<span class="cli-turn-badge">${turnCount} turns</span>` : ''}
${turnCount > 1 ? `<span class="cli-turn-badge"><i data-lucide="messages-square" class="w-3 h-3"></i> ${turnCount} turns</span>` : ''}
<span class="cli-detail-status status-${latestStatus}">${latestStatus}</span>
<span class="text-muted-foreground">${formatDuration(totalDuration)}</span>
</div>
@@ -255,21 +295,41 @@ async function showExecutionDetail(executionId, sourceDir) {
<span><i data-lucide="cpu" class="w-3 h-3"></i> ${conversation.model || 'default'}</span>
<span><i data-lucide="toggle-right" class="w-3 h-3"></i> ${conversation.mode}</span>
<span><i data-lucide="calendar" class="w-3 h-3"></i> ${new Date(createdAt).toLocaleString()}</span>
<span><i data-lucide="hash" class="w-3 h-3"></i> ${executionId.split('-')[0]}</span>
</div>
</div>
<div class="cli-turns-container">
${turnCount > 1 ? `
<div class="cli-view-toggle">
<button class="btn btn-sm btn-outline active" onclick="toggleConversationView('turns')">
<i data-lucide="list" class="w-3.5 h-3.5"></i> Per-Turn View
</button>
<button class="btn btn-sm btn-outline" onclick="toggleConversationView('concat')">
<i data-lucide="layers" class="w-3.5 h-3.5"></i> Concatenated View
</button>
</div>
` : ''}
<div class="cli-turns-container" id="turnsContainer">
${turnsHtml}
</div>
${concatenatedPromptHtml}
<div class="cli-detail-actions">
<button class="btn btn-sm btn-outline" onclick="copyConversationId('${executionId}')">
<i data-lucide="copy" class="w-3.5 h-3.5"></i> Copy ID
</button>
${turnCount > 1 ? `
<button class="btn btn-sm btn-outline" onclick="copyConcatenatedPrompt('${executionId}')">
<i data-lucide="clipboard-copy" class="w-3.5 h-3.5"></i> Copy Full Prompt
</button>
` : ''}
<button class="btn btn-sm btn-outline btn-danger" onclick="confirmDeleteExecution('${executionId}'); closeModal();">
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i> Delete
</button>
</div>
`;
// 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) {

View File

@@ -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() {
</div>
` : '';
// CLI Settings section
const settingsHtml = `
<div class="cli-settings-section">
<div class="cli-settings-header">
<h4><i data-lucide="settings" class="w-3.5 h-3.5"></i> Settings</h4>
</div>
<div class="cli-settings-grid">
<div class="cli-setting-item">
<label class="cli-setting-label">
<i data-lucide="layers" class="w-3 h-3"></i>
Prompt Format
</label>
<div class="cli-setting-control">
<select class="cli-setting-select" onchange="setPromptFormat(this.value)">
<option value="plain" ${promptConcatFormat === 'plain' ? 'selected' : ''}>Plain Text</option>
<option value="yaml" ${promptConcatFormat === 'yaml' ? 'selected' : ''}>YAML</option>
<option value="json" ${promptConcatFormat === 'json' ? 'selected' : ''}>JSON</option>
</select>
</div>
<p class="cli-setting-desc">Format for multi-turn conversation concatenation</p>
</div>
<div class="cli-setting-item">
<label class="cli-setting-label">
<i data-lucide="database" class="w-3 h-3"></i>
Storage Backend
</label>
<div class="cli-setting-control">
<select class="cli-setting-select" onchange="setStorageBackend(this.value)">
<option value="sqlite" ${storageBackend === 'sqlite' ? 'selected' : ''}>SQLite (Recommended)</option>
<option value="json" ${storageBackend === 'json' ? 'selected' : ''}>JSON Files</option>
</select>
</div>
<p class="cli-setting-desc">History storage: SQLite for search, JSON for portability</p>
</div>
</div>
</div>
`;
container.innerHTML = `
<div class="cli-status-header">
<h3><i data-lucide="terminal" class="w-4 h-4"></i> CLI Tools</h3>
@@ -219,6 +258,7 @@ function renderCliStatus() {
${codexLensHtml}
${semanticHtml}
</div>
${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();

View File

@@ -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
? '<span class="history-turn-badge"><i data-lucide="messages-square" class="w-3 h-3"></i> ' + exec.turn_count + '</span>'
: '';
var sourceDirHtml = exec.sourceDir && exec.sourceDir !== '.'
? '<span class="history-source-dir"><i data-lucide="folder" class="w-3 h-3"></i> ' + escapeHtml(exec.sourceDir) + '</span>'
: '';
var resumeBadge = isResume ? '<span class="history-resume-badge"><i data-lucide="rotate-ccw" class="w-3 h-3"></i></span>' : '';
// Multi-select checkbox
var checkboxHtml = isMultiSelectMode
? '<div class="history-checkbox-wrapper" onclick="event.stopPropagation(); toggleExecutionSelection(\'' + exec.id + '\')">' +
'<input type="checkbox" class="history-checkbox" ' + (isSelected ? 'checked' : '') + ' tabindex="-1">' +
'</div>'
: '';
historyHtml += '<div class="history-item' + (isResume ? ' history-item-resume' : '') + '" onclick="showExecutionDetail(\'' + exec.id + (exec.sourceDir ? '\',\'' + escapeHtml(exec.sourceDir) : '') + '\')">' +
historyHtml += '<div class="history-item' + (isSelected ? ' history-item-selected' : '') + '" ' +
'onclick="' + (isMultiSelectMode ? 'toggleExecutionSelection(\'' + exec.id + '\')' : 'showExecutionDetail(\'' + exec.id + (exec.sourceDir ? '\',\'' + escapeHtml(exec.sourceDir) : '') + '\')') + '">' +
checkboxHtml +
'<div class="history-item-main">' +
'<div class="history-item-header">' +
'<span class="history-tool-tag tool-' + exec.tool + '">' + exec.tool + '</span>' +
'<span class="history-mode-tag">' + (exec.mode || 'analysis') + '</span>' +
resumeBadge +
turnBadge +
sourceDirHtml +
'<span class="history-status ' + statusClass + '">' +
'<i data-lucide="' + statusIcon + '" class="w-3.5 h-3.5"></i>' +
@@ -75,9 +91,6 @@ async function renderCliHistoryView() {
'</div>' +
'</div>' +
'<div class="history-item-actions">' +
'<button class="btn-icon btn-resume" onclick="event.stopPropagation(); promptResumeExecution(\'' + exec.id + '\', \'' + exec.tool + '\')" title="Resume">' +
'<i data-lucide="play" class="w-4 h-4"></i>' +
'</button>' +
'<button class="btn-icon" onclick="event.stopPropagation(); showExecutionDetail(\'' + exec.id + '\')" title="View Details">' +
'<i data-lucide="eye" class="w-4 h-4"></i>' +
'</button>' +
@@ -90,6 +103,26 @@ async function renderCliHistoryView() {
historyHtml += '</div>';
}
// Build batch actions bar
var batchActionsHtml = '';
if (isMultiSelectMode) {
batchActionsHtml = '<div class="history-batch-actions">' +
'<span class="batch-select-count">' + selectedExecutions.size + ' selected</span>' +
'<button class="btn btn-sm btn-outline" onclick="selectAllExecutions()">' +
'<i data-lucide="check-square" class="w-3.5 h-3.5"></i> Select All' +
'</button>' +
'<button class="btn btn-sm btn-outline" onclick="clearExecutionSelection()">' +
'<i data-lucide="square" class="w-3.5 h-3.5"></i> Clear' +
'</button>' +
'<button class="btn btn-sm btn-danger" onclick="confirmBatchDelete()" ' + (selectedExecutions.size === 0 ? 'disabled' : '') + '>' +
'<i data-lucide="trash-2" class="w-3.5 h-3.5"></i> Delete Selected' +
'</button>' +
'<button class="btn btn-sm btn-outline" onclick="exitMultiSelectMode()">' +
'<i data-lucide="x" class="w-3.5 h-3.5"></i> Cancel' +
'</button>' +
'</div>';
}
container.innerHTML = '<div class="history-view">' +
'<div class="history-header">' +
'<div class="history-header-left">' +
@@ -108,11 +141,36 @@ async function renderCliHistoryView() {
'<option value="qwen"' + (cliHistoryFilter === 'qwen' ? ' selected' : '') + '>Qwen</option>' +
'<option value="codex"' + (cliHistoryFilter === 'codex' ? ' selected' : '') + '>Codex</option>' +
'</select>' +
// Batch delete dropdown
'<div class="history-delete-dropdown">' +
'<button class="btn-icon" onclick="toggleDeleteDropdown(event)" title="Delete Options">' +
'<i data-lucide="trash" class="w-4 h-4"></i>' +
'</button>' +
'<div class="delete-dropdown-menu" id="deleteDropdownMenu">' +
'<button onclick="enterMultiSelectMode()">' +
'<i data-lucide="check-square" class="w-3.5 h-3.5"></i> Multi-select Delete' +
'</button>' +
'<button onclick="confirmDeleteByTool(\'gemini\')">' +
'<i data-lucide="trash-2" class="w-3.5 h-3.5"></i> Delete All Gemini' +
'</button>' +
'<button onclick="confirmDeleteByTool(\'qwen\')">' +
'<i data-lucide="trash-2" class="w-3.5 h-3.5"></i> Delete All Qwen' +
'</button>' +
'<button onclick="confirmDeleteByTool(\'codex\')">' +
'<i data-lucide="trash-2" class="w-3.5 h-3.5"></i> Delete All Codex' +
'</button>' +
'<div class="dropdown-divider"></div>' +
'<button class="delete-all-btn" onclick="confirmDeleteAll()">' +
'<i data-lucide="alert-triangle" class="w-3.5 h-3.5"></i> Delete All History' +
'</button>' +
'</div>' +
'</div>' +
'<button class="btn-icon" onclick="refreshCliHistoryView()" title="Refresh">' +
'<i data-lucide="refresh-cw" class="w-4 h-4"></i>' +
'</button>' +
'</div>' +
'</div>' +
batchActionsHtml +
historyHtml +
'</div>';
@@ -144,79 +202,136 @@ async function refreshCliHistoryView() {
showRefreshToast('History refreshed', 'success');
}
// ========== Resume Execution ==========
function promptResumeExecution(executionId, tool) {
var modalContent = '<div class="resume-modal">' +
'<p>Resume this ' + tool + ' session with an optional continuation prompt:</p>' +
'<textarea id="resumePromptInput" class="resume-prompt-input" placeholder="Continue from where we left off... (optional)" rows="3"></textarea>' +
'<div class="resume-modal-actions">' +
'<button class="btn btn-outline" onclick="closeModal()">Cancel</button>' +
'<button class="btn btn-primary" onclick="executeResume(\'' + executionId + '\', \'' + tool + '\')">' +
'<i data-lucide="play" class="w-4 h-4"></i> Resume' +
'</button>' +
'</div>' +
'</div>';
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');
}
}

View File

@@ -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<void> {
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<ConversationRecord | null> {
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<Record<string, ToolAvailabili
return results;
}
// ========== Prompt Concatenation System ==========
/**
* Build multi-turn prompt with full conversation history
* Supported prompt concatenation formats
*/
function buildMultiTurnPrompt(conversation: ConversationRecord, newPrompt: string): string {
const parts: string[] = [];
type PromptFormat = 'plain' | 'yaml' | 'json';
parts.push('=== CONVERSATION HISTORY ===');
parts.push('');
/**
* Turn data structure for concatenation
*/
interface TurnData {
turn: number;
timestamp?: string;
role: 'user' | 'assistant';
content: string;
status?: string;
duration_ms?: number;
source_id?: string; // For merged conversations
}
// Add all previous turns
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 recorded]');
parts.push('');
/**
* Prompt concatenation options
*/
interface ConcatOptions {
format: PromptFormat;
includeMetadata?: boolean;
includeTurnMarkers?: boolean;
maxOutputLength?: number; // Truncate output for context efficiency
}
/**
* PromptConcatenator - Dedicated class for building multi-turn prompts
* Supports multiple output formats: plain text, YAML, JSON
*/
class PromptConcatenator {
private turns: TurnData[] = [];
private options: ConcatOptions;
private metadata: Record<string, unknown> = {};
constructor(options: Partial<ConcatOptions> = {}) {
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<Omit<TurnData, 'role' | 'content'>> = {}): this {
this.turns.push({
turn: this.turns.length + 1,
role: 'user',
content,
...options
});
return this;
}
/**
* Add an assistant turn
*/
addAssistantTurn(content: string, options: Partial<Omit<TurnData, 'role' | 'content'>> = {}): 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<string, unknown> = {};
// 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<ConcatOptions>): 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,

View File

@@ -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<string, number>;
byStatus: Record<string, number>;
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<string, number> = {};
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<string, number> = {};
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<string, CliHistoryStore>();
/**
* 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();
}