mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
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:
350
ccw/package-lock.json
generated
350
ccw/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
528
ccw/src/tools/cli-history-store.ts
Normal file
528
ccw/src/tools/cli-history-store.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user