mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-10 15:55:53 +08:00
Compare commits
95 Commits
refactor/u
...
v1.34.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6a45b3183 | ||
|
|
ce327b6fa9 | ||
|
|
276639099b | ||
|
|
f4f88318c2 | ||
|
|
029d159592 | ||
|
|
7c9ec8fa12 | ||
|
|
1b4d4b7278 | ||
|
|
b1a0afe9e0 | ||
|
|
88eb2009bb | ||
|
|
602e6ad4ac | ||
|
|
4a2453fe32 | ||
|
|
f439a8a3d5 | ||
|
|
23210bc40e | ||
|
|
beae8c6513 | ||
|
|
33a4e72ca4 | ||
|
|
f7c0024fe1 | ||
|
|
ca8fd0ee23 | ||
|
|
b7e6bca2e3 | ||
|
|
84c166c4cb | ||
|
|
231ed04002 | ||
|
|
d70dc077bf | ||
|
|
1faa1a6a00 | ||
|
|
3cd89956ba | ||
|
|
01dbe2a8bf | ||
|
|
f4a1614a0a | ||
|
|
c235b05e1d | ||
|
|
dd77649053 | ||
|
|
af3a28abc7 | ||
|
|
371ff034e4 | ||
|
|
3b4d6885aa | ||
|
|
bc9d2dd830 | ||
|
|
c21a9f4561 | ||
|
|
ed9cdf0114 | ||
|
|
b39997c429 | ||
|
|
d638a8982c | ||
|
|
f238050b85 | ||
|
|
beaa2d2533 | ||
|
|
c90b34108e | ||
|
|
323357384e | ||
|
|
d509aa635b | ||
|
|
2149b8776b | ||
|
|
2b416f2dcb | ||
|
|
bb8db5815c | ||
|
|
b3d0f9037d | ||
|
|
3ec76b5bb1 | ||
|
|
14ddbc7c57 | ||
|
|
ebb0e59e80 | ||
|
|
957f53fb99 | ||
|
|
ef2fd48b46 | ||
|
|
cdcac182d4 | ||
|
|
94785bfa57 | ||
|
|
9e608b8426 | ||
|
|
c667b6a179 | ||
|
|
fa9eaf5573 | ||
|
|
2edfef2e3f | ||
|
|
96b16b42e4 | ||
|
|
f082cdc63b | ||
|
|
d9e9df183f | ||
|
|
43c33d5cb1 | ||
|
|
b988e0da51 | ||
|
|
f132a21cd7 | ||
|
|
36b860e322 | ||
|
|
1e125f3db5 | ||
|
|
dbc41dc91d | ||
|
|
38bf21ddf5 | ||
|
|
86948097aa | ||
|
|
951f58751c | ||
|
|
27e509a9b8 | ||
|
|
295bad9c00 | ||
|
|
3b79aab958 | ||
|
|
997cf9fd1a | ||
|
|
374e9de719 | ||
|
|
10f721cf14 | ||
|
|
631695ef73 | ||
|
|
039696c2de | ||
|
|
beb0a50413 | ||
|
|
e89d2da5df | ||
|
|
392c73b693 | ||
|
|
5e7c4c5f8c | ||
|
|
3f71d4932b | ||
|
|
80561ee9e9 | ||
|
|
658421c1c4 | ||
|
|
881465aa71 | ||
|
|
9f2afebc66 | ||
|
|
df3d5de8c1 | ||
|
|
b44c93d884 | ||
|
|
a1c6d667a4 | ||
|
|
0753c04783 | ||
|
|
e1275e6d3c | ||
|
|
ccb8b83692 | ||
|
|
641731b3ef | ||
|
|
d4bdc667cc | ||
|
|
ce724e6e3f | ||
|
|
b4a39c7297 | ||
|
|
44edf94f3a |
120
CHANGELOG.md
120
CHANGELOG.md
@@ -3,6 +3,126 @@
|
|||||||
All notable changes to CloudCLI UI will be documented in this file.
|
All notable changes to CloudCLI UI will be documented in this file.
|
||||||
|
|
||||||
|
|
||||||
|
## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* adding Fable 5 in claude code ([ce327b6](https://github.com/siteboon/claudecodeui/commit/ce327b6fa9329aa3e9a3a1da7225ca01d3b06ac5))
|
||||||
|
|
||||||
|
## [1.33.3](https://github.com/siteboon/claudecodeui/compare/v1.33.2...v1.33.3) (2026-06-09)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* add file tree upload progress ([c235b05](https://github.com/siteboon/claudecodeui/commit/c235b05e1d3b626667dba4043b685512e3cd3d5d))
|
||||||
|
* signal when chat runs complete ([d70dc07](https://github.com/siteboon/claudecodeui/commit/d70dc077bfbbfcf2ff4fa5514fabf7b4485861fa))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* address notification review feedback ([602e6ad](https://github.com/siteboon/claudecodeui/commit/602e6ad4acba612a7ea66fb3bc7485054f5675ee))
|
||||||
|
* align prism plugin name and id with manifest.json ([ca8fd0e](https://github.com/siteboon/claudecodeui/commit/ca8fd0ee235b6a3210157bd0d9af83024d4a2248))
|
||||||
|
* **chat:** re-anchor initial scroll across lazy content reflow ([33a4e72](https://github.com/siteboon/claudecodeui/commit/33a4e72ca4f84df60aadfc4ff3f3467d6f5ae948))
|
||||||
|
* keep editor toolbar in view on long unwrapped lines ([beae8c6](https://github.com/siteboon/claudecodeui/commit/beae8c6513daa7518b9de40d8bfde3bf08e7bc87))
|
||||||
|
* **sandbox:** prevent server SIGHUP on sbx exec exit ([#792](https://github.com/siteboon/claudecodeui/issues/792)) ([f4a1614](https://github.com/siteboon/claudecodeui/commit/f4a1614a0a4ab4b65e8368d5e4221f015cb7555d)), closes [#791](https://github.com/siteboon/claudecodeui/issues/791)
|
||||||
|
* slash command suggestions trigger at any / in input, not only at start ([#843](https://github.com/siteboon/claudecodeui/issues/843)) ([f7c0024](https://github.com/siteboon/claudecodeui/commit/f7c0024fe15057ad049c71e15e88adb482a4497f))
|
||||||
|
* update naming convention ([3cd8995](https://github.com/siteboon/claudecodeui/commit/3cd89956ba06f0fc3e17d349b0c50baab4012658))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* add prism plugin ([01dbe2a](https://github.com/siteboon/claudecodeui/commit/01dbe2a8bfcb3b265995f01f905b218d5f576f7b))
|
||||||
|
|
||||||
|
## [1.33.2](https://github.com/siteboon/claudecodeui/compare/v1.33.1...v1.33.2) (2026-06-08)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* **chat:** open cost modal from token usage ([f238050](https://github.com/siteboon/claudecodeui/commit/f238050b85c3b99a702a8635059735e1a3b3a4f4))
|
||||||
|
* **i18n:** add Traditional Chinese (zh-TW) locale ([#773](https://github.com/siteboon/claudecodeui/issues/773)) ([c21a9f4](https://github.com/siteboon/claudecodeui/commit/c21a9f45610eb1eeb650d8e6cf8650e798f77f6f))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* do not show model description in chat view ([d638a89](https://github.com/siteboon/claudecodeui/commit/d638a8982c7f75b08fc7f65f01d6d54989c790d1))
|
||||||
|
* include Claude cache tokens in usage ([ed9cdf0](https://github.com/siteboon/claudecodeui/commit/ed9cdf01145fa0d063580bb76d30cfa7ee67af86))
|
||||||
|
|
||||||
|
## [1.33.1](https://github.com/siteboon/claudecodeui/compare/v1.33.0...v1.33.1) (2026-06-05)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* **chat:** auto-detect text direction for RTL languages ([#729](https://github.com/siteboon/claudecodeui/issues/729)) ([fa9eaf5](https://github.com/siteboon/claudecodeui/commit/fa9eaf5573a6f870a19fb62ab430ffd87c466582))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* file tree concurrency ([#828](https://github.com/siteboon/claudecodeui/issues/828)) ([ebb0e59](https://github.com/siteboon/claudecodeui/commit/ebb0e59e8023c0a8040d168a5adffb7102e80561))
|
||||||
|
* load claude models directly from provider ([cdcac18](https://github.com/siteboon/claudecodeui/commit/cdcac182d458a24908777568979c8e756f94428c))
|
||||||
|
* plugin svg icon sanitization ([#817](https://github.com/siteboon/claudecodeui/issues/817)) ([d9e9df1](https://github.com/siteboon/claudecodeui/commit/d9e9df183f462c88c3b60975eb8254faa9168717))
|
||||||
|
* recognize claude auth token env ([#818](https://github.com/siteboon/claudecodeui/issues/818)) ([43c33d5](https://github.com/siteboon/claudecodeui/commit/43c33d5cb1b41835dfe3bccd450c5a9c2441509b))
|
||||||
|
* redact websocket auth token in logs ([#827](https://github.com/siteboon/claudecodeui/issues/827)) ([14ddbc7](https://github.com/siteboon/claudecodeui/commit/14ddbc7c57a01da9fb65fd87d8588532b11833fa))
|
||||||
|
* remove thinking mode ([#835](https://github.com/siteboon/claudecodeui/issues/835)) ([2149b87](https://github.com/siteboon/claudecodeui/commit/2149b8776b7ebfec0eace413f4fc527ccb2324c0))
|
||||||
|
* **shell:** disconnect and restart buttons ([#831](https://github.com/siteboon/claudecodeui/issues/831)) ([ef2fd48](https://github.com/siteboon/claudecodeui/commit/ef2fd48b46452d4b9e2bf1f5e3c30fafe19f27f2))
|
||||||
|
* show Claude tool result errors ([bb8db58](https://github.com/siteboon/claudecodeui/commit/bb8db5815c2d20ee4fbfa02d14c886a56ef352e0))
|
||||||
|
* **vite:** proxy /plugin-ws WebSocket requests to the backend in dev ([#757](https://github.com/siteboon/claudecodeui/issues/757)) ([96b16b4](https://github.com/siteboon/claudecodeui/commit/96b16b42e4f807d04ec743a5a4117a37a3f5e0d9))
|
||||||
|
* **websocket:** add 30s server-side heartbeat to prevent proxy idle disconnects ([#770](https://github.com/siteboon/claudecodeui/issues/770)) ([2edfef2](https://github.com/siteboon/claudecodeui/commit/2edfef2e3f4271c29ae8670df9dd382a9eef7c3c)), closes [#769](https://github.com/siteboon/claudecodeui/issues/769)
|
||||||
|
* **websocket:** reset unmountedRef on each effect re-run so token refresh reconnects ([#721](https://github.com/siteboon/claudecodeui/issues/721)) ([f082cdc](https://github.com/siteboon/claudecodeui/commit/f082cdc63bd0de90f8b3da1df6071e91ab545831))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* add nginx subpath deployment template ([#820](https://github.com/siteboon/claudecodeui/issues/820)) ([3ec76b5](https://github.com/siteboon/claudecodeui/commit/3ec76b5bb15a13cec41056f4c9b9c425195022fa))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* update Claude fallback models ([94785bf](https://github.com/siteboon/claudecodeui/commit/94785bfa579d1f39a2bee0f9dd0f09fd0243bc79))
|
||||||
|
* update package-lock.json ([c90b341](https://github.com/siteboon/claudecodeui/commit/c90b34108e86a3effdb5c6979ea7b1692d2b9da0))
|
||||||
|
|
||||||
|
## [](https://github.com/siteboon/claudecodeui/compare/v1.32.0...vnull) (2026-06-01)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* add opencode support ([#762](https://github.com/siteboon/claudecodeui/issues/762)) ([374e9de](https://github.com/siteboon/claudecodeui/commit/374e9de71934c41ce2c19c796e35a19234b240ec))
|
||||||
|
* **sidebar:** tooltip for the active-session indicator dot ([#782](https://github.com/siteboon/claudecodeui/issues/782)) ([27e509a](https://github.com/siteboon/claudecodeui/commit/27e509a9b8bb25c35ae0abbda44c536e15c332c8))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **chat:** prevent double send on mobile by removing redundant submit handlers ([#719](https://github.com/siteboon/claudecodeui/issues/719)) ([dbc41dc](https://github.com/siteboon/claudecodeui/commit/dbc41dc91dbf1fb54f92f5536d64646b4e924f31))
|
||||||
|
* preserve WebSocket frame type in plugin proxy ([#594](https://github.com/siteboon/claudecodeui/issues/594)) ([36b860e](https://github.com/siteboon/claudecodeui/commit/36b860e322454df62ebf5309018590b596e6b913)), closes [CoderLuii/HolyClaude#11](https://github.com/CoderLuii/HolyClaude/issues/11)
|
||||||
|
* refine token usage reporting ([#807](https://github.com/siteboon/claudecodeui/issues/807)) ([38bf21d](https://github.com/siteboon/claudecodeui/commit/38bf21ddf554ed28676d86b5221c25adf6f07afd))
|
||||||
|
* refresh Claude auth status after login flow ([#617](https://github.com/siteboon/claudecodeui/issues/617)) ([1e125f3](https://github.com/siteboon/claudecodeui/commit/1e125f3db5248399cd50dc3d40b1f8f44cf7ccb6))
|
||||||
|
* **sidebar:** keep session rename input visible while editing ([#781](https://github.com/siteboon/claudecodeui/issues/781)) ([951f587](https://github.com/siteboon/claudecodeui/commit/951f58751c152fbbb3f8b3ce3c814c06c061de18))
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
* fix project star button location by replacing folder icon ([#793](https://github.com/siteboon/claudecodeui/issues/793)) ([295bad9](https://github.com/siteboon/claudecodeui/commit/295bad9c006b669878cbf52940794f29f7370178))
|
||||||
|
|
||||||
|
## [1.32.0](https://github.com/siteboon/claudecodeui/compare/v1.31.5...v1.32.0) (2026-05-13)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add clarification on auto mode ([392c73b](https://github.com/siteboon/claudecodeui/commit/392c73b6933600ea8a589c5d4eff5f7b830f99c5))
|
||||||
|
* enhance regex to correctly parse wrapper file paths for claude.exe ([#741](https://github.com/siteboon/claudecodeui/issues/741)) ([beb0a50](https://github.com/siteboon/claudecodeui/commit/beb0a50413beddfb16f6b49103e1b6b80567cb90))
|
||||||
|
|
||||||
|
## [1.31.5](https://github.com/siteboon/claudecodeui/compare/v1.31.4...v1.31.5) (2026-04-30)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* add auto mode to claude code ([3f71d49](https://github.com/siteboon/claudecodeui/commit/3f71d4932b05dfedcdf816e2a3d7d0cd69c4f566))
|
||||||
|
|
||||||
|
## [1.31.4](https://github.com/siteboon/claudecodeui/compare/v1.31.3...v1.31.4) (2026-04-30)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bump codex sdk to latest version ([658421c](https://github.com/siteboon/claudecodeui/commit/658421c1c44ec4eb58b69ec7b1844a9fba11a3f3))
|
||||||
|
|
||||||
|
## [1.31.3](https://github.com/siteboon/claudecodeui/compare/v1.31.2...v1.31.3) (2026-04-30)
|
||||||
|
|
||||||
|
## [1.31.2](https://github.com/siteboon/claudecodeui/compare/v1.31.0...v1.31.2) (2026-04-30)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* migrations for new sqlite schema ([0753c04](https://github.com/siteboon/claudecodeui/commit/0753c047837dab17b86ae4453027e30b465870f8))
|
||||||
|
|
||||||
|
## [1.31.0](https://github.com/siteboon/claudecodeui/compare/v1.30.0...v1.31.0) (2026-04-30)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **/status:** use CLAUDE_MODELS.DEFAULT instead of stale 'claude-sonnet-4.5' fallback ([#723](https://github.com/siteboon/claudecodeui/issues/723)) ([b4a39c7](https://github.com/siteboon/claudecodeui/commit/b4a39c729710a6294c62eb742e99e05f3e3914e9))
|
||||||
|
|
||||||
## [1.30.0](https://github.com/siteboon/claudecodeui/compare/v1.29.5...v1.30.0) (2026-04-21)
|
## [1.30.0](https://github.com/siteboon/claudecodeui/compare/v1.29.5...v1.30.0) (2026-04-21)
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
- **Sitzungsverwaltung** – Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen
|
- **Sitzungsverwaltung** – Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen
|
||||||
- **Plugin-System** – CloudCLI mit eigenen Plugins erweitern – neue Tabs, Backend-Dienste und Integrationen hinzufügen. [Eigenes Plugin erstellen →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
- **Plugin-System** – CloudCLI mit eigenen Plugins erweitern – neue Tabs, Backend-Dienste und Integrationen hinzufügen. [Eigenes Plugin erstellen →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||||
- **TaskMaster AI Integration** *(Optional)* – Erweitertes Projektmanagement mit KI-gestützter Aufgabenplanung, PRD-Parsing und Workflow-Automatisierung
|
- **TaskMaster AI Integration** *(Optional)* – Erweitertes Projektmanagement mit KI-gestützter Aufgabenplanung, PRD-Parsing und Workflow-Automatisierung
|
||||||
- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`shared/modelConstants.js`](shared/modelConstants.js))
|
- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`public/modelConstants.js`](public/modelConstants.js))
|
||||||
|
|
||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
|
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
- **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적
|
- **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적
|
||||||
- **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
- **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||||
- **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리
|
- **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리
|
||||||
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`shared/modelConstants.js`에서 전체 지원 모델 확인)
|
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`public/modelConstants.js`에서 전체 지원 모델 확인)
|
||||||
|
|
||||||
## 빠른 시작
|
## 빠른 시작
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||||
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
||||||
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](shared/modelConstants.js) for the full list of supported models)
|
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](public/modelConstants.js) for the full list of supported models)
|
||||||
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -164,7 +164,7 @@ CloudCLI has a plugin system that lets you add custom tabs with their own fronte
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project |
|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project |
|
||||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support|
|
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support|
|
||||||
|
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI|
|
||||||
### Build Your Own
|
### Build Your Own
|
||||||
|
|
||||||
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server.
|
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
- **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю
|
- **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю
|
||||||
- **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
- **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||||
- **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow
|
- **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow
|
||||||
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`shared/modelConstants.js`](shared/modelConstants.js) для полного списка поддерживаемых моделей)
|
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`public/modelConstants.js`](public/modelConstants.js) для полного списка поддерживаемых моделей)
|
||||||
|
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
|
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
- **Oturum Yönetimi** — Konuşmalara devam et, birden fazla oturumu yönet ve geçmişi takip et
|
- **Oturum Yönetimi** — Konuşmalara devam et, birden fazla oturumu yönet ve geçmişi takip et
|
||||||
- **Eklenti Sistemi** — CloudCLI'ı özel eklentilerle genişlet: yeni sekmeler, arka uç servisleri ve entegrasyonlar ekle. [Kendi eklentini yaz →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
- **Eklenti Sistemi** — CloudCLI'ı özel eklentilerle genişlet: yeni sekmeler, arka uç servisleri ve entegrasyonlar ekle. [Kendi eklentini yaz →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||||
- **TaskMaster AI Entegrasyonu** *(İsteğe Bağlı)* — AI destekli görev planlama, PRD ayrıştırma ve iş akışı otomasyonu ile gelişmiş proje yönetimi
|
- **TaskMaster AI Entegrasyonu** *(İsteğe Bağlı)* — AI destekli görev planlama, PRD ayrıştırma ve iş akışı otomasyonu ile gelişmiş proje yönetimi
|
||||||
- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için [`shared/modelConstants.js`](shared/modelConstants.js) dosyasına bak)
|
- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için [`public/modelConstants.js`](public/modelConstants.js) dosyasına bak)
|
||||||
|
|
||||||
|
|
||||||
## Hızlı Başlangıç
|
## Hızlı Başlangıç
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>简体中文</b> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
|
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
|
||||||
- **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
- **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||||
- **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理
|
- **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理
|
||||||
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`shared/modelConstants.js`](shared/modelConstants.js))
|
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`public/modelConstants.js`](public/modelConstants.js))
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
|||||||
242
README.zh-TW.md
Normal file
242
README.zh-TW.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
|
||||||
|
<h1>Cloud CLI(又名 Claude Code UI)</h1>
|
||||||
|
<p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>、<a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>、<a href="https://developers.openai.com/codex">Codex</a> 和 <a href="https://geminicli.com/">Gemini-CLI</a> 的桌面和行動裝置 UI。可在本機或遠端使用,從任何地方查看您的專案與工作階段。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">文件</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug 回報</a> · <a href="CONTRIBUTING.md">貢獻指南</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁️_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
|
||||||
|
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="加入 Discord 社群"></a>
|
||||||
|
<br><br>
|
||||||
|
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <b>繁體中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 截圖
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<h3>桌面檢視</h3>
|
||||||
|
<img src="public/screenshots/desktop-main.png" alt="桌面介面" width="400">
|
||||||
|
<br>
|
||||||
|
<em>顯示專案總覽和聊天的主介面</em>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<h3>行動裝置體驗</h3>
|
||||||
|
<img src="public/screenshots/mobile-chat.png" alt="行動裝置介面" width="250">
|
||||||
|
<br>
|
||||||
|
<em>具有觸控導覽的響應式行動裝置設計</em>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" colspan="2">
|
||||||
|
<h3>CLI 選擇</h3>
|
||||||
|
<img src="public/screenshots/cli-selection.png" alt="CLI 選擇" width="400">
|
||||||
|
<br>
|
||||||
|
<em>在 Claude Code、Gemini、Cursor CLI 與 Codex 之間進行選擇</em>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- **響應式設計** — 在桌面、平板和行動裝置上無縫運作,讓您隨時隨地使用 Agents
|
||||||
|
- **互動聊天介面** — 內建聊天 UI,輕鬆與 Agents 交流
|
||||||
|
- **整合 Shell 終端機** — 透過內建 shell 功能直接存取 Agents CLI
|
||||||
|
- **檔案瀏覽器** — 互動式檔案樹,支援語法醒目提示與即時編輯
|
||||||
|
- **Git 瀏覽器** — 檢視、暫存並提交變更,還可切換分支
|
||||||
|
- **工作階段管理** — 恢復對話、管理多個工作階段並追蹤歷史紀錄
|
||||||
|
- **外掛系統** — 透過自訂分頁、後端服務與整合來擴充 CloudCLI。[開始建構 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||||
|
- **TaskMaster AI 整合** *(選用)* — 結合 AI 任務規劃、PRD 分析與工作流程自動化,實現進階專案管理
|
||||||
|
- **模型相容性** — 支援 Claude、GPT、Gemini 模型家族(完整支援列表見 [`shared/modelConstants.js`](shared/modelConstants.js))
|
||||||
|
|
||||||
|
## 快速開始
|
||||||
|
|
||||||
|
### CloudCLI Cloud(推薦)
|
||||||
|
|
||||||
|
無需本機設定即可快速啟動。提供可透過網路瀏覽器、行動應用程式、API 或慣用的 IDE 存取的完全容器化託管開發環境。
|
||||||
|
|
||||||
|
**[立即開始 CloudCLI Cloud](https://cloudcli.ai)**
|
||||||
|
|
||||||
|
### 自架(開源)
|
||||||
|
|
||||||
|
#### npm
|
||||||
|
|
||||||
|
啟動 CloudCLI UI,只需一行 `npx`(需要 Node.js v22+):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @cloudcli-ai/cloudcli
|
||||||
|
```
|
||||||
|
|
||||||
|
或進行全域安裝,便於日常使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @cloudcli-ai/cloudcli
|
||||||
|
cloudcli
|
||||||
|
```
|
||||||
|
|
||||||
|
開啟 `http://localhost:3001`,系統會自動發現所有現有工作階段。
|
||||||
|
|
||||||
|
更多設定選項、PM2、遠端伺服器設定等,請參閱 **[文件 →](https://cloudcli.ai/docs)**。
|
||||||
|
|
||||||
|
#### Docker Sandboxes(實驗性)
|
||||||
|
|
||||||
|
在隔離的沙箱中執行代理,具有虛擬機管理程式等級的隔離。預設啟動 Claude Code。需要 [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
|
||||||
|
```
|
||||||
|
|
||||||
|
支援 Claude Code、Codex 和 Gemini CLI。詳情請參閱[沙箱文件](docker/)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 哪個選項更適合你?
|
||||||
|
|
||||||
|
CloudCLI UI 是 CloudCLI Cloud 的開源 UI 層。你可以在本機上自架它,也可以使用提供團隊功能與深入整合的 CloudCLI Cloud。
|
||||||
|
|
||||||
|
| | CloudCLI UI(自架) | CloudCLI Cloud |
|
||||||
|
|---|---|---|
|
||||||
|
| **適合對象** | 需要為本機代理工作階段提供完整 UI 的開發者 | 需要部署在雲端,隨時從任何地方存取代理的團隊與開發者 |
|
||||||
|
| **存取方式** | 透過 `[yourip]:port` 在瀏覽器中存取 | 瀏覽器、任意 IDE、REST API、n8n |
|
||||||
|
| **設定** | `npx @cloudcli-ai/cloudcli` | 無需設定 |
|
||||||
|
| **機器需保持開機嗎** | 是 | 否 |
|
||||||
|
| **行動裝置存取** | 網路內任意瀏覽器 | 任意裝置(原生應用程式即將推出) |
|
||||||
|
| **可用工作階段** | 自動發現 `~/.claude` 中的所有工作階段 | 雲端環境內的工作階段 |
|
||||||
|
| **支援的 Agents** | Claude Code、Cursor CLI、Codex、Gemini CLI | Claude Code、Cursor CLI、Codex、Gemini CLI |
|
||||||
|
| **檔案瀏覽與 Git** | 內建於 UI | 內建於 UI |
|
||||||
|
| **MCP 設定** | UI 管理,與本機 `~/.claude` 設定同步 | UI 管理 |
|
||||||
|
| **IDE 存取** | 本機 IDE | 任何連線到雲端環境的 IDE |
|
||||||
|
| **REST API** | 是 | 是 |
|
||||||
|
| **n8n 節點** | 否 | 是 |
|
||||||
|
| **團隊共享** | 否 | 是 |
|
||||||
|
| **平台費用** | 免費開源 | 起價 $7/月 |
|
||||||
|
|
||||||
|
> 兩種方式都使用你自己的 AI 訂閱(Claude、Cursor 等)— CloudCLI 提供環境,而非 AI。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全與工具設定
|
||||||
|
|
||||||
|
**🔒 重要提示**:所有 Claude Code 工具預設**停用**,可防止潛在的有害操作自動執行。
|
||||||
|
|
||||||
|
### 啟用工具
|
||||||
|
|
||||||
|
1. **開啟工具設定** — 點擊側邊欄齒輪圖示
|
||||||
|
2. **選擇性啟用** — 僅啟用所需工具
|
||||||
|
3. **套用設定** — 偏好設定儲存在本機
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
*工具設定介面 — 只啟用你需要的內容*
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
**建議做法**:先啟用基礎工具,再根據需要新增其他工具。隨時可以調整。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 外掛
|
||||||
|
|
||||||
|
CloudCLI 配備外掛系統,允許你新增帶有自訂前端 UI 和選用 Node.js 後端的分頁。在 Settings > Plugins 中直接從 Git 儲存庫安裝外掛,或自行開發。
|
||||||
|
|
||||||
|
### 可用外掛
|
||||||
|
|
||||||
|
| 外掛 | 描述 |
|
||||||
|
|---|---|
|
||||||
|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 |
|
||||||
|
|
||||||
|
### 自行建構
|
||||||
|
|
||||||
|
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — Fork 該儲存庫以建構自己的外掛。範例包括前端渲染、即時上下文更新和 RPC 通訊。
|
||||||
|
|
||||||
|
**[外掛文件 →](https://cloudcli.ai/docs/plugin-overview)** — 提供外掛 API、清單格式、安全模型等完整指南。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常見問題
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>與 Claude Code Remote Control 有何不同?</summary>
|
||||||
|
|
||||||
|
Claude Code Remote Control 讓你傳送訊息到本機終端機中已經執行的工作階段。該方式要求你的機器保持開機,終端機保持開啟,中斷網路後約 10 分鐘工作階段會逾時。
|
||||||
|
|
||||||
|
CloudCLI UI 與 CloudCLI Cloud 是對 Claude Code 的擴充,而非旁觀 — MCP 伺服器、權限、設定、工作階段與 Claude Code 完全一致。
|
||||||
|
|
||||||
|
- **涵蓋全部工作階段** — CloudCLI UI 會自動掃描 `~/.claude` 資料夾中的每個工作階段。Remote Control 只暴露目前活動的工作階段。
|
||||||
|
- **設定統一** — 在 CloudCLI UI 中修改的 MCP、工具權限等設定會立即寫入 Claude Code。
|
||||||
|
- **支援更多 Agents** — Claude Code、Cursor CLI、Codex、Gemini CLI。
|
||||||
|
- **完整 UI** — 除了聊天介面,還包括檔案瀏覽器、Git 整合、MCP 管理和 Shell 終端機。
|
||||||
|
- **CloudCLI Cloud 持續運作於雲端** — 關閉本機裝置也不會中斷代理執行,無需監控終端機。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>需要額外購買 AI 訂閱嗎?</summary>
|
||||||
|
|
||||||
|
需要。CloudCLI 只提供環境。你仍需自行取得 Claude、Cursor、Codex 或 Gemini 訂閱。CloudCLI Cloud 從 $7/月起提供託管環境。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>能在手機上使用 CloudCLI UI 嗎?</summary>
|
||||||
|
|
||||||
|
可以。自架時,在你的裝置上執行伺服器,然後在網路中的任意瀏覽器開啟 `[yourip]:port`。CloudCLI Cloud 可從任意裝置存取,內建原生應用程式也在開發中。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>UI 中的變更會影響本機 Claude Code 設定嗎?</summary>
|
||||||
|
|
||||||
|
會的。自架模式下,CloudCLI UI 讀取並寫入 Claude Code 使用的 `~/.claude` 設定。透過 UI 新增的 MCP 伺服器會立即在 Claude Code 中可見。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 社群與支援
|
||||||
|
|
||||||
|
- **[文件](https://cloudcli.ai/docs)** — 安裝、設定、功能與疑難排解指南
|
||||||
|
- **[Discord](https://discord.gg/buxwujPNRE)** — 取得協助並與社群交流
|
||||||
|
- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — 回報 Bug 與建議功能
|
||||||
|
- **[貢獻指南](CONTRIBUTING.md)** — 如何參與專案貢獻
|
||||||
|
|
||||||
|
## 授權條款
|
||||||
|
|
||||||
|
GNU 通用公共授權條款 v3.0 — 詳見 [LICENSE](LICENSE) 檔案。
|
||||||
|
|
||||||
|
該專案為開源軟體,在 GPL v3 授權條款下可自由使用、修改與散布。
|
||||||
|
|
||||||
|
## 致謝
|
||||||
|
|
||||||
|
### 使用技術
|
||||||
|
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic 官方 CLI
|
||||||
|
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** — Cursor 官方 CLI
|
||||||
|
- **[Codex](https://developers.openai.com/codex)** — OpenAI Codex
|
||||||
|
- **[Gemini-CLI](https://geminicli.com/)** — Google Gemini CLI
|
||||||
|
- **[React](https://react.dev/)** — 使用者介面函式庫
|
||||||
|
- **[Vite](https://vitejs.dev/)** — 快速建構工具與開發伺服器
|
||||||
|
- **[Tailwind CSS](https://tailwindcss.com/)** — 實用優先 CSS 框架
|
||||||
|
- **[CodeMirror](https://codemirror.net/)** — 進階程式碼編輯器
|
||||||
|
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(選用)* — AI 驅動的專案管理與任務規劃
|
||||||
|
|
||||||
|
### 贊助商
|
||||||
|
- [Siteboon - AI powered website builder](https://siteboon.ai)
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<strong>為 Claude Code、Cursor 和 Codex 社群精心打造。</strong>
|
||||||
|
</div>
|
||||||
218
docs/nginx-subpath-template.conf
Normal file
218
docs/nginx-subpath-template.conf
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# CloudCLI UI Nginx subpath deployment template.
|
||||||
|
#
|
||||||
|
# Purpose:
|
||||||
|
# Serve CloudCLI UI from a path prefix such as:
|
||||||
|
# http://localhost/ai/
|
||||||
|
# https://example.com/ai/
|
||||||
|
#
|
||||||
|
# CloudCLI itself still runs at the root of its own HTTP server, for example:
|
||||||
|
# http://127.0.0.1:3001/
|
||||||
|
#
|
||||||
|
# Nginx receives public requests under /ai, strips that prefix, and forwards the
|
||||||
|
# remaining path to CloudCLI. For example:
|
||||||
|
# /ai/ -> /
|
||||||
|
# /ai/session/abc -> /session/abc
|
||||||
|
# /ai/assets/index.js -> /assets/index.js
|
||||||
|
#
|
||||||
|
# Important Nginx limitation:
|
||||||
|
# Nginx does not allow variables in `location` matchers or `rewrite` regexes.
|
||||||
|
# The configurable variables below are still useful for proxy/filter values,
|
||||||
|
# but if you change /ai to a different subpath, also update every line marked:
|
||||||
|
# [SUBPATH LITERAL]
|
||||||
|
#
|
||||||
|
# To use a different subpath, replace these literal matchers:
|
||||||
|
# location = /ai
|
||||||
|
# location ^~ /ai/
|
||||||
|
# rewrite ^/ai(?<cloudcli_path>/.*)$ ...
|
||||||
|
#
|
||||||
|
# Recommended deployment shape:
|
||||||
|
# CloudCLI is the only app using /ai, while root paths /api, /ws, and /shell
|
||||||
|
# are also proxied because the current frontend still calls those endpoints
|
||||||
|
# with root-relative URLs.
|
||||||
|
|
||||||
|
worker_processes 1;
|
||||||
|
|
||||||
|
events {
|
||||||
|
# Maximum simultaneous connections handled by each worker process.
|
||||||
|
# The default is enough for local testing and small self-hosted deployments.
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
# WebSocket requests include an Upgrade header. Normal HTTP requests do not.
|
||||||
|
# This map gives us the right Connection header for both cases:
|
||||||
|
# Upgrade present -> "upgrade"
|
||||||
|
# Upgrade absent -> "close"
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
# For HTTPS deployments, replace this with `listen 443 ssl http2;` and
|
||||||
|
# add ssl_certificate / ssl_certificate_key lines.
|
||||||
|
listen 80 default_server;
|
||||||
|
|
||||||
|
# Use your real hostname in production, for example:
|
||||||
|
# server_name cloudcli.example.com;
|
||||||
|
server_name localhost 127.0.0.1;
|
||||||
|
|
||||||
|
# ---- User settings -------------------------------------------------
|
||||||
|
#
|
||||||
|
# Public path prefix where users access CloudCLI.
|
||||||
|
# Do not add a trailing slash.
|
||||||
|
#
|
||||||
|
# This variable can be used in redirects and response rewrites. It
|
||||||
|
# cannot be used in `location` matchers, so update the [SUBPATH LITERAL]
|
||||||
|
# lines too if you change it.
|
||||||
|
set $cloudcli_subpath /ai;
|
||||||
|
|
||||||
|
# Private upstream URL where the CloudCLI server is listening.
|
||||||
|
# For a default local server this is usually http://127.0.0.1:3001.
|
||||||
|
set $cloudcli_upstream http://127.0.0.1:3001;
|
||||||
|
|
||||||
|
# Allow larger file uploads through the code editor/project file APIs.
|
||||||
|
client_max_body_size 200m;
|
||||||
|
|
||||||
|
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
|
||||||
|
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.
|
||||||
|
location = /ai {
|
||||||
|
return 301 $cloudcli_subpath/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main prefixed CloudCLI UI route.
|
||||||
|
#
|
||||||
|
# [SUBPATH LITERAL] Change `/ai/` and the `^/ai` rewrite if you change
|
||||||
|
# $cloudcli_subpath.
|
||||||
|
location ^~ /ai/ {
|
||||||
|
# Strip the public subpath before proxying. CloudCLI expects to see
|
||||||
|
# root paths such as /, /session/:id, /assets/..., /manifest.json.
|
||||||
|
rewrite ^/ai(?<cloudcli_path>/.*)$ $cloudcli_path break;
|
||||||
|
|
||||||
|
# Forward the rewritten request to the private CloudCLI server.
|
||||||
|
proxy_pass $cloudcli_upstream;
|
||||||
|
|
||||||
|
# Use HTTP/1.1 so WebSocket upgrade requests can pass through if a
|
||||||
|
# browser reaches a socket endpoint under the subpath.
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# Preserve useful request metadata for logs and future app support.
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||||
|
|
||||||
|
# WebSocket upgrade headers. Harmless for normal HTTP requests.
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
|
# Long-running agent and terminal sessions can stay open for a long
|
||||||
|
# time, so avoid closing idle proxied connections too aggressively.
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
|
||||||
|
# Disable gzip from the upstream response so sub_filter can inspect
|
||||||
|
# and rewrite HTML/JSON/JS response bodies.
|
||||||
|
proxy_set_header Accept-Encoding "";
|
||||||
|
|
||||||
|
# Rewrite browser-visible root-relative URLs so the runtime can
|
||||||
|
# discover that the app is mounted under the subpath.
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# href="/manifest.json" -> href="/ai/manifest.json"
|
||||||
|
# src="/assets/app.js" -> src="/ai/assets/app.js"
|
||||||
|
#
|
||||||
|
# These rewrites are important for React Router basename detection.
|
||||||
|
sub_filter_once off;
|
||||||
|
sub_filter_types
|
||||||
|
application/json
|
||||||
|
application/manifest+json
|
||||||
|
application/javascript
|
||||||
|
text/javascript;
|
||||||
|
|
||||||
|
sub_filter 'href="/' 'href="$cloudcli_subpath/';
|
||||||
|
sub_filter 'src="/' 'src="$cloudcli_subpath/';
|
||||||
|
|
||||||
|
# The production HTML and JS register the service worker at /sw.js.
|
||||||
|
# Rewrite that registration so the worker is served from /ai/sw.js.
|
||||||
|
sub_filter "register('/sw.js')" "register('$cloudcli_subpath/sw.js')";
|
||||||
|
sub_filter 'register("/sw.js")' 'register("$cloudcli_subpath/sw.js")';
|
||||||
|
|
||||||
|
# The manifest and service worker contain root-relative paths too.
|
||||||
|
# Rewriting them keeps PWA metadata and cached manifest requests
|
||||||
|
# under the same public subpath.
|
||||||
|
sub_filter '"start_url": "/"' '"start_url": "$cloudcli_subpath/"';
|
||||||
|
sub_filter '"scope": "/"' '"scope": "$cloudcli_subpath/"';
|
||||||
|
sub_filter '"src": "/' '"src": "$cloudcli_subpath/';
|
||||||
|
sub_filter "'/manifest.json'" "'$cloudcli_subpath/manifest.json'";
|
||||||
|
sub_filter '"/manifest.json"' '"$cloudcli_subpath/manifest.json"';
|
||||||
|
}
|
||||||
|
|
||||||
|
# Root API proxy.
|
||||||
|
#
|
||||||
|
# The current CloudCLI frontend calls APIs with root-relative URLs such
|
||||||
|
# as /api/auth/login. Keep this location unless the frontend becomes
|
||||||
|
# fully prefix-aware for API requests.
|
||||||
|
location ^~ /api/ {
|
||||||
|
proxy_pass $cloudcli_upstream;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||||
|
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main app WebSocket proxy.
|
||||||
|
#
|
||||||
|
# The frontend opens /ws for realtime chat/session/task updates.
|
||||||
|
location /ws {
|
||||||
|
proxy_pass $cloudcli_upstream;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||||
|
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shell WebSocket proxy.
|
||||||
|
#
|
||||||
|
# The browser terminal uses /shell. It requires the same WebSocket
|
||||||
|
# upgrade handling as /ws.
|
||||||
|
location /shell {
|
||||||
|
proxy_pass $cloudcli_upstream;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||||
|
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional health endpoint proxy used by the frontend version checker.
|
||||||
|
location = /health {
|
||||||
|
proxy_pass $cloudcli_upstream;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,7 +157,11 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "backend-shared-utils", // shared backend runtime helpers that modules may import directly
|
type: "backend-shared-utils", // shared backend runtime helpers that modules may import directly
|
||||||
pattern: ["server/shared/utils.{js,ts}"], // classify the shared utils file so modules can depend on it explicitly
|
pattern: [
|
||||||
|
"server/shared/utils.{js,ts}",
|
||||||
|
"server/shared/frontmatter.ts",
|
||||||
|
"server/shared/claude-cli-path.ts",
|
||||||
|
], // classify shared utility files so modules can depend on them explicitly
|
||||||
mode: "file",
|
mode: "file",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -165,9 +169,8 @@ export default tseslint.config(
|
|||||||
pattern: [
|
pattern: [
|
||||||
"server/projects.js",
|
"server/projects.js",
|
||||||
"server/sessionManager.js",
|
"server/sessionManager.js",
|
||||||
"server/database/*.{js,ts}",
|
|
||||||
"server/utils/runtime-paths.js",
|
"server/utils/runtime-paths.js",
|
||||||
], // provider history loading still resolves session data through these legacy runtime/database files
|
], // provider history loading still resolves session data through these legacy runtime files
|
||||||
mode: "file",
|
mode: "file",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
983
package-lock.json
generated
983
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.30.0",
|
"version": "1.34.0",
|
||||||
"description": "A web-based UI for Claude Code CLI",
|
"description": "A web-based UI for Claude Code CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist-server/server/index.js",
|
"main": "dist-server/server/index.js",
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"server/",
|
"server/",
|
||||||
"shared/",
|
"shared/",
|
||||||
|
"public/api-docs.html",
|
||||||
|
"public/modelConstants.js",
|
||||||
"dist/",
|
"dist/",
|
||||||
"dist-server/",
|
"dist-server/",
|
||||||
"scripts/",
|
"scripts/",
|
||||||
@@ -65,7 +67,7 @@
|
|||||||
"author": "CloudCLI UI Contributors",
|
"author": "CloudCLI UI Contributors",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.165",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-html": "^6.4.9",
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
@@ -76,10 +78,11 @@
|
|||||||
"@codemirror/theme-one-dark": "^6.1.2",
|
"@codemirror/theme-one-dark": "^6.1.2",
|
||||||
"@iarna/toml": "^2.2.5",
|
"@iarna/toml": "^2.2.5",
|
||||||
"@octokit/rest": "^22.0.0",
|
"@octokit/rest": "^22.0.0",
|
||||||
"@openai/codex-sdk": "^0.101.0",
|
"@openai/codex-sdk": "^0.125.0",
|
||||||
"@replit/codemirror-minimap": "^0.5.2",
|
"@replit/codemirror-minimap": "^0.5.2",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@uiw/react-codemirror": "^4.23.13",
|
"@uiw/react-codemirror": "^4.23.13",
|
||||||
|
"@vscode/ripgrep": "^1.17.1",
|
||||||
"@xterm/addon-clipboard": "^0.1.0",
|
"@xterm/addon-clipboard": "^0.1.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
@@ -90,8 +93,10 @@
|
|||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cross-spawn": "^7.0.3",
|
"cross-spawn": "^7.0.3",
|
||||||
|
"dompurify": "^3.4.7",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
@@ -132,6 +137,7 @@
|
|||||||
"@types/node": "^22.19.7",
|
"@types/node": "^22.19.7",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"auto-changelog": "^2.5.0",
|
"auto-changelog": "^2.5.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
|
|||||||
@@ -822,7 +822,7 @@ data: {"type":"done"}</code></pre>
|
|||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
// Import model constants
|
// Import model constants
|
||||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '/shared/modelConstants.js';
|
import { PROVIDERS } from './modelConstants.js';
|
||||||
|
|
||||||
// Dynamic URL replacement
|
// Dynamic URL replacement
|
||||||
const apiUrl = window.location.origin;
|
const apiUrl = window.location.origin;
|
||||||
@@ -834,15 +834,14 @@ data: {"type":"done"}</code></pre>
|
|||||||
window.addEventListener('DOMContentLoaded', () => {
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
const modelCell = document.getElementById('model-options-cell');
|
const modelCell = document.getElementById('model-options-cell');
|
||||||
if (modelCell) {
|
if (modelCell) {
|
||||||
const claudeModels = CLAUDE_MODELS.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
const providerModels = PROVIDERS.map(provider => {
|
||||||
const cursorModels = CURSOR_MODELS.OPTIONS.slice(0, 8).map(m => `<code>${m.value}</code>`).join(', ');
|
const models = provider.models.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
||||||
const codexModels = CODEX_MODELS.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
return `<strong>${provider.name}:</strong> ${models} (default: <code>${provider.models.DEFAULT}</code>)`;
|
||||||
|
}).join('<br><br>');
|
||||||
|
|
||||||
modelCell.innerHTML = `
|
modelCell.innerHTML = `
|
||||||
Model identifier for the AI provider:<br><br>
|
Model identifier for the AI provider:<br><br>
|
||||||
<strong>Claude:</strong> ${claudeModels} (default: <code>${CLAUDE_MODELS.DEFAULT}</code>)<br><br>
|
${providerModels}
|
||||||
<strong>Cursor:</strong> ${cursorModels}, and more (default: <code>${CURSOR_MODELS.DEFAULT}</code>)<br><br>
|
|
||||||
<strong>Codex:</strong> ${codexModels} (default: <code>${CODEX_MODELS.DEFAULT}</code>)
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
854
public/modelConstants.js
Normal file
854
public/modelConstants.js
Normal file
@@ -0,0 +1,854 @@
|
|||||||
|
/**
|
||||||
|
* Documentation Model Definitions
|
||||||
|
* Used by README links and the public API docs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude (Anthropic) Models
|
||||||
|
*/
|
||||||
|
export const CLAUDE_MODELS = {
|
||||||
|
OPTIONS: [
|
||||||
|
{
|
||||||
|
value: "default",
|
||||||
|
label: "Default (recommended)",
|
||||||
|
description:
|
||||||
|
"Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "fable",
|
||||||
|
label: "Fable",
|
||||||
|
description:
|
||||||
|
"Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "sonnet",
|
||||||
|
label: "Sonnet",
|
||||||
|
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "sonnet[1m]",
|
||||||
|
label: "Sonnet (1M context)",
|
||||||
|
description: "Sonnet 4.6 for long sessions · $3/$15 per Mtok",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "opus[1m]",
|
||||||
|
label: "Opus 4.8 (1M context)",
|
||||||
|
description:
|
||||||
|
"Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "haiku",
|
||||||
|
label: "Haiku",
|
||||||
|
description: "Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
DEFAULT: "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cursor Models
|
||||||
|
*/
|
||||||
|
export const CURSOR_MODELS = {
|
||||||
|
OPTIONS: [
|
||||||
|
{ value: "auto", label: "auto", description: "Auto" },
|
||||||
|
{
|
||||||
|
value: "composer-2-fast",
|
||||||
|
label: "composer-2-fast",
|
||||||
|
description: "Composer 2 Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "composer-2",
|
||||||
|
label: "composer-2",
|
||||||
|
description: "Composer 2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.3-codex-low",
|
||||||
|
label: "gpt-5.3-codex-low",
|
||||||
|
description: "Codex 5.3 Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.3-codex-low-fast",
|
||||||
|
label: "gpt-5.3-codex-low-fast",
|
||||||
|
description: "Codex 5.3 Low Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.3-codex",
|
||||||
|
label: "gpt-5.3-codex",
|
||||||
|
description: "Codex 5.3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.3-codex-fast",
|
||||||
|
label: "gpt-5.3-codex-fast",
|
||||||
|
description: "Codex 5.3 Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.3-codex-high",
|
||||||
|
label: "gpt-5.3-codex-high",
|
||||||
|
description: "Codex 5.3 High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.3-codex-high-fast",
|
||||||
|
label: "gpt-5.3-codex-high-fast",
|
||||||
|
description: "Codex 5.3 High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.3-codex-xhigh",
|
||||||
|
label: "gpt-5.3-codex-xhigh",
|
||||||
|
description: "Codex 5.3 Extra High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.3-codex-xhigh-fast",
|
||||||
|
label: "gpt-5.3-codex-xhigh-fast",
|
||||||
|
description: "Codex 5.3 Extra High Fast",
|
||||||
|
},
|
||||||
|
{ value: "gpt-5.2", label: "gpt-5.2", description: "GPT-5.2" },
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-codex-low",
|
||||||
|
label: "gpt-5.2-codex-low",
|
||||||
|
description: "Codex 5.2 Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-codex-low-fast",
|
||||||
|
label: "gpt-5.2-codex-low-fast",
|
||||||
|
description: "Codex 5.2 Low Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-codex",
|
||||||
|
label: "gpt-5.2-codex",
|
||||||
|
description: "Codex 5.2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-codex-fast",
|
||||||
|
label: "gpt-5.2-codex-fast",
|
||||||
|
description: "Codex 5.2 Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-codex-high",
|
||||||
|
label: "gpt-5.2-codex-high",
|
||||||
|
description: "Codex 5.2 High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-codex-high-fast",
|
||||||
|
label: "gpt-5.2-codex-high-fast",
|
||||||
|
description: "Codex 5.2 High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-codex-xhigh",
|
||||||
|
label: "gpt-5.2-codex-xhigh",
|
||||||
|
description: "Codex 5.2 Extra High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-codex-xhigh-fast",
|
||||||
|
label: "gpt-5.2-codex-xhigh-fast",
|
||||||
|
description: "Codex 5.2 Extra High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-max-low",
|
||||||
|
label: "gpt-5.1-codex-max-low",
|
||||||
|
description: "Codex 5.1 Max Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-max-low-fast",
|
||||||
|
label: "gpt-5.1-codex-max-low-fast",
|
||||||
|
description: "Codex 5.1 Max Low Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-max-medium",
|
||||||
|
label: "gpt-5.1-codex-max-medium",
|
||||||
|
description: "Codex 5.1 Max",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-max-medium-fast",
|
||||||
|
label: "gpt-5.1-codex-max-medium-fast",
|
||||||
|
description: "Codex 5.1 Max Medium Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-max-high",
|
||||||
|
label: "gpt-5.1-codex-max-high",
|
||||||
|
description: "Codex 5.1 Max High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-max-high-fast",
|
||||||
|
label: "gpt-5.1-codex-max-high-fast",
|
||||||
|
description: "Codex 5.1 Max High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-max-xhigh",
|
||||||
|
label: "gpt-5.1-codex-max-xhigh",
|
||||||
|
description: "Codex 5.1 Max Extra High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-max-xhigh-fast",
|
||||||
|
label: "gpt-5.1-codex-max-xhigh-fast",
|
||||||
|
description: "Codex 5.1 Max Extra High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "composer-2.5",
|
||||||
|
label: "composer-2.5",
|
||||||
|
description: "Composer 2.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-high",
|
||||||
|
label: "gpt-5.5-high",
|
||||||
|
description: "GPT-5.5 1M High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-high-fast",
|
||||||
|
label: "gpt-5.5-high-fast",
|
||||||
|
description: "GPT-5.5 High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-high",
|
||||||
|
label: "claude-opus-4-7-thinking-high",
|
||||||
|
description: "Opus 4.7 1M High Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-high",
|
||||||
|
label: "gpt-5.4-high",
|
||||||
|
description: "GPT-5.4 1M High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-high-fast",
|
||||||
|
label: "gpt-5.4-high-fast",
|
||||||
|
description: "GPT-5.4 High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.6-opus-high-thinking",
|
||||||
|
label: "claude-4.6-opus-high-thinking",
|
||||||
|
description: "Opus 4.6 1M Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.6-opus-high-thinking-fast",
|
||||||
|
label: "claude-4.6-opus-high-thinking-fast",
|
||||||
|
description: "Opus 4.6 1M Thinking Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "composer-2.5-fast",
|
||||||
|
label: "composer-2.5-fast",
|
||||||
|
description: "Composer 2.5 Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-none",
|
||||||
|
label: "gpt-5.5-none",
|
||||||
|
description: "GPT-5.5 1M None",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-none-fast",
|
||||||
|
label: "gpt-5.5-none-fast",
|
||||||
|
description: "GPT-5.5 None Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-low",
|
||||||
|
label: "gpt-5.5-low",
|
||||||
|
description: "GPT-5.5 1M Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-low-fast",
|
||||||
|
label: "gpt-5.5-low-fast",
|
||||||
|
description: "GPT-5.5 Low Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-medium",
|
||||||
|
label: "gpt-5.5-medium",
|
||||||
|
description: "GPT-5.5 1M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-medium-fast",
|
||||||
|
label: "gpt-5.5-medium-fast",
|
||||||
|
description: "GPT-5.5 Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-extra-high",
|
||||||
|
label: "gpt-5.5-extra-high",
|
||||||
|
description: "GPT-5.5 1M Extra High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-extra-high-fast",
|
||||||
|
label: "gpt-5.5-extra-high-fast",
|
||||||
|
description: "GPT-5.5 Extra High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.6-sonnet-medium",
|
||||||
|
label: "claude-4.6-sonnet-medium",
|
||||||
|
description: "Sonnet 4.6 1M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.6-sonnet-medium-thinking",
|
||||||
|
label: "claude-4.6-sonnet-medium-thinking",
|
||||||
|
description: "Sonnet 4.6 1M Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-low",
|
||||||
|
label: "claude-opus-4-7-low",
|
||||||
|
description: "Opus 4.7 1M Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-low-fast",
|
||||||
|
label: "claude-opus-4-7-low-fast",
|
||||||
|
description: "Opus 4.7 1M Low Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-medium",
|
||||||
|
label: "claude-opus-4-7-medium",
|
||||||
|
description: "Opus 4.7 1M Medium",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-medium-fast",
|
||||||
|
label: "claude-opus-4-7-medium-fast",
|
||||||
|
description: "Opus 4.7 1M Medium Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-high",
|
||||||
|
label: "claude-opus-4-7-high",
|
||||||
|
description: "Opus 4.7 1M High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-high-fast",
|
||||||
|
label: "claude-opus-4-7-high-fast",
|
||||||
|
description: "Opus 4.7 1M High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-xhigh",
|
||||||
|
label: "claude-opus-4-7-xhigh",
|
||||||
|
description: "Opus 4.7 1M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-xhigh-fast",
|
||||||
|
label: "claude-opus-4-7-xhigh-fast",
|
||||||
|
description: "Opus 4.7 1M Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-max",
|
||||||
|
label: "claude-opus-4-7-max",
|
||||||
|
description: "Opus 4.7 1M Max",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-max-fast",
|
||||||
|
label: "claude-opus-4-7-max-fast",
|
||||||
|
description: "Opus 4.7 1M Max Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-low",
|
||||||
|
label: "claude-opus-4-7-thinking-low",
|
||||||
|
description: "Opus 4.7 1M Low Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-low-fast",
|
||||||
|
label: "claude-opus-4-7-thinking-low-fast",
|
||||||
|
description: "Opus 4.7 1M Low Thinking Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-medium",
|
||||||
|
label: "claude-opus-4-7-thinking-medium",
|
||||||
|
description: "Opus 4.7 1M Medium Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-medium-fast",
|
||||||
|
label: "claude-opus-4-7-thinking-medium-fast",
|
||||||
|
description: "Opus 4.7 1M Medium Thinking Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-high-fast",
|
||||||
|
label: "claude-opus-4-7-thinking-high-fast",
|
||||||
|
description: "Opus 4.7 1M High Thinking Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-xhigh",
|
||||||
|
label: "claude-opus-4-7-thinking-xhigh",
|
||||||
|
description: "Opus 4.7 1M Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-xhigh-fast",
|
||||||
|
label: "claude-opus-4-7-thinking-xhigh-fast",
|
||||||
|
description: "Opus 4.7 1M Thinking Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-max",
|
||||||
|
label: "claude-opus-4-7-thinking-max",
|
||||||
|
description: "Opus 4.7 1M Max Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-max-fast",
|
||||||
|
label: "claude-opus-4-7-thinking-max-fast",
|
||||||
|
description: "Opus 4.7 1M Max Thinking Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "grok-build-0.1",
|
||||||
|
label: "grok-build-0.1",
|
||||||
|
description: "Grok Build 0.1 1M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-low",
|
||||||
|
label: "gpt-5.4-low",
|
||||||
|
description: "GPT-5.4 1M Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-medium",
|
||||||
|
label: "gpt-5.4-medium",
|
||||||
|
description: "GPT-5.4 1M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-medium-fast",
|
||||||
|
label: "gpt-5.4-medium-fast",
|
||||||
|
description: "GPT-5.4 Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-xhigh",
|
||||||
|
label: "gpt-5.4-xhigh",
|
||||||
|
description: "GPT-5.4 1M Extra High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-xhigh-fast",
|
||||||
|
label: "gpt-5.4-xhigh-fast",
|
||||||
|
description: "GPT-5.4 Extra High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.6-opus-high",
|
||||||
|
label: "claude-4.6-opus-high",
|
||||||
|
description: "Opus 4.6 1M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.6-opus-max",
|
||||||
|
label: "claude-4.6-opus-max",
|
||||||
|
description: "Opus 4.6 1M Max",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.6-opus-max-thinking",
|
||||||
|
label: "claude-4.6-opus-max-thinking",
|
||||||
|
description: "Opus 4.6 1M Max Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.6-opus-max-thinking-fast",
|
||||||
|
label: "claude-4.6-opus-max-thinking-fast",
|
||||||
|
description: "Opus 4.6 1M Max Thinking Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.5-opus-high",
|
||||||
|
label: "claude-4.5-opus-high",
|
||||||
|
description: "Opus 4.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.5-opus-high-thinking",
|
||||||
|
label: "claude-4.5-opus-high-thinking",
|
||||||
|
description: "Opus 4.5 Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-low",
|
||||||
|
label: "gpt-5.2-low",
|
||||||
|
description: "GPT-5.2 Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-low-fast",
|
||||||
|
label: "gpt-5.2-low-fast",
|
||||||
|
description: "GPT-5.2 Low Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-fast",
|
||||||
|
label: "gpt-5.2-fast",
|
||||||
|
description: "GPT-5.2 Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-high",
|
||||||
|
label: "gpt-5.2-high",
|
||||||
|
description: "GPT-5.2 High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-high-fast",
|
||||||
|
label: "gpt-5.2-high-fast",
|
||||||
|
description: "GPT-5.2 High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-xhigh",
|
||||||
|
label: "gpt-5.2-xhigh",
|
||||||
|
description: "GPT-5.2 Extra High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-xhigh-fast",
|
||||||
|
label: "gpt-5.2-xhigh-fast",
|
||||||
|
description: "GPT-5.2 Extra High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gemini-3.1-pro",
|
||||||
|
label: "gemini-3.1-pro",
|
||||||
|
description: "Gemini 3.1 Pro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-mini-none",
|
||||||
|
label: "gpt-5.4-mini-none",
|
||||||
|
description: "GPT-5.4 Mini None",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-mini-low",
|
||||||
|
label: "gpt-5.4-mini-low",
|
||||||
|
description: "GPT-5.4 Mini Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-mini-medium",
|
||||||
|
label: "gpt-5.4-mini-medium",
|
||||||
|
description: "GPT-5.4 Mini",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-mini-high",
|
||||||
|
label: "gpt-5.4-mini-high",
|
||||||
|
description: "GPT-5.4 Mini High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-mini-xhigh",
|
||||||
|
label: "gpt-5.4-mini-xhigh",
|
||||||
|
description: "GPT-5.4 Mini Extra High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-nano-none",
|
||||||
|
label: "gpt-5.4-nano-none",
|
||||||
|
description: "GPT-5.4 Nano None",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-nano-low",
|
||||||
|
label: "gpt-5.4-nano-low",
|
||||||
|
description: "GPT-5.4 Nano Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-nano-medium",
|
||||||
|
label: "gpt-5.4-nano-medium",
|
||||||
|
description: "GPT-5.4 Nano",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-nano-high",
|
||||||
|
label: "gpt-5.4-nano-high",
|
||||||
|
description: "GPT-5.4 Nano High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-nano-xhigh",
|
||||||
|
label: "gpt-5.4-nano-xhigh",
|
||||||
|
description: "GPT-5.4 Nano Extra High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "grok-4.3",
|
||||||
|
label: "grok-4.3",
|
||||||
|
description: "Grok 4.3 1M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.5-sonnet",
|
||||||
|
label: "claude-4.5-sonnet",
|
||||||
|
description: "Sonnet 4.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.5-sonnet-thinking",
|
||||||
|
label: "claude-4.5-sonnet-thinking",
|
||||||
|
description: "Sonnet 4.5 Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-low",
|
||||||
|
label: "gpt-5.1-low",
|
||||||
|
description: "GPT-5.1 Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1",
|
||||||
|
label: "gpt-5.1",
|
||||||
|
description: "GPT-5.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-high",
|
||||||
|
label: "gpt-5.1-high",
|
||||||
|
description: "GPT-5.1 High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gemini-3-flash",
|
||||||
|
label: "gemini-3-flash",
|
||||||
|
description: "Gemini 3 Flash",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gemini-3.5-flash",
|
||||||
|
label: "gemini-3.5-flash",
|
||||||
|
description: "Gemini 3.5 Flash",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-mini-low",
|
||||||
|
label: "gpt-5.1-codex-mini-low",
|
||||||
|
description: "Codex 5.1 Mini Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-mini",
|
||||||
|
label: "gpt-5.1-codex-mini",
|
||||||
|
description: "Codex 5.1 Mini",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-mini-high",
|
||||||
|
label: "gpt-5.1-codex-mini-high",
|
||||||
|
description: "Codex 5.1 Mini High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4-sonnet",
|
||||||
|
label: "claude-4-sonnet",
|
||||||
|
description: "Sonnet 4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4-sonnet-thinking",
|
||||||
|
label: "claude-4-sonnet-thinking",
|
||||||
|
description: "Sonnet 4 Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5-mini",
|
||||||
|
label: "gpt-5-mini",
|
||||||
|
description: "GPT-5 Mini",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "kimi-k2.5",
|
||||||
|
label: "kimi-k2.5",
|
||||||
|
description: "Kimi K2.5",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
DEFAULT: "composer-2.5-fast",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codex (OpenAI) Models
|
||||||
|
*/
|
||||||
|
export const CODEX_MODELS = {
|
||||||
|
OPTIONS: [
|
||||||
|
{ value: "gpt-5.5", label: "gpt-5.5" },
|
||||||
|
{ value: "gpt-5.4", label: "gpt-5.4" },
|
||||||
|
{ value: "gpt-5.4-mini", label: "gpt-5.4-mini" },
|
||||||
|
{ value: "gpt-5.3-codex", label: "gpt-5.3-codex" },
|
||||||
|
{ value: "gpt-5.2", label: "gpt-5.2" },
|
||||||
|
],
|
||||||
|
|
||||||
|
DEFAULT: "gpt-5.4",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemini Models
|
||||||
|
*/
|
||||||
|
export const GEMINI_MODELS = {
|
||||||
|
OPTIONS: [
|
||||||
|
{ value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
|
||||||
|
{ value: "gemini-3-pro-preview", label: "Gemini 3 Pro Preview" },
|
||||||
|
{ value: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" },
|
||||||
|
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
||||||
|
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
||||||
|
{ value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
|
||||||
|
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
|
||||||
|
{ value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" },
|
||||||
|
{
|
||||||
|
value: "gemini-2.0-flash-thinking-exp",
|
||||||
|
label: "Gemini 2.0 Flash Thinking",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
DEFAULT: "gemini-3.1-pro-preview",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenCode Models
|
||||||
|
*
|
||||||
|
* OpenCode model ids include the upstream provider prefix.
|
||||||
|
*/
|
||||||
|
export const OPENCODE_MODELS = {
|
||||||
|
OPTIONS: [
|
||||||
|
{
|
||||||
|
value: "opencode/big-pickle",
|
||||||
|
label: "Big Pickle",
|
||||||
|
description: "opencode - opencode/big-pickle",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "opencode/deepseek-v4-flash-free",
|
||||||
|
label: "Deepseek V4 Flash Free",
|
||||||
|
description: "opencode - opencode/deepseek-v4-flash-free",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "opencode/nemotron-3-super-free",
|
||||||
|
label: "Nemotron 3 Super Free",
|
||||||
|
description: "opencode - opencode/nemotron-3-super-free",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-3-5-haiku-20241022",
|
||||||
|
label: "Claude 3.5 Haiku (2024-10-22)",
|
||||||
|
description: "anthropic - anthropic/claude-3-5-haiku-20241022",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-3-5-haiku-latest",
|
||||||
|
label: "Claude 3.5 Haiku Latest",
|
||||||
|
description: "anthropic - anthropic/claude-3-5-haiku-latest",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-3-5-sonnet-20240620",
|
||||||
|
label: "Claude 3.5 Sonnet (2024-06-20)",
|
||||||
|
description: "anthropic - anthropic/claude-3-5-sonnet-20240620",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-3-5-sonnet-20241022",
|
||||||
|
label: "Claude 3.5 Sonnet (2024-10-22)",
|
||||||
|
description: "anthropic - anthropic/claude-3-5-sonnet-20241022",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-3-7-sonnet-20250219",
|
||||||
|
label: "Claude 3.7 Sonnet (2025-02-19)",
|
||||||
|
description: "anthropic - anthropic/claude-3-7-sonnet-20250219",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-3-haiku-20240307",
|
||||||
|
label: "Claude 3 Haiku (2024-03-07)",
|
||||||
|
description: "anthropic - anthropic/claude-3-haiku-20240307",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-3-opus-20240229",
|
||||||
|
label: "Claude 3 Opus (2024-02-29)",
|
||||||
|
description: "anthropic - anthropic/claude-3-opus-20240229",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-3-sonnet-20240229",
|
||||||
|
label: "Claude 3 Sonnet (2024-02-29)",
|
||||||
|
description: "anthropic - anthropic/claude-3-sonnet-20240229",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-haiku-4-5",
|
||||||
|
label: "Claude Haiku 4.5",
|
||||||
|
description: "anthropic - anthropic/claude-haiku-4-5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-haiku-4-5-20251001",
|
||||||
|
label: "Claude Haiku 4.5 (2025-10-01)",
|
||||||
|
description: "anthropic - anthropic/claude-haiku-4-5-20251001",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-opus-4-0",
|
||||||
|
label: "Claude Opus 4.0",
|
||||||
|
description: "anthropic - anthropic/claude-opus-4-0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-opus-4-1",
|
||||||
|
label: "Claude Opus 4.1",
|
||||||
|
description: "anthropic - anthropic/claude-opus-4-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-opus-4-1-20250805",
|
||||||
|
label: "Claude Opus 4.1 (2025-08-05)",
|
||||||
|
description: "anthropic - anthropic/claude-opus-4-1-20250805",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-opus-4-20250514",
|
||||||
|
label: "Claude Opus 4 (2025-05-14)",
|
||||||
|
description: "anthropic - anthropic/claude-opus-4-20250514",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-opus-4-5",
|
||||||
|
label: "Claude Opus 4.5",
|
||||||
|
description: "anthropic - anthropic/claude-opus-4-5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-opus-4-5-20251101",
|
||||||
|
label: "Claude Opus 4.5 (2025-11-01)",
|
||||||
|
description: "anthropic - anthropic/claude-opus-4-5-20251101",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-opus-4-6",
|
||||||
|
label: "Claude Opus 4.6",
|
||||||
|
description: "anthropic - anthropic/claude-opus-4-6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-opus-4-6-fast",
|
||||||
|
label: "Claude Opus 4.6 Fast",
|
||||||
|
description: "anthropic - anthropic/claude-opus-4-6-fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-opus-4-7",
|
||||||
|
label: "Claude Opus 4.7",
|
||||||
|
description: "anthropic - anthropic/claude-opus-4-7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-opus-4-7-fast",
|
||||||
|
label: "Claude Opus 4.7 Fast",
|
||||||
|
description: "anthropic - anthropic/claude-opus-4-7-fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-sonnet-4-0",
|
||||||
|
label: "Claude Sonnet 4.0",
|
||||||
|
description: "anthropic - anthropic/claude-sonnet-4-0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-sonnet-4-20250514",
|
||||||
|
label: "Claude Sonnet 4 (2025-05-14)",
|
||||||
|
description: "anthropic - anthropic/claude-sonnet-4-20250514",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-sonnet-4-5",
|
||||||
|
label: "Claude Sonnet 4.5",
|
||||||
|
description: "anthropic - anthropic/claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-sonnet-4-5-20250929",
|
||||||
|
label: "Claude Sonnet 4.5 (2025-09-29)",
|
||||||
|
description: "anthropic - anthropic/claude-sonnet-4-5-20250929",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "anthropic/claude-sonnet-4-6",
|
||||||
|
label: "Claude Sonnet 4.6",
|
||||||
|
description: "anthropic - anthropic/claude-sonnet-4-6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/gpt-5.2",
|
||||||
|
label: "GPT-5.2",
|
||||||
|
description: "openai - openai/gpt-5.2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/gpt-5.3-codex",
|
||||||
|
label: "GPT-5.3 Codex",
|
||||||
|
description: "openai - openai/gpt-5.3-codex",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/gpt-5.3-codex-spark",
|
||||||
|
label: "GPT-5.3 Codex Spark",
|
||||||
|
description: "openai - openai/gpt-5.3-codex-spark",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/gpt-5.4",
|
||||||
|
label: "GPT-5.4",
|
||||||
|
description: "openai - openai/gpt-5.4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/gpt-5.4-fast",
|
||||||
|
label: "GPT-5.4 Fast",
|
||||||
|
description: "openai - openai/gpt-5.4-fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/gpt-5.4-mini",
|
||||||
|
label: "GPT-5.4 Mini",
|
||||||
|
description: "openai - openai/gpt-5.4-mini",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/gpt-5.4-mini-fast",
|
||||||
|
label: "GPT-5.4 Mini Fast",
|
||||||
|
description: "openai - openai/gpt-5.4-mini-fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/gpt-5.5",
|
||||||
|
label: "GPT-5.5",
|
||||||
|
description: "openai - openai/gpt-5.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/gpt-5.5-fast",
|
||||||
|
label: "GPT-5.5 Fast",
|
||||||
|
description: "openai - openai/gpt-5.5-fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/gpt-5.5-pro",
|
||||||
|
label: "GPT-5.5 Pro",
|
||||||
|
description: "openai - openai/gpt-5.5-pro",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
DEFAULT: "anthropic/claude-sonnet-4-5",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordered provider registry. Display order in documentation.
|
||||||
|
*/
|
||||||
|
export const PROVIDERS = [
|
||||||
|
{ id: "claude", name: "Anthropic", models: CLAUDE_MODELS },
|
||||||
|
{ id: "codex", name: "OpenAI", models: CODEX_MODELS },
|
||||||
|
{ id: "gemini", name: "Google", models: GEMINI_MODELS },
|
||||||
|
{ id: "cursor", name: "Cursor", models: CURSOR_MODELS },
|
||||||
|
{ id: "opencode", name: "OpenCode", models: OPENCODE_MODELS },
|
||||||
|
];
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||||
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
||||||
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](https://github.com/siteboon/claudecodeui/blob/main/shared/modelConstants.js) for the full list of supported models)
|
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](https://github.com/siteboon/claudecodeui/blob/main/public/modelConstants.js) for the full list of supported models)
|
||||||
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import crypto from 'crypto';
|
|||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js';
|
||||||
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||||
|
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
|
||||||
import {
|
import {
|
||||||
createNotificationEvent,
|
createNotificationEvent,
|
||||||
notifyRunFailed,
|
notifyRunFailed,
|
||||||
@@ -153,11 +155,9 @@ function mapCliOptionsToSDK(options = {}) {
|
|||||||
// Since SDK 0.2.113, options.env replaces process.env instead of overlaying it.
|
// Since SDK 0.2.113, options.env replaces process.env instead of overlaying it.
|
||||||
sdkOptions.env = { ...process.env };
|
sdkOptions.env = { ...process.env };
|
||||||
|
|
||||||
// Use CLAUDE_CLI_PATH if explicitly set, otherwise fall back to 'claude' on PATH.
|
// Resolve the executable eagerly on Windows because the SDK uses raw child_process.spawn,
|
||||||
// The SDK 0.2.113+ looks for a bundled native binary optional dep by default;
|
// which does not reliably follow npm's shell wrappers like cross-spawn does.
|
||||||
// this fallback ensures users who installed via the official installer still work
|
sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
|
||||||
// even when npm prune --production has removed those optional deps.
|
|
||||||
sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH || 'claude';
|
|
||||||
|
|
||||||
// Map working directory
|
// Map working directory
|
||||||
if (cwd) {
|
if (cwd) {
|
||||||
@@ -204,8 +204,8 @@ function mapCliOptionsToSDK(options = {}) {
|
|||||||
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
||||||
|
|
||||||
// Map model (default to sonnet)
|
// Map model (default to sonnet)
|
||||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable
|
||||||
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
|
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
|
||||||
// Model logged at query start below
|
// Model logged at query start below
|
||||||
|
|
||||||
// Map system prompt configuration
|
// Map system prompt configuration
|
||||||
@@ -285,43 +285,75 @@ function transformMessage(sdkMessage) {
|
|||||||
return sdkMessage;
|
return sdkMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readNumber(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts token usage from SDK result messages
|
* Extracts token usage from SDK messages.
|
||||||
* @param {Object} resultMessage - SDK result message
|
* Prefers per-step `message.usage` (Claude message payload), then falls back
|
||||||
|
* to result-level usage/modelUsage for compatibility across SDK versions.
|
||||||
|
* @param {Object} sdkMessage - SDK stream message
|
||||||
* @returns {Object|null} Token budget object or null
|
* @returns {Object|null} Token budget object or null
|
||||||
*/
|
*/
|
||||||
function extractTokenBudget(resultMessage) {
|
function extractTokenBudget(sdkMessage) {
|
||||||
if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
|
if (!sdkMessage || typeof sdkMessage !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the first model's usage data
|
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
|
||||||
const modelKey = Object.keys(resultMessage.modelUsage)[0];
|
if (messageUsage && typeof messageUsage === 'object') {
|
||||||
const modelData = resultMessage.modelUsage[modelKey];
|
const directInputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
|
||||||
|
const cacheCreationTokens = readNumber(messageUsage.cache_creation_input_tokens ?? messageUsage.cacheCreationInputTokens ?? messageUsage.cacheCreationTokens);
|
||||||
|
const cacheReadTokens = readNumber(messageUsage.cache_read_input_tokens ?? messageUsage.cacheReadInputTokens ?? messageUsage.cacheReadTokens);
|
||||||
|
const cacheTokens = cacheCreationTokens + cacheReadTokens;
|
||||||
|
const inputTokens = directInputTokens + cacheTokens;
|
||||||
|
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
|
||||||
|
const totalUsed = inputTokens + outputTokens;
|
||||||
|
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
|
||||||
|
|
||||||
if (!modelData) {
|
return {
|
||||||
|
used: totalUsed,
|
||||||
|
total: contextWindow,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
cacheReadTokens,
|
||||||
|
cacheCreationTokens,
|
||||||
|
cacheTokens,
|
||||||
|
breakdown: {
|
||||||
|
input: inputTokens,
|
||||||
|
output: outputTokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sdkMessage.modelUsage || typeof sdkMessage.modelUsage !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cumulative tokens if available (tracks total for the session)
|
// Fallback for older SDK messages with only modelUsage
|
||||||
// Otherwise fall back to per-request tokens
|
const modelKey = Object.keys(sdkMessage.modelUsage)[0];
|
||||||
const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
|
const modelData = sdkMessage.modelUsage[modelKey];
|
||||||
const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
|
|
||||||
const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
|
|
||||||
const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
|
|
||||||
|
|
||||||
// Total used = input + output + cache tokens
|
if (!modelData || typeof modelData !== 'object') {
|
||||||
const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Use configured context window budget from environment (default 160000)
|
const inputTokens = readNumber(modelData.cumulativeInputTokens ?? modelData.inputTokens);
|
||||||
// This is the user's budget limit, not the model's context window
|
const outputTokens = readNumber(modelData.cumulativeOutputTokens ?? modelData.outputTokens);
|
||||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
|
const totalUsed = inputTokens + outputTokens;
|
||||||
|
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
|
||||||
// Token calc logged via token-budget WS event
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
used: totalUsed,
|
used: totalUsed,
|
||||||
total: contextWindow
|
total: contextWindow,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
breakdown: {
|
||||||
|
input: inputTokens,
|
||||||
|
output: outputTokens,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,8 +524,17 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const resolvedModel = await providerModelsService.resolveResumeModel(
|
||||||
|
'claude',
|
||||||
|
sessionId,
|
||||||
|
options.model,
|
||||||
|
);
|
||||||
|
|
||||||
// Map CLI options to SDK format
|
// Map CLI options to SDK format
|
||||||
const sdkOptions = mapCliOptionsToSDK(options);
|
const sdkOptions = mapCliOptionsToSDK({
|
||||||
|
...options,
|
||||||
|
model: resolvedModel || options.model,
|
||||||
|
});
|
||||||
|
|
||||||
// Load MCP configuration
|
// Load MCP configuration
|
||||||
const mcpServers = await loadMcpConfig(options.cwd);
|
const mcpServers = await loadMcpConfig(options.cwd);
|
||||||
@@ -527,6 +568,12 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Caveat: in 'auto' and 'bypassPermissions' modes the SDK resolves approval
|
||||||
|
// at the permission-mode step and skips this callback, so interactive tools
|
||||||
|
// (AskUserQuestion, ExitPlanMode) won't reach the UI — the classifier/bypass
|
||||||
|
// auto-approves them and the model acts on a generated answer. Move these
|
||||||
|
// tools to a PreToolUse hook (runs before the mode check) if we need them
|
||||||
|
// to work in those modes.
|
||||||
sdkOptions.canUseTool = async (toolName, input, context) => {
|
sdkOptions.canUseTool = async (toolName, input, context) => {
|
||||||
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
|
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
|
||||||
|
|
||||||
@@ -669,16 +716,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
ws.send(msg);
|
ws.send(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract and send token budget updates from result messages
|
// Extract and send token budget updates from assistant/result usage payloads
|
||||||
if (message.type === 'result') {
|
const tokenBudgetData = extractTokenBudget(message);
|
||||||
const models = Object.keys(message.modelUsage || {});
|
if (tokenBudgetData) {
|
||||||
if (models.length > 0) {
|
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||||
// Model info available in result message
|
|
||||||
}
|
|
||||||
const tokenBudgetData = extractTokenBudget(message);
|
|
||||||
if (tokenBudgetData) {
|
|
||||||
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ async function sandboxCommand(args) {
|
|||||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
|
|
||||||
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
||||||
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
|
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
|
||||||
|
|
||||||
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
||||||
try {
|
try {
|
||||||
@@ -554,7 +554,7 @@ async function sandboxCommand(args) {
|
|||||||
|
|
||||||
// Step 3: Start CloudCLI inside the sandbox
|
// Step 3: Start CloudCLI inside the sandbox
|
||||||
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
||||||
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
|
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
|
||||||
|
|
||||||
// Step 4: Forward port
|
// Step 4: Forward port
|
||||||
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import crossSpawn from 'cross-spawn';
|
|||||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||||
import { createNormalizedMessage } from './shared/utils.js';
|
import { createNormalizedMessage } from './shared/utils.js';
|
||||||
|
|
||||||
// Use cross-spawn on Windows for better command execution
|
// Use cross-spawn on Windows for better command execution
|
||||||
@@ -28,6 +29,7 @@ function isWorkspaceTrustPrompt(text = '') {
|
|||||||
async function spawnCursor(command, options = {}, ws) {
|
async function spawnCursor(command, options = {}, ws) {
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, sessionSummary } = options;
|
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, sessionSummary } = options;
|
||||||
|
const resolvedModel = await providerModelsService.resolveResumeModel('cursor', sessionId, model);
|
||||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||||
let hasRetriedWithTrust = false;
|
let hasRetriedWithTrust = false;
|
||||||
@@ -52,9 +54,10 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
// Provide a prompt (works for both new and resumed sessions)
|
// Provide a prompt (works for both new and resumed sessions)
|
||||||
baseArgs.push('-p', command);
|
baseArgs.push('-p', command);
|
||||||
|
|
||||||
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
|
// Model overrides are applied to both new and resumed sessions so a
|
||||||
if (!sessionId && model) {
|
// session-scoped change request can take effect on the next turn.
|
||||||
baseArgs.push('--model', model);
|
if (resolvedModel) {
|
||||||
|
baseArgs.push('--model', resolvedModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request streaming JSON when we are providing a prompt
|
// Request streaming JSON when we are providing a prompt
|
||||||
@@ -150,7 +153,6 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = JSON.parse(line);
|
const response = JSON.parse(line);
|
||||||
console.log('Parsed JSON response:', response);
|
|
||||||
|
|
||||||
// Handle different message types
|
// Handle different message types
|
||||||
switch (response.type) {
|
switch (response.type) {
|
||||||
@@ -159,7 +161,6 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
// Capture session ID
|
// Capture session ID
|
||||||
if (response.session_id && !capturedSessionId) {
|
if (response.session_id && !capturedSessionId) {
|
||||||
capturedSessionId = response.session_id;
|
capturedSessionId = response.session_id;
|
||||||
console.log('Captured session ID:', capturedSessionId);
|
|
||||||
|
|
||||||
// Update process key with captured session ID
|
// Update process key with captured session ID
|
||||||
if (processKey !== capturedSessionId) {
|
if (processKey !== capturedSessionId) {
|
||||||
@@ -197,7 +198,6 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
|
|
||||||
case 'result': {
|
case 'result': {
|
||||||
// Session complete — send stream end + lifecycle complete with result payload
|
// Session complete — send stream end + lifecycle complete with result payload
|
||||||
console.log('Cursor session result:', response);
|
|
||||||
const resultText = typeof response.result === 'string' ? response.result : '';
|
const resultText = typeof response.result === 'string' ? response.result : '';
|
||||||
ws.send(createNormalizedMessage({
|
ws.send(createNormalizedMessage({
|
||||||
kind: 'complete',
|
kind: 'complete',
|
||||||
@@ -213,8 +213,6 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
// Unknown message types — ignore.
|
// Unknown message types — ignore.
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.log('Non-JSON response:', line);
|
|
||||||
|
|
||||||
if (shouldSuppressForTrustRetry(line)) {
|
if (shouldSuppressForTrustRetry(line)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -228,7 +226,6 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
// Handle stdout (streaming JSON responses)
|
// Handle stdout (streaming JSON responses)
|
||||||
cursorProcess.stdout.on('data', (data) => {
|
cursorProcess.stdout.on('data', (data) => {
|
||||||
const rawOutput = data.toString();
|
const rawOutput = data.toString();
|
||||||
console.log('Cursor CLI stdout:', rawOutput);
|
|
||||||
|
|
||||||
// Stream chunks can split JSON objects across packets; keep trailing partial line.
|
// Stream chunks can split JSON objects across packets; keep trailing partial line.
|
||||||
stdoutLineBuffer += rawOutput;
|
stdoutLineBuffer += rawOutput;
|
||||||
@@ -254,8 +251,6 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
|
|
||||||
// Handle process completion
|
// Handle process completion
|
||||||
cursorProcess.on('close', async (code) => {
|
cursorProcess.on('close', async (code) => {
|
||||||
console.log(`Cursor CLI process exited with code ${code}`);
|
|
||||||
|
|
||||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||||
activeCursorProcesses.delete(finalSessionId);
|
activeCursorProcesses.delete(finalSessionId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,593 +0,0 @@
|
|||||||
import Database from 'better-sqlite3';
|
|
||||||
import path from 'path';
|
|
||||||
import fs from 'fs';
|
|
||||||
import crypto from 'crypto';
|
|
||||||
import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js';
|
|
||||||
import {
|
|
||||||
APP_CONFIG_TABLE_SQL,
|
|
||||||
USER_NOTIFICATION_PREFERENCES_TABLE_SQL,
|
|
||||||
VAPID_KEYS_TABLE_SQL,
|
|
||||||
PUSH_SUBSCRIPTIONS_TABLE_SQL,
|
|
||||||
SESSION_NAMES_TABLE_SQL,
|
|
||||||
SESSION_NAMES_LOOKUP_INDEX_SQL,
|
|
||||||
DATABASE_SCHEMA_SQL
|
|
||||||
} from './schema.js';
|
|
||||||
|
|
||||||
const __dirname = getModuleDir(import.meta.url);
|
|
||||||
// The compiled backend lives under dist-server/server/database, but the install root we log
|
|
||||||
// should still point at the project/app root. Resolving it here avoids build-layout drift.
|
|
||||||
const APP_ROOT = findAppRoot(__dirname);
|
|
||||||
|
|
||||||
// ANSI color codes for terminal output
|
|
||||||
const colors = {
|
|
||||||
reset: '\x1b[0m',
|
|
||||||
bright: '\x1b[1m',
|
|
||||||
cyan: '\x1b[36m',
|
|
||||||
dim: '\x1b[2m',
|
|
||||||
};
|
|
||||||
|
|
||||||
const c = {
|
|
||||||
info: (text) => `${colors.cyan}${text}${colors.reset}`,
|
|
||||||
bright: (text) => `${colors.bright}${text}${colors.reset}`,
|
|
||||||
dim: (text) => `${colors.dim}${text}${colors.reset}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use DATABASE_PATH environment variable if set, otherwise use default location
|
|
||||||
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db');
|
|
||||||
|
|
||||||
// Ensure database directory exists if custom path is provided
|
|
||||||
if (process.env.DATABASE_PATH) {
|
|
||||||
const dbDir = path.dirname(DB_PATH);
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(dbDir)) {
|
|
||||||
fs.mkdirSync(dbDir, { recursive: true });
|
|
||||||
console.log(`Created database directory: ${dbDir}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to create database directory ${dbDir}:`, error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// As part of 1.19.2 we are introducing a new location for auth.db. The below handles exisitng moving legacy database from install directory to new location
|
|
||||||
const LEGACY_DB_PATH = path.join(__dirname, 'auth.db');
|
|
||||||
if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGACY_DB_PATH)) {
|
|
||||||
try {
|
|
||||||
fs.copyFileSync(LEGACY_DB_PATH, DB_PATH);
|
|
||||||
console.log(`[MIGRATION] Copied database from ${LEGACY_DB_PATH} to ${DB_PATH}`);
|
|
||||||
for (const suffix of ['-wal', '-shm']) {
|
|
||||||
if (fs.existsSync(LEGACY_DB_PATH + suffix)) {
|
|
||||||
fs.copyFileSync(LEGACY_DB_PATH + suffix, DB_PATH + suffix);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[MIGRATION] Could not copy legacy database: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create database connection
|
|
||||||
const db = new Database(DB_PATH);
|
|
||||||
|
|
||||||
// app_config must exist before any other module imports (auth.js reads the JWT secret at load time).
|
|
||||||
// runMigrations() also creates this table, but it runs too late for existing installations
|
|
||||||
// where auth.js is imported before initializeDatabase() is called.
|
|
||||||
db.exec(APP_CONFIG_TABLE_SQL);
|
|
||||||
|
|
||||||
// Show app installation path prominently
|
|
||||||
const appInstallPath = APP_ROOT;
|
|
||||||
console.log('');
|
|
||||||
console.log(c.dim('═'.repeat(60)));
|
|
||||||
console.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`);
|
|
||||||
console.log(`${c.info('[INFO]')} Database: ${c.dim(path.relative(appInstallPath, DB_PATH))}`);
|
|
||||||
if (process.env.DATABASE_PATH) {
|
|
||||||
console.log(` ${c.dim('(Using custom DATABASE_PATH from environment)')}`);
|
|
||||||
}
|
|
||||||
console.log(c.dim('═'.repeat(60)));
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
const runMigrations = () => {
|
|
||||||
try {
|
|
||||||
const tableInfo = db.prepare("PRAGMA table_info(users)").all();
|
|
||||||
const columnNames = tableInfo.map(col => col.name);
|
|
||||||
|
|
||||||
if (!columnNames.includes('git_name')) {
|
|
||||||
console.log('Running migration: Adding git_name column');
|
|
||||||
db.exec('ALTER TABLE users ADD COLUMN git_name TEXT');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!columnNames.includes('git_email')) {
|
|
||||||
console.log('Running migration: Adding git_email column');
|
|
||||||
db.exec('ALTER TABLE users ADD COLUMN git_email TEXT');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!columnNames.includes('has_completed_onboarding')) {
|
|
||||||
console.log('Running migration: Adding has_completed_onboarding column');
|
|
||||||
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
db.exec(USER_NOTIFICATION_PREFERENCES_TABLE_SQL);
|
|
||||||
db.exec(VAPID_KEYS_TABLE_SQL);
|
|
||||||
db.exec(PUSH_SUBSCRIPTIONS_TABLE_SQL);
|
|
||||||
db.exec(APP_CONFIG_TABLE_SQL);
|
|
||||||
db.exec(SESSION_NAMES_TABLE_SQL);
|
|
||||||
db.exec(SESSION_NAMES_LOOKUP_INDEX_SQL);
|
|
||||||
|
|
||||||
console.log('Database migrations completed successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error running migrations:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize database with schema
|
|
||||||
const initializeDatabase = async () => {
|
|
||||||
try {
|
|
||||||
db.exec(DATABASE_SCHEMA_SQL);
|
|
||||||
console.log('Database initialized successfully');
|
|
||||||
runMigrations();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error initializing database:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// User database operations
|
|
||||||
const userDb = {
|
|
||||||
// Check if any users exist
|
|
||||||
hasUsers: () => {
|
|
||||||
try {
|
|
||||||
const row = db.prepare('SELECT COUNT(*) as count FROM users').get();
|
|
||||||
return row.count > 0;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Create a new user
|
|
||||||
createUser: (username, passwordHash) => {
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
|
|
||||||
const result = stmt.run(username, passwordHash);
|
|
||||||
return { id: result.lastInsertRowid, username };
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get user by username
|
|
||||||
getUserByUsername: (username) => {
|
|
||||||
try {
|
|
||||||
const row = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username);
|
|
||||||
return row;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Update last login time (non-fatal — logged but not thrown)
|
|
||||||
updateLastLogin: (userId) => {
|
|
||||||
try {
|
|
||||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to update last login:', err.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get user by ID
|
|
||||||
getUserById: (userId) => {
|
|
||||||
try {
|
|
||||||
const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1').get(userId);
|
|
||||||
return row;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
getFirstUser: () => {
|
|
||||||
try {
|
|
||||||
const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1').get();
|
|
||||||
return row;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
updateGitConfig: (userId, gitName, gitEmail) => {
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('UPDATE users SET git_name = ?, git_email = ? WHERE id = ?');
|
|
||||||
stmt.run(gitName, gitEmail, userId);
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
getGitConfig: (userId) => {
|
|
||||||
try {
|
|
||||||
const row = db.prepare('SELECT git_name, git_email FROM users WHERE id = ?').get(userId);
|
|
||||||
return row;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
completeOnboarding: (userId) => {
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('UPDATE users SET has_completed_onboarding = 1 WHERE id = ?');
|
|
||||||
stmt.run(userId);
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
hasCompletedOnboarding: (userId) => {
|
|
||||||
try {
|
|
||||||
const row = db.prepare('SELECT has_completed_onboarding FROM users WHERE id = ?').get(userId);
|
|
||||||
return row?.has_completed_onboarding === 1;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// API Keys database operations
|
|
||||||
const apiKeysDb = {
|
|
||||||
// Generate a new API key
|
|
||||||
generateApiKey: () => {
|
|
||||||
return 'ck_' + crypto.randomBytes(32).toString('hex');
|
|
||||||
},
|
|
||||||
|
|
||||||
// Create a new API key
|
|
||||||
createApiKey: (userId, keyName) => {
|
|
||||||
try {
|
|
||||||
const apiKey = apiKeysDb.generateApiKey();
|
|
||||||
const stmt = db.prepare('INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)');
|
|
||||||
const result = stmt.run(userId, keyName, apiKey);
|
|
||||||
return { id: result.lastInsertRowid, keyName, apiKey };
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get all API keys for a user
|
|
||||||
getApiKeys: (userId) => {
|
|
||||||
try {
|
|
||||||
const rows = db.prepare('SELECT id, key_name, api_key, created_at, last_used, is_active FROM api_keys WHERE user_id = ? ORDER BY created_at DESC').all(userId);
|
|
||||||
return rows;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Validate API key and get user
|
|
||||||
validateApiKey: (apiKey) => {
|
|
||||||
try {
|
|
||||||
const row = db.prepare(`
|
|
||||||
SELECT u.id, u.username, ak.id as api_key_id
|
|
||||||
FROM api_keys ak
|
|
||||||
JOIN users u ON ak.user_id = u.id
|
|
||||||
WHERE ak.api_key = ? AND ak.is_active = 1 AND u.is_active = 1
|
|
||||||
`).get(apiKey);
|
|
||||||
|
|
||||||
if (row) {
|
|
||||||
// Update last_used timestamp
|
|
||||||
db.prepare('UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?').run(row.api_key_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return row;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Delete an API key
|
|
||||||
deleteApiKey: (userId, apiKeyId) => {
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?');
|
|
||||||
const result = stmt.run(apiKeyId, userId);
|
|
||||||
return result.changes > 0;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Toggle API key active status
|
|
||||||
toggleApiKey: (userId, apiKeyId, isActive) => {
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('UPDATE api_keys SET is_active = ? WHERE id = ? AND user_id = ?');
|
|
||||||
const result = stmt.run(isActive ? 1 : 0, apiKeyId, userId);
|
|
||||||
return result.changes > 0;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// User credentials database operations (for GitHub tokens, GitLab tokens, etc.)
|
|
||||||
const credentialsDb = {
|
|
||||||
// Create a new credential
|
|
||||||
createCredential: (userId, credentialName, credentialType, credentialValue, description = null) => {
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('INSERT INTO user_credentials (user_id, credential_name, credential_type, credential_value, description) VALUES (?, ?, ?, ?, ?)');
|
|
||||||
const result = stmt.run(userId, credentialName, credentialType, credentialValue, description);
|
|
||||||
return { id: result.lastInsertRowid, credentialName, credentialType };
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get all credentials for a user, optionally filtered by type
|
|
||||||
getCredentials: (userId, credentialType = null) => {
|
|
||||||
try {
|
|
||||||
let query = 'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ?';
|
|
||||||
const params = [userId];
|
|
||||||
|
|
||||||
if (credentialType) {
|
|
||||||
query += ' AND credential_type = ?';
|
|
||||||
params.push(credentialType);
|
|
||||||
}
|
|
||||||
|
|
||||||
query += ' ORDER BY created_at DESC';
|
|
||||||
|
|
||||||
const rows = db.prepare(query).all(...params);
|
|
||||||
return rows;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get active credential value for a user by type (returns most recent active)
|
|
||||||
getActiveCredential: (userId, credentialType) => {
|
|
||||||
try {
|
|
||||||
const row = db.prepare('SELECT credential_value FROM user_credentials WHERE user_id = ? AND credential_type = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1').get(userId, credentialType);
|
|
||||||
return row?.credential_value || null;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Delete a credential
|
|
||||||
deleteCredential: (userId, credentialId) => {
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('DELETE FROM user_credentials WHERE id = ? AND user_id = ?');
|
|
||||||
const result = stmt.run(credentialId, userId);
|
|
||||||
return result.changes > 0;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Toggle credential active status
|
|
||||||
toggleCredential: (userId, credentialId, isActive) => {
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('UPDATE user_credentials SET is_active = ? WHERE id = ? AND user_id = ?');
|
|
||||||
const result = stmt.run(isActive ? 1 : 0, credentialId, userId);
|
|
||||||
return result.changes > 0;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_NOTIFICATION_PREFERENCES = {
|
|
||||||
channels: {
|
|
||||||
inApp: false,
|
|
||||||
webPush: false
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
actionRequired: true,
|
|
||||||
stop: true,
|
|
||||||
error: true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeNotificationPreferences = (value) => {
|
|
||||||
const source = value && typeof value === 'object' ? value : {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
channels: {
|
|
||||||
inApp: source.channels?.inApp === true,
|
|
||||||
webPush: source.channels?.webPush === true
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
actionRequired: source.events?.actionRequired !== false,
|
|
||||||
stop: source.events?.stop !== false,
|
|
||||||
error: source.events?.error !== false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const notificationPreferencesDb = {
|
|
||||||
getPreferences: (userId) => {
|
|
||||||
try {
|
|
||||||
const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId);
|
|
||||||
if (!row) {
|
|
||||||
const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
|
|
||||||
db.prepare(
|
|
||||||
'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
|
|
||||||
).run(userId, JSON.stringify(defaults));
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(row.preferences_json);
|
|
||||||
} catch {
|
|
||||||
parsed = DEFAULT_NOTIFICATION_PREFERENCES;
|
|
||||||
}
|
|
||||||
return normalizeNotificationPreferences(parsed);
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
updatePreferences: (userId, preferences) => {
|
|
||||||
try {
|
|
||||||
const normalized = normalizeNotificationPreferences(preferences);
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
|
|
||||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT(user_id) DO UPDATE SET
|
|
||||||
preferences_json = excluded.preferences_json,
|
|
||||||
updated_at = CURRENT_TIMESTAMP`
|
|
||||||
).run(userId, JSON.stringify(normalized));
|
|
||||||
return normalized;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pushSubscriptionsDb = {
|
|
||||||
saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => {
|
|
||||||
try {
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
ON CONFLICT(endpoint) DO UPDATE SET
|
|
||||||
user_id = excluded.user_id,
|
|
||||||
keys_p256dh = excluded.keys_p256dh,
|
|
||||||
keys_auth = excluded.keys_auth`
|
|
||||||
).run(userId, endpoint, keysP256dh, keysAuth);
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
getSubscriptions: (userId) => {
|
|
||||||
try {
|
|
||||||
return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId);
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
removeSubscription: (endpoint) => {
|
|
||||||
try {
|
|
||||||
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
removeAllForUser: (userId) => {
|
|
||||||
try {
|
|
||||||
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Session custom names database operations
|
|
||||||
const sessionNamesDb = {
|
|
||||||
// Set (insert or update) a custom session name
|
|
||||||
setName: (sessionId, provider, customName) => {
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO session_names (session_id, provider, custom_name)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(session_id, provider)
|
|
||||||
DO UPDATE SET custom_name = excluded.custom_name, updated_at = CURRENT_TIMESTAMP
|
|
||||||
`).run(sessionId, provider, customName);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get a single custom session name
|
|
||||||
getName: (sessionId, provider) => {
|
|
||||||
const row = db.prepare(
|
|
||||||
'SELECT custom_name FROM session_names WHERE session_id = ? AND provider = ?'
|
|
||||||
).get(sessionId, provider);
|
|
||||||
return row?.custom_name || null;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Batch lookup — returns Map<sessionId, customName>
|
|
||||||
getNames: (sessionIds, provider) => {
|
|
||||||
if (!sessionIds.length) return new Map();
|
|
||||||
const placeholders = sessionIds.map(() => '?').join(',');
|
|
||||||
const rows = db.prepare(
|
|
||||||
`SELECT session_id, custom_name FROM session_names
|
|
||||||
WHERE session_id IN (${placeholders}) AND provider = ?`
|
|
||||||
).all(...sessionIds, provider);
|
|
||||||
return new Map(rows.map(r => [r.session_id, r.custom_name]));
|
|
||||||
},
|
|
||||||
|
|
||||||
// Delete a custom session name
|
|
||||||
deleteName: (sessionId, provider) => {
|
|
||||||
return db.prepare(
|
|
||||||
'DELETE FROM session_names WHERE session_id = ? AND provider = ?'
|
|
||||||
).run(sessionId, provider).changes > 0;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply custom session names from the database (overrides CLI-generated summaries)
|
|
||||||
function applyCustomSessionNames(sessions, provider) {
|
|
||||||
if (!sessions?.length) return;
|
|
||||||
try {
|
|
||||||
const ids = sessions.map(s => s.id);
|
|
||||||
const customNames = sessionNamesDb.getNames(ids, provider);
|
|
||||||
for (const session of sessions) {
|
|
||||||
const custom = customNames.get(session.id);
|
|
||||||
if (custom) session.summary = custom;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`[DB] Failed to apply custom session names for ${provider}:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// App config database operations
|
|
||||||
const appConfigDb = {
|
|
||||||
get: (key) => {
|
|
||||||
try {
|
|
||||||
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key);
|
|
||||||
return row?.value || null;
|
|
||||||
} catch (err) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
set: (key, value) => {
|
|
||||||
db.prepare(
|
|
||||||
'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
|
||||||
).run(key, value);
|
|
||||||
},
|
|
||||||
|
|
||||||
getOrCreateJwtSecret: () => {
|
|
||||||
let secret = appConfigDb.get('jwt_secret');
|
|
||||||
if (!secret) {
|
|
||||||
secret = crypto.randomBytes(64).toString('hex');
|
|
||||||
appConfigDb.set('jwt_secret', secret);
|
|
||||||
}
|
|
||||||
return secret;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Backward compatibility - keep old names pointing to new system
|
|
||||||
const githubTokensDb = {
|
|
||||||
createGithubToken: (userId, tokenName, githubToken, description = null) => {
|
|
||||||
return credentialsDb.createCredential(userId, tokenName, 'github_token', githubToken, description);
|
|
||||||
},
|
|
||||||
getGithubTokens: (userId) => {
|
|
||||||
return credentialsDb.getCredentials(userId, 'github_token');
|
|
||||||
},
|
|
||||||
getActiveGithubToken: (userId) => {
|
|
||||||
return credentialsDb.getActiveCredential(userId, 'github_token');
|
|
||||||
},
|
|
||||||
deleteGithubToken: (userId, tokenId) => {
|
|
||||||
return credentialsDb.deleteCredential(userId, tokenId);
|
|
||||||
},
|
|
||||||
toggleGithubToken: (userId, tokenId, isActive) => {
|
|
||||||
return credentialsDb.toggleCredential(userId, tokenId, isActive);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export {
|
|
||||||
db,
|
|
||||||
initializeDatabase,
|
|
||||||
userDb,
|
|
||||||
apiKeysDb,
|
|
||||||
credentialsDb,
|
|
||||||
notificationPreferencesDb,
|
|
||||||
pushSubscriptionsDb,
|
|
||||||
sessionNamesDb,
|
|
||||||
applyCustomSessionNames,
|
|
||||||
appConfigDb,
|
|
||||||
githubTokensDb // Backward compatibility
|
|
||||||
};
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
export const APP_CONFIG_TABLE_SQL = `CREATE TABLE IF NOT EXISTS app_config (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);`;
|
|
||||||
|
|
||||||
export const USER_NOTIFICATION_PREFERENCES_TABLE_SQL = `CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
|
||||||
user_id INTEGER PRIMARY KEY,
|
|
||||||
preferences_json TEXT NOT NULL,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);`;
|
|
||||||
|
|
||||||
export const VAPID_KEYS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS vapid_keys (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
public_key TEXT NOT NULL,
|
|
||||||
private_key TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);`;
|
|
||||||
|
|
||||||
export const PUSH_SUBSCRIPTIONS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
endpoint TEXT NOT NULL UNIQUE,
|
|
||||||
keys_p256dh TEXT NOT NULL,
|
|
||||||
keys_auth TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);`;
|
|
||||||
|
|
||||||
export const SESSION_NAMES_TABLE_SQL = `CREATE TABLE IF NOT EXISTS session_names (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
session_id TEXT NOT NULL,
|
|
||||||
provider TEXT NOT NULL DEFAULT 'claude',
|
|
||||||
custom_name TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(session_id, provider)
|
|
||||||
);`;
|
|
||||||
|
|
||||||
export const SESSION_NAMES_LOOKUP_INDEX_SQL = `CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);`;
|
|
||||||
|
|
||||||
export const DATABASE_SCHEMA_SQL = `PRAGMA foreign_keys = ON;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username TEXT UNIQUE NOT NULL,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_login DATETIME,
|
|
||||||
is_active BOOLEAN DEFAULT 1,
|
|
||||||
git_name TEXT,
|
|
||||||
git_email TEXT,
|
|
||||||
has_completed_onboarding BOOLEAN DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
key_name TEXT NOT NULL,
|
|
||||||
api_key TEXT UNIQUE NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_used DATETIME,
|
|
||||||
is_active BOOLEAN DEFAULT 1,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_credentials (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
credential_name TEXT NOT NULL,
|
|
||||||
credential_type TEXT NOT NULL,
|
|
||||||
credential_value TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
is_active BOOLEAN DEFAULT 1,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
|
|
||||||
|
|
||||||
${USER_NOTIFICATION_PREFERENCES_TABLE_SQL}
|
|
||||||
|
|
||||||
${VAPID_KEYS_TABLE_SQL}
|
|
||||||
|
|
||||||
${PUSH_SUBSCRIPTIONS_TABLE_SQL}
|
|
||||||
|
|
||||||
${SESSION_NAMES_TABLE_SQL}
|
|
||||||
|
|
||||||
${SESSION_NAMES_LOOKUP_INDEX_SQL}
|
|
||||||
|
|
||||||
${APP_CONFIG_TABLE_SQL}
|
|
||||||
`;
|
|
||||||
@@ -1,21 +1,131 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
import crossSpawn from 'cross-spawn';
|
import crossSpawn from 'cross-spawn';
|
||||||
|
|
||||||
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
|
|
||||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import os from 'os';
|
|
||||||
import sessionManager from './sessionManager.js';
|
import sessionManager from './sessionManager.js';
|
||||||
import GeminiResponseHandler from './gemini-response-handler.js';
|
import GeminiResponseHandler from './gemini-response-handler.js';
|
||||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||||
import { createNormalizedMessage } from './shared/utils.js';
|
import { createNormalizedMessage } from './shared/utils.js';
|
||||||
|
|
||||||
|
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
|
||||||
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
|
|
||||||
let activeGeminiProcesses = new Map(); // Track active processes by session ID
|
let activeGeminiProcesses = new Map(); // Track active processes by session ID
|
||||||
|
|
||||||
|
function mapGeminiExitCodeToMessage(exitCode) {
|
||||||
|
switch (exitCode) {
|
||||||
|
case 42:
|
||||||
|
return 'Gemini rejected the request input (exit code 42).';
|
||||||
|
case 44:
|
||||||
|
return 'Gemini sandbox error (exit code 44). Check local sandbox/container settings.';
|
||||||
|
case 52:
|
||||||
|
return 'Gemini configuration error (exit code 52). Check your Gemini settings files for invalid JSON/config.';
|
||||||
|
case 53:
|
||||||
|
return 'Gemini conversation turn limit reached (exit code 53). Start a new Gemini session.';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GEMINI_AUTH_ENV_KEYS = [
|
||||||
|
'GEMINI_API_KEY',
|
||||||
|
'GOOGLE_API_KEY',
|
||||||
|
'GOOGLE_CLOUD_PROJECT',
|
||||||
|
'GOOGLE_CLOUD_PROJECT_ID',
|
||||||
|
'GOOGLE_CLOUD_LOCATION',
|
||||||
|
'GOOGLE_APPLICATION_CREDENTIALS'
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseEnvFileContent(content) {
|
||||||
|
const parsed = {};
|
||||||
|
|
||||||
|
for (const rawLine of content.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportPrefix = 'export ';
|
||||||
|
const normalizedLine = line.startsWith(exportPrefix) ? line.slice(exportPrefix.length).trim() : line;
|
||||||
|
const separatorIndex = normalizedLine.indexOf('=');
|
||||||
|
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalizedLine.slice(0, separatorIndex).trim();
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = normalizedLine.slice(separatorIndex + 1).trim();
|
||||||
|
const hasDoubleQuotes = value.startsWith('"') && value.endsWith('"');
|
||||||
|
const hasSingleQuotes = value.startsWith('\'') && value.endsWith('\'');
|
||||||
|
|
||||||
|
if (hasDoubleQuotes || hasSingleQuotes) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
} else {
|
||||||
|
// Support inline comments in unquoted values: KEY=value # comment
|
||||||
|
value = value.replace(/\s+#.*$/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGeminiUserLevelEnv() {
|
||||||
|
const geminiCliHome = (process.env.GEMINI_CLI_HOME || '').trim() || os.homedir();
|
||||||
|
const envCandidates = [
|
||||||
|
path.join(geminiCliHome, '.gemini', '.env'),
|
||||||
|
path.join(geminiCliHome, '.env')
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const envPath of envCandidates) {
|
||||||
|
try {
|
||||||
|
await fs.access(envPath);
|
||||||
|
const content = await fs.readFile(envPath, 'utf8');
|
||||||
|
return parseEnvFileContent(content);
|
||||||
|
} catch {
|
||||||
|
// Keep scanning for the next candidate.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildGeminiProcessEnv() {
|
||||||
|
const processEnv = { ...process.env };
|
||||||
|
if (processEnv.GEMINI_API_KEY || processEnv.GOOGLE_API_KEY || processEnv.GOOGLE_APPLICATION_CREDENTIALS) {
|
||||||
|
return processEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini CLI docs recommend ~/.gemini/.env for persistent headless auth settings.
|
||||||
|
// When the server process was launched without shell profile variables, we still
|
||||||
|
// want the spawned CLI process to inherit those user-level credentials.
|
||||||
|
const userEnv = await loadGeminiUserLevelEnv();
|
||||||
|
for (const key of GEMINI_AUTH_ENV_KEYS) {
|
||||||
|
if (!processEnv[key] && userEnv[key]) {
|
||||||
|
processEnv[key] = userEnv[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return processEnv;
|
||||||
|
}
|
||||||
|
|
||||||
async function spawnGemini(command, options = {}, ws) {
|
async function spawnGemini(command, options = {}, ws) {
|
||||||
const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options;
|
const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options;
|
||||||
|
const resolvedModel = await providerModelsService.resolveResumeModel(
|
||||||
|
'gemini',
|
||||||
|
sessionId,
|
||||||
|
options.model
|
||||||
|
);
|
||||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||||
let assistantBlocks = []; // Accumulate the full response blocks including tools
|
let assistantBlocks = []; // Accumulate the full response blocks including tools
|
||||||
@@ -100,6 +210,11 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
args.push('--debug');
|
args.push('--debug');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This integration runs Gemini in headless mode and cannot answer trust prompts.
|
||||||
|
// Skip folder-trust interactivity so authenticated runs don't fail with
|
||||||
|
// FatalUntrustedWorkspaceError in previously unseen directories.
|
||||||
|
args.push('--skip-trust');
|
||||||
|
|
||||||
// Add MCP config flag only if MCP servers are configured
|
// Add MCP config flag only if MCP servers are configured
|
||||||
try {
|
try {
|
||||||
const geminiConfigPath = path.join(os.homedir(), '.gemini.json');
|
const geminiConfigPath = path.join(os.homedir(), '.gemini.json');
|
||||||
@@ -135,7 +250,7 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add model for all sessions (both new and resumed)
|
// Add model for all sessions (both new and resumed)
|
||||||
let modelToUse = options.model || 'gemini-2.5-flash';
|
let modelToUse = resolvedModel || 'gemini-2.5-flash';
|
||||||
args.push('--model', modelToUse);
|
args.push('--model', modelToUse);
|
||||||
args.push('--output-format', 'stream-json');
|
args.push('--output-format', 'stream-json');
|
||||||
|
|
||||||
@@ -154,9 +269,6 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
|
|
||||||
// Try to find gemini in PATH first, then fall back to environment variable
|
// Try to find gemini in PATH first, then fall back to environment variable
|
||||||
const geminiPath = process.env.GEMINI_PATH || 'gemini';
|
const geminiPath = process.env.GEMINI_PATH || 'gemini';
|
||||||
console.log('Spawning Gemini CLI:', geminiPath, args.join(' '));
|
|
||||||
console.log('Working directory:', workingDir);
|
|
||||||
|
|
||||||
let spawnCmd = geminiPath;
|
let spawnCmd = geminiPath;
|
||||||
let spawnArgs = args;
|
let spawnArgs = args;
|
||||||
|
|
||||||
@@ -168,11 +280,13 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
spawnArgs = ['-c', 'exec "$0" "$@"', geminiPath, ...args];
|
spawnArgs = ['-c', 'exec "$0" "$@"', geminiPath, ...args];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const spawnEnv = await buildGeminiProcessEnv();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const geminiProcess = spawnFunction(spawnCmd, spawnArgs, {
|
const geminiProcess = spawnFunction(spawnCmd, spawnArgs, {
|
||||||
cwd: workingDir,
|
cwd: workingDir,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
env: { ...process.env } // Inherit all environment variables
|
env: spawnEnv
|
||||||
});
|
});
|
||||||
let terminalNotificationSent = false;
|
let terminalNotificationSent = false;
|
||||||
let terminalFailureReason = null;
|
let terminalFailureReason = null;
|
||||||
@@ -276,12 +390,43 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onInit: (event) => {
|
onInit: (event) => {
|
||||||
if (capturedSessionId) {
|
const discoveredSessionId = event?.session_id;
|
||||||
const sess = sessionManager.getSession(capturedSessionId);
|
if (!discoveredSessionId) {
|
||||||
if (sess && !sess.cliSessionId) {
|
return;
|
||||||
sess.cliSessionId = event.session_id;
|
}
|
||||||
sessionManager.saveSession(capturedSessionId);
|
|
||||||
|
// New Gemini sessions announce their canonical ID asynchronously via the
|
||||||
|
// initial `init` stream event. Avoid synthetic IDs and only register
|
||||||
|
// the session once that real ID is known (same model used by Claude/Codex).
|
||||||
|
if (!capturedSessionId) {
|
||||||
|
capturedSessionId = discoveredSessionId;
|
||||||
|
|
||||||
|
sessionManager.createSession(capturedSessionId, cwd || process.cwd());
|
||||||
|
if (command) {
|
||||||
|
sessionManager.addMessage(capturedSessionId, 'user', command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (processKey !== capturedSessionId) {
|
||||||
|
activeGeminiProcesses.delete(processKey);
|
||||||
|
activeGeminiProcesses.set(capturedSessionId, geminiProcess);
|
||||||
|
}
|
||||||
|
|
||||||
|
geminiProcess.sessionId = capturedSessionId;
|
||||||
|
|
||||||
|
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
||||||
|
ws.setSessionId(capturedSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId && !sessionCreatedSent) {
|
||||||
|
sessionCreatedSent = true;
|
||||||
|
ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sess = sessionManager.getSession(capturedSessionId);
|
||||||
|
if (sess && !sess.cliSessionId) {
|
||||||
|
sess.cliSessionId = discoveredSessionId;
|
||||||
|
sessionManager.saveSession(capturedSessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -292,30 +437,6 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
const rawOutput = data.toString();
|
const rawOutput = data.toString();
|
||||||
startTimeout(); // Re-arm the timeout
|
startTimeout(); // Re-arm the timeout
|
||||||
|
|
||||||
// For new sessions, create a session ID FIRST
|
|
||||||
if (!sessionId && !sessionCreatedSent && !capturedSessionId) {
|
|
||||||
capturedSessionId = `gemini_${Date.now()}`;
|
|
||||||
sessionCreatedSent = true;
|
|
||||||
|
|
||||||
// Create session in session manager
|
|
||||||
sessionManager.createSession(capturedSessionId, cwd || process.cwd());
|
|
||||||
|
|
||||||
// Save the user message now that we have a session ID
|
|
||||||
if (command) {
|
|
||||||
sessionManager.addMessage(capturedSessionId, 'user', command);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update process key with captured session ID
|
|
||||||
if (processKey !== capturedSessionId) {
|
|
||||||
activeGeminiProcesses.delete(processKey);
|
|
||||||
activeGeminiProcesses.set(capturedSessionId, geminiProcess);
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId);
|
|
||||||
|
|
||||||
ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (responseHandler) {
|
if (responseHandler) {
|
||||||
responseHandler.processData(rawOutput);
|
responseHandler.processData(rawOutput);
|
||||||
} else if (rawOutput) {
|
} else if (rawOutput) {
|
||||||
@@ -381,12 +502,38 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
notifyTerminalState({ code });
|
notifyTerminalState({ code });
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
// code 127 = shell "command not found" — check installation
|
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
||||||
|
|
||||||
|
// code 127 = shell "command not found" - check installation
|
||||||
if (code === 127) {
|
if (code === 127) {
|
||||||
const installed = await providerAuthService.isProviderInstalled('gemini');
|
const installed = await providerAuthService.isProviderInstalled('gemini');
|
||||||
if (!installed) {
|
if (!installed) {
|
||||||
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
terminalFailureReason = 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli';
|
||||||
ws.send(createNormalizedMessage({ kind: 'error', content: 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli', sessionId: socketSessionId, provider: 'gemini' }));
|
ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
|
||||||
|
}
|
||||||
|
} else if (code === 41) {
|
||||||
|
// Gemini CLI documents exit code 41 as FatalAuthenticationError.
|
||||||
|
// Surface an actionable auth error instead of a generic exit-code message.
|
||||||
|
let authErrorSuffix = '';
|
||||||
|
try {
|
||||||
|
const authStatus = await providerAuthService.getProviderAuthStatus('gemini');
|
||||||
|
if (!authStatus?.authenticated && authStatus?.error) {
|
||||||
|
authErrorSuffix = ` Details: ${authStatus.error}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep base remediation text when auth status lookup fails.
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalFailureReason =
|
||||||
|
'Gemini authentication failed (exit code 41). '
|
||||||
|
+ 'Run `gemini` in a terminal to choose an auth method, or configure a valid `GEMINI_API_KEY`.'
|
||||||
|
+ authErrorSuffix;
|
||||||
|
ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
|
||||||
|
} else {
|
||||||
|
const mappedError = mapGeminiExitCodeToMessage(code);
|
||||||
|
if (mappedError) {
|
||||||
|
terminalFailureReason = mappedError;
|
||||||
|
ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +541,14 @@ async function spawnGemini(command, options = {}, ws) {
|
|||||||
code,
|
code,
|
||||||
error: code === null ? 'Gemini CLI process was terminated or timed out' : null
|
error: code === null ? 'Gemini CLI process was terminated or timed out' : null
|
||||||
});
|
});
|
||||||
reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`));
|
reject(
|
||||||
|
new Error(
|
||||||
|
terminalFailureReason
|
||||||
|
|| (code === null
|
||||||
|
? 'Gemini CLI process was terminated or timed out'
|
||||||
|
: `Gemini CLI exited with code ${code}`)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
// Gemini Response Handler - JSON Stream processing
|
// Gemini Response Handler - JSON Stream processing
|
||||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||||
|
import { createNormalizedMessage } from './shared/utils.js';
|
||||||
|
|
||||||
|
function buildGeminiTokenBudget(tokens) {
|
||||||
|
if (!tokens || typeof tokens !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedInputTokens = Number(tokens.input);
|
||||||
|
const parsedOutputTokens = Number(tokens.output);
|
||||||
|
const inputTokens = Number.isFinite(parsedInputTokens) ? parsedInputTokens : 0;
|
||||||
|
const outputTokens = Number.isFinite(parsedOutputTokens) ? parsedOutputTokens : 0;
|
||||||
|
const parsedUsed = Number(tokens.total);
|
||||||
|
const used = Number.isFinite(parsedUsed) ? parsedUsed : inputTokens + outputTokens;
|
||||||
|
if (!Number.isFinite(used) || used <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
used,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
breakdown: {
|
||||||
|
input: inputTokens,
|
||||||
|
output: outputTokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class GeminiResponseHandler {
|
class GeminiResponseHandler {
|
||||||
constructor(ws, options = {}) {
|
constructor(ws, options = {}) {
|
||||||
@@ -60,6 +87,17 @@ class GeminiResponseHandler {
|
|||||||
for (const msg of normalized) {
|
for (const msg of normalized) {
|
||||||
this.ws.send(msg);
|
this.ws.send(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenBudget = buildGeminiTokenBudget(event.tokens);
|
||||||
|
if (tokenBudget) {
|
||||||
|
this.ws.send(createNormalizedMessage({
|
||||||
|
kind: 'status',
|
||||||
|
text: 'token_budget',
|
||||||
|
tokenBudget,
|
||||||
|
sessionId: sid,
|
||||||
|
provider: 'gemini',
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
forceFlush() {
|
forceFlush() {
|
||||||
|
|||||||
1526
server/index.js
1526
server/index.js
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { userDb, appConfigDb } from '../database/db.js';
|
import { userDb, appConfigDb } from '../modules/database/index.js';
|
||||||
import { IS_PLATFORM } from '../constants/config.js';
|
import { IS_PLATFORM } from '../constants/config.js';
|
||||||
|
|
||||||
// Use env var if set, otherwise auto-generate a unique secret per installation
|
// Use env var if set, otherwise auto-generate a unique secret per installation
|
||||||
|
|||||||
143
server/modules/database/connection.ts
Normal file
143
server/modules/database/connection.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Database connection management.
|
||||||
|
*
|
||||||
|
* Owns the single SQLite connection used across all repositories.
|
||||||
|
* Handles path resolution, directory creation, legacy database migration,
|
||||||
|
* and eager app_config bootstrap so the auth middleware can read the
|
||||||
|
* JWT secret before the full schema is applied.
|
||||||
|
*
|
||||||
|
* Consumers should never create their own Database instance — they use
|
||||||
|
* `getConnection()` to obtain the shared singleton.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
import { APP_CONFIG_TABLE_SCHEMA_SQL } from '@/modules/database/schema.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Path resolution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the database file path from environment or falls back
|
||||||
|
* to the legacy location inside the server/database/ folder.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. DATABASE_PATH environment variable (set by cli.js or load-env-vars.js)
|
||||||
|
* 2. Legacy path: server/database/auth.db
|
||||||
|
*/
|
||||||
|
function resolveDatabasePath(): string {
|
||||||
|
// process.env.DATABASE_PATH is set by load-env-vars.js to either the .env value or a default(~/.cloudcli/auth.db) in the user's home directory.
|
||||||
|
return process.env.DATABASE_PATH || resolveLegacyDatabasePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the legacy database path (always inside server/database/).
|
||||||
|
* Used for the one-time migration to the new external location.
|
||||||
|
*/
|
||||||
|
function resolveLegacyDatabasePath(): string {
|
||||||
|
const serverDir = path.resolve(__dirname, '..', '..', '..');
|
||||||
|
return path.join(serverDir, 'database', 'auth.db');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Directory & migration helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ensureDatabaseDirectory(dbPath: string): void {
|
||||||
|
const dir = path.dirname(dbPath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
console.log('Created database directory:', dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the database was moved to an external location (e.g. ~/.cloudcli/)
|
||||||
|
* but the user still has a legacy auth.db inside the install directory,
|
||||||
|
* copy it to the new location as a one-time migration.
|
||||||
|
*/
|
||||||
|
function migrateLegacyDatabase(targetPath: string): void {
|
||||||
|
const legacyPath = resolveLegacyDatabasePath();
|
||||||
|
|
||||||
|
if (targetPath === legacyPath) return;
|
||||||
|
if (fs.existsSync(targetPath)) return;
|
||||||
|
if (!fs.existsSync(legacyPath)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(legacyPath, targetPath);
|
||||||
|
console.log('Migrated legacy database', { from: legacyPath, to: targetPath });
|
||||||
|
|
||||||
|
|
||||||
|
// copy the write-ahead log and shared memory files (auth.db-wal, auth.db-shm) if they exist, to preserve any uncommitted transactions
|
||||||
|
for (const suffix of ['-wal', '-shm']) {
|
||||||
|
const src = legacyPath + suffix;
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
fs.copyFileSync(src, targetPath + suffix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Could not migrate legacy database', { error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Singleton connection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let instance: Database.Database | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the shared database connection, creating it on first call.
|
||||||
|
*
|
||||||
|
* The first invocation:
|
||||||
|
* 1. Resolves the target database path
|
||||||
|
* 2. Ensures the parent directory exists
|
||||||
|
* 3. Migrates from the legacy install-directory path if needed
|
||||||
|
* 4. Opens the SQLite connection
|
||||||
|
* 5. Eagerly creates the app_config table (auth reads JWT secret at import time)
|
||||||
|
* 6. Logs the database location
|
||||||
|
*/
|
||||||
|
export function getConnection(): Database.Database {
|
||||||
|
if (instance) return instance;
|
||||||
|
|
||||||
|
const dbPath = resolveDatabasePath();
|
||||||
|
|
||||||
|
ensureDatabaseDirectory(dbPath);
|
||||||
|
migrateLegacyDatabase(dbPath);
|
||||||
|
|
||||||
|
instance = new Database(dbPath);
|
||||||
|
|
||||||
|
// app_config must exist immediately — the auth middleware reads
|
||||||
|
// the JWT secret at module-load time, before initializeDatabase() runs.
|
||||||
|
instance.exec(APP_CONFIG_TABLE_SCHEMA_SQL);
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the resolved database file path without opening a connection.
|
||||||
|
* Useful for diagnostics and CLI status commands.
|
||||||
|
*/
|
||||||
|
export function getDatabasePath(): string {
|
||||||
|
return resolveDatabasePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the database connection and clears the singleton.
|
||||||
|
* Primarily used for graceful shutdown or testing.
|
||||||
|
*/
|
||||||
|
export function closeConnection(): void {
|
||||||
|
if (instance) {
|
||||||
|
instance.close();
|
||||||
|
instance = null;
|
||||||
|
console.log('Database connection closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
13
server/modules/database/index.ts
Normal file
13
server/modules/database/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export { initializeDatabase } from '@/modules/database/init-db.js';
|
||||||
|
export { closeConnection, getConnection, getDatabasePath } from '@/modules/database/connection.js';
|
||||||
|
export { apiKeysDb } from '@/modules/database/repositories/api-keys.js';
|
||||||
|
export { appConfigDb } from '@/modules/database/repositories/app-config.js';
|
||||||
|
export { credentialsDb } from '@/modules/database/repositories/credentials.js';
|
||||||
|
export { githubTokensDb } from '@/modules/database/repositories/github-tokens.js';
|
||||||
|
export { notificationPreferencesDb } from '@/modules/database/repositories/notification-preferences.js';
|
||||||
|
export { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
||||||
|
export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js';
|
||||||
|
export { scanStateDb } from '@/modules/database/repositories/scan-state.db.js';
|
||||||
|
export { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
|
||||||
|
export { userDb } from '@/modules/database/repositories/users.js';
|
||||||
|
export { vapidKeysDb } from '@/modules/database/repositories/vapid-keys.js';
|
||||||
17
server/modules/database/init-db.ts
Normal file
17
server/modules/database/init-db.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { getConnection } from "@/modules/database/connection.js";
|
||||||
|
import { runMigrations } from "@/modules/database/migrations.js";
|
||||||
|
import { INIT_SCHEMA_SQL } from "@/modules/database/schema.js";
|
||||||
|
|
||||||
|
// Initialize database with schema
|
||||||
|
export const initializeDatabase = async () => {
|
||||||
|
try {
|
||||||
|
const db = getConnection();
|
||||||
|
db.exec(INIT_SCHEMA_SQL);
|
||||||
|
console.log('Database schema applied');
|
||||||
|
runMigrations(db);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.log('Database initialization failed', { error: message });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
455
server/modules/database/migrations.ts
Normal file
455
server/modules/database/migrations.ts
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
import { Database } from 'better-sqlite3';
|
||||||
|
|
||||||
|
import {
|
||||||
|
APP_CONFIG_TABLE_SCHEMA_SQL,
|
||||||
|
LAST_SCANNED_AT_SQL,
|
||||||
|
PROJECTS_TABLE_SCHEMA_SQL,
|
||||||
|
PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL,
|
||||||
|
SESSIONS_TABLE_SCHEMA_SQL,
|
||||||
|
USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL,
|
||||||
|
VAPID_KEYS_TABLE_SCHEMA_SQL,
|
||||||
|
} from '@/modules/database/schema.js';
|
||||||
|
|
||||||
|
const SQLITE_UUID_SQL = `
|
||||||
|
lower(hex(randomblob(4))) || '-' ||
|
||||||
|
lower(hex(randomblob(2))) || '-' ||
|
||||||
|
lower(hex(randomblob(2))) || '-' ||
|
||||||
|
lower(hex(randomblob(2))) || '-' ||
|
||||||
|
lower(hex(randomblob(6)))
|
||||||
|
`;
|
||||||
|
|
||||||
|
type TableInfoRow = {
|
||||||
|
name: string;
|
||||||
|
pk: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addColumnToTableIfNotExists = (
|
||||||
|
db: Database,
|
||||||
|
tableName: string,
|
||||||
|
columnNames: string[],
|
||||||
|
columnName: string,
|
||||||
|
columnType: string
|
||||||
|
) => {
|
||||||
|
if (!columnNames.includes(columnName)) {
|
||||||
|
console.log(`Running migration: Adding ${columnName} column to ${tableName} table`);
|
||||||
|
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnType}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableExists = (db: Database, tableName: string): boolean =>
|
||||||
|
Boolean(
|
||||||
|
db
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||||
|
.get(tableName)
|
||||||
|
);
|
||||||
|
|
||||||
|
const getTableInfo = (db: Database, tableName: string): TableInfoRow[] =>
|
||||||
|
db.prepare(`PRAGMA table_info(${tableName})`).all() as TableInfoRow[];
|
||||||
|
|
||||||
|
const migrateLegacySessionNames = (db: Database): void => {
|
||||||
|
const hasLegacySessionNamesTable = tableExists(db, 'session_names');
|
||||||
|
const hasSessionsTable = tableExists(db, 'sessions');
|
||||||
|
|
||||||
|
if (!hasLegacySessionNamesTable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSessionsTable) {
|
||||||
|
console.log('Running migration: Merging session_names into sessions');
|
||||||
|
db.exec(`
|
||||||
|
INSERT INTO sessions (session_id, provider, custom_name, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
session_id,
|
||||||
|
COALESCE(provider, 'claude'),
|
||||||
|
custom_name,
|
||||||
|
COALESCE(created_at, CURRENT_TIMESTAMP),
|
||||||
|
COALESCE(updated_at, CURRENT_TIMESTAMP)
|
||||||
|
FROM session_names
|
||||||
|
WHERE true
|
||||||
|
ON CONFLICT(session_id) DO UPDATE SET
|
||||||
|
provider = excluded.provider,
|
||||||
|
custom_name = COALESCE(excluded.custom_name, sessions.custom_name),
|
||||||
|
created_at = COALESCE(sessions.created_at, excluded.created_at),
|
||||||
|
updated_at = COALESCE(excluded.updated_at, sessions.updated_at)
|
||||||
|
`);
|
||||||
|
db.exec('DROP TABLE session_names');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Running migration: Renaming session_names table to sessions');
|
||||||
|
db.exec('ALTER TABLE session_names RENAME TO sessions');
|
||||||
|
};
|
||||||
|
|
||||||
|
const migrateLegacyWorkspaceTableIntoProjects = (db: Database): void => {
|
||||||
|
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
|
||||||
|
|
||||||
|
if (!tableExists(db, 'workspace_original_paths')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Running migration: Migrating workspace_original_paths data into projects');
|
||||||
|
db.exec(`
|
||||||
|
INSERT INTO projects (project_id, project_path, custom_project_name, isStarred, isArchived)
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN workspace_id IS NULL OR trim(workspace_id) = ''
|
||||||
|
THEN ${SQLITE_UUID_SQL}
|
||||||
|
ELSE workspace_id
|
||||||
|
END,
|
||||||
|
workspace_path,
|
||||||
|
custom_workspace_name,
|
||||||
|
COALESCE(isStarred, 0),
|
||||||
|
0
|
||||||
|
FROM workspace_original_paths
|
||||||
|
WHERE workspace_path IS NOT NULL AND trim(workspace_path) <> ''
|
||||||
|
ON CONFLICT(project_path) DO UPDATE SET
|
||||||
|
custom_project_name = COALESCE(projects.custom_project_name, excluded.custom_project_name),
|
||||||
|
isStarred = COALESCE(projects.isStarred, excluded.isStarred)
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rebuildProjectsTableWithPrimaryKeySchema = (db: Database): void => {
|
||||||
|
const hasProjectsTable = tableExists(db, 'projects');
|
||||||
|
if (!hasProjectsTable) {
|
||||||
|
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectsTableInfo = getTableInfo(db, 'projects');
|
||||||
|
const columnNames = projectsTableInfo.map((column) => column.name);
|
||||||
|
const hasProjectIdPrimaryKey = projectsTableInfo.some(
|
||||||
|
(column) => column.name === 'project_id' && column.pk === 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasProjectIdPrimaryKey) {
|
||||||
|
addColumnToTableIfNotExists(db, 'projects', columnNames, 'custom_project_name', 'TEXT DEFAULT NULL');
|
||||||
|
addColumnToTableIfNotExists(db, 'projects', columnNames, 'isStarred', 'BOOLEAN DEFAULT 0');
|
||||||
|
addColumnToTableIfNotExists(db, 'projects', columnNames, 'isArchived', 'BOOLEAN DEFAULT 0');
|
||||||
|
db.exec(`
|
||||||
|
UPDATE projects
|
||||||
|
SET project_id = ${SQLITE_UUID_SQL}
|
||||||
|
WHERE project_id IS NULL OR trim(project_id) = ''
|
||||||
|
`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Running migration: Rebuilding projects table to enforce project_id primary key');
|
||||||
|
|
||||||
|
const projectPathExpression = columnNames.includes('project_path')
|
||||||
|
? 'project_path'
|
||||||
|
: columnNames.includes('workspace_path')
|
||||||
|
? 'workspace_path'
|
||||||
|
: 'NULL';
|
||||||
|
|
||||||
|
const customProjectNameExpression = columnNames.includes('custom_project_name')
|
||||||
|
? 'custom_project_name'
|
||||||
|
: columnNames.includes('custom_workspace_name')
|
||||||
|
? 'custom_workspace_name'
|
||||||
|
: 'NULL';
|
||||||
|
|
||||||
|
const isStarredExpression = columnNames.includes('isStarred') ? 'COALESCE(isStarred, 0)' : '0';
|
||||||
|
|
||||||
|
const isArchivedExpression = columnNames.includes('isArchived') ? 'COALESCE(isArchived, 0)' : '0';
|
||||||
|
|
||||||
|
const projectIdExpression = columnNames.includes('project_id')
|
||||||
|
? `CASE
|
||||||
|
WHEN project_id IS NULL OR trim(project_id) = ''
|
||||||
|
THEN ${SQLITE_UUID_SQL}
|
||||||
|
ELSE project_id
|
||||||
|
END`
|
||||||
|
: SQLITE_UUID_SQL;
|
||||||
|
|
||||||
|
db.exec('PRAGMA foreign_keys = OFF');
|
||||||
|
try {
|
||||||
|
db.exec('BEGIN TRANSACTION');
|
||||||
|
db.exec('DROP TABLE IF EXISTS projects__new');
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE projects__new (
|
||||||
|
project_id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
project_path TEXT NOT NULL UNIQUE,
|
||||||
|
custom_project_name TEXT DEFAULT NULL,
|
||||||
|
isStarred BOOLEAN DEFAULT 0,
|
||||||
|
isArchived BOOLEAN DEFAULT 0
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec(`
|
||||||
|
WITH source_rows AS (
|
||||||
|
SELECT
|
||||||
|
${projectPathExpression} AS project_path,
|
||||||
|
${customProjectNameExpression} AS custom_project_name,
|
||||||
|
${isStarredExpression} AS isStarred,
|
||||||
|
${isArchivedExpression} AS isArchived,
|
||||||
|
${projectIdExpression} AS candidate_project_id,
|
||||||
|
rowid AS source_rowid
|
||||||
|
FROM projects
|
||||||
|
WHERE ${projectPathExpression} IS NOT NULL AND trim(${projectPathExpression}) <> ''
|
||||||
|
),
|
||||||
|
deduped_paths AS (
|
||||||
|
SELECT
|
||||||
|
project_path,
|
||||||
|
custom_project_name,
|
||||||
|
isStarred,
|
||||||
|
isArchived,
|
||||||
|
candidate_project_id,
|
||||||
|
source_rowid,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY project_path ORDER BY source_rowid) AS project_path_rank
|
||||||
|
FROM source_rows
|
||||||
|
),
|
||||||
|
prepared_rows AS (
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN ROW_NUMBER() OVER (PARTITION BY candidate_project_id ORDER BY source_rowid) = 1
|
||||||
|
THEN candidate_project_id
|
||||||
|
ELSE ${SQLITE_UUID_SQL}
|
||||||
|
END AS project_id,
|
||||||
|
project_path,
|
||||||
|
custom_project_name,
|
||||||
|
isStarred,
|
||||||
|
isArchived
|
||||||
|
FROM deduped_paths
|
||||||
|
WHERE project_path_rank = 1
|
||||||
|
)
|
||||||
|
INSERT INTO projects__new (
|
||||||
|
project_id,
|
||||||
|
project_path,
|
||||||
|
custom_project_name,
|
||||||
|
isStarred,
|
||||||
|
isArchived
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
project_id,
|
||||||
|
project_path,
|
||||||
|
custom_project_name,
|
||||||
|
isStarred,
|
||||||
|
isArchived
|
||||||
|
FROM prepared_rows
|
||||||
|
`);
|
||||||
|
db.exec('DROP TABLE projects');
|
||||||
|
db.exec('ALTER TABLE projects__new RENAME TO projects');
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch (migrationError) {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw migrationError;
|
||||||
|
} finally {
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
|
||||||
|
const hasSessions = tableExists(db, 'sessions');
|
||||||
|
if (!hasSessions) {
|
||||||
|
db.exec(SESSIONS_TABLE_SCHEMA_SQL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionsTableInfo = getTableInfo(db, 'sessions');
|
||||||
|
const columnNames = sessionsTableInfo.map((column) => column.name);
|
||||||
|
const primaryKeyColumns = sessionsTableInfo
|
||||||
|
.filter((column) => column.pk > 0)
|
||||||
|
.sort((a, b) => a.pk - b.pk)
|
||||||
|
.map((column) => column.name);
|
||||||
|
|
||||||
|
const shouldRebuild =
|
||||||
|
!columnNames.includes('project_path') ||
|
||||||
|
primaryKeyColumns.length !== 1 ||
|
||||||
|
primaryKeyColumns[0] !== 'session_id' ||
|
||||||
|
!columnNames.includes('provider');
|
||||||
|
|
||||||
|
if (!shouldRebuild) {
|
||||||
|
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'jsonl_path', 'TEXT');
|
||||||
|
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'isArchived', 'BOOLEAN DEFAULT 0');
|
||||||
|
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'created_at', 'DATETIME');
|
||||||
|
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'updated_at', 'DATETIME');
|
||||||
|
db.exec('UPDATE sessions SET isArchived = COALESCE(isArchived, 0)');
|
||||||
|
db.exec('UPDATE sessions SET created_at = COALESCE(created_at, CURRENT_TIMESTAMP)');
|
||||||
|
db.exec('UPDATE sessions SET updated_at = COALESCE(updated_at, CURRENT_TIMESTAMP)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Running migration: Rebuilding sessions table to project-based schema');
|
||||||
|
|
||||||
|
const projectPathExpression = columnNames.includes('project_path')
|
||||||
|
? 'project_path'
|
||||||
|
: columnNames.includes('workspace_path')
|
||||||
|
? 'workspace_path'
|
||||||
|
: 'NULL';
|
||||||
|
|
||||||
|
const providerExpression = columnNames.includes('provider')
|
||||||
|
? "COALESCE(provider, 'claude')"
|
||||||
|
: "'claude'";
|
||||||
|
|
||||||
|
const customNameExpression = columnNames.includes('custom_name')
|
||||||
|
? 'custom_name'
|
||||||
|
: 'NULL';
|
||||||
|
|
||||||
|
const jsonlPathExpression = columnNames.includes('jsonl_path')
|
||||||
|
? 'jsonl_path'
|
||||||
|
: 'NULL';
|
||||||
|
|
||||||
|
const isArchivedExpression = columnNames.includes('isArchived')
|
||||||
|
? 'COALESCE(isArchived, 0)'
|
||||||
|
: '0';
|
||||||
|
|
||||||
|
const createdAtExpression = columnNames.includes('created_at')
|
||||||
|
? 'COALESCE(created_at, CURRENT_TIMESTAMP)'
|
||||||
|
: 'CURRENT_TIMESTAMP';
|
||||||
|
|
||||||
|
const updatedAtExpression = columnNames.includes('updated_at')
|
||||||
|
? 'COALESCE(updated_at, CURRENT_TIMESTAMP)'
|
||||||
|
: 'CURRENT_TIMESTAMP';
|
||||||
|
|
||||||
|
db.exec('PRAGMA foreign_keys = OFF');
|
||||||
|
try {
|
||||||
|
db.exec('BEGIN TRANSACTION');
|
||||||
|
db.exec('DROP TABLE IF EXISTS sessions__new');
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE sessions__new (
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL DEFAULT 'claude',
|
||||||
|
custom_name TEXT,
|
||||||
|
project_path TEXT,
|
||||||
|
jsonl_path TEXT,
|
||||||
|
isArchived BOOLEAN DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (session_id),
|
||||||
|
FOREIGN KEY (project_path) REFERENCES projects(project_path)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec(`
|
||||||
|
WITH source_rows AS (
|
||||||
|
SELECT
|
||||||
|
session_id,
|
||||||
|
${providerExpression} AS provider,
|
||||||
|
${customNameExpression} AS custom_name,
|
||||||
|
${projectPathExpression} AS project_path,
|
||||||
|
${jsonlPathExpression} AS jsonl_path,
|
||||||
|
${isArchivedExpression} AS isArchived,
|
||||||
|
${createdAtExpression} AS created_at,
|
||||||
|
${updatedAtExpression} AS updated_at,
|
||||||
|
rowid AS source_rowid
|
||||||
|
FROM sessions
|
||||||
|
WHERE session_id IS NOT NULL AND trim(session_id) <> ''
|
||||||
|
),
|
||||||
|
ranked_rows AS (
|
||||||
|
SELECT
|
||||||
|
session_id,
|
||||||
|
provider,
|
||||||
|
custom_name,
|
||||||
|
project_path,
|
||||||
|
jsonl_path,
|
||||||
|
isArchived,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY session_id
|
||||||
|
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, source_rowid DESC
|
||||||
|
) AS session_rank
|
||||||
|
FROM source_rows
|
||||||
|
)
|
||||||
|
INSERT INTO sessions__new (
|
||||||
|
session_id,
|
||||||
|
provider,
|
||||||
|
custom_name,
|
||||||
|
project_path,
|
||||||
|
jsonl_path,
|
||||||
|
isArchived,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
session_id,
|
||||||
|
provider,
|
||||||
|
custom_name,
|
||||||
|
project_path,
|
||||||
|
jsonl_path,
|
||||||
|
isArchived,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM ranked_rows
|
||||||
|
WHERE session_rank = 1
|
||||||
|
`);
|
||||||
|
db.exec('DROP TABLE sessions');
|
||||||
|
db.exec('ALTER TABLE sessions__new RENAME TO sessions');
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch (migrationError) {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw migrationError;
|
||||||
|
} finally {
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureProjectsForSessionPaths = (db: Database): void => {
|
||||||
|
if (!tableExists(db, 'sessions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
INSERT INTO projects (project_id, project_path, custom_project_name, isStarred, isArchived)
|
||||||
|
SELECT
|
||||||
|
${SQLITE_UUID_SQL},
|
||||||
|
project_path,
|
||||||
|
NULL,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
FROM sessions
|
||||||
|
WHERE project_path IS NOT NULL AND trim(project_path) <> ''
|
||||||
|
ON CONFLICT(project_path) DO NOTHING
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runMigrations = (db: Database) => {
|
||||||
|
try {
|
||||||
|
const usersTableInfo = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
|
||||||
|
const userColumnNames = usersTableInfo.map((column) => column.name);
|
||||||
|
|
||||||
|
addColumnToTableIfNotExists(db, 'users', userColumnNames, 'git_name', 'TEXT');
|
||||||
|
addColumnToTableIfNotExists(db, 'users', userColumnNames, 'git_email', 'TEXT');
|
||||||
|
addColumnToTableIfNotExists(
|
||||||
|
db,
|
||||||
|
'users',
|
||||||
|
userColumnNames,
|
||||||
|
'has_completed_onboarding',
|
||||||
|
'BOOLEAN DEFAULT 0'
|
||||||
|
);
|
||||||
|
|
||||||
|
db.exec(APP_CONFIG_TABLE_SCHEMA_SQL);
|
||||||
|
db.exec(USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL);
|
||||||
|
db.exec(VAPID_KEYS_TABLE_SCHEMA_SQL);
|
||||||
|
db.exec(PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL);
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id)');
|
||||||
|
|
||||||
|
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
|
||||||
|
rebuildProjectsTableWithPrimaryKeySchema(db);
|
||||||
|
|
||||||
|
migrateLegacyWorkspaceTableIntoProjects(db);
|
||||||
|
rebuildSessionsTableWithProjectSchema(db);
|
||||||
|
migrateLegacySessionNames(db);
|
||||||
|
ensureProjectsForSessionPaths(db);
|
||||||
|
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_archived ON projects(isArchived)');
|
||||||
|
|
||||||
|
db.exec('DROP INDEX IF EXISTS idx_session_names_lookup');
|
||||||
|
db.exec('DROP INDEX IF EXISTS idx_sessions_workspace_path');
|
||||||
|
db.exec('DROP INDEX IF EXISTS idx_workspace_original_paths_is_starred');
|
||||||
|
db.exec('DROP INDEX IF EXISTS idx_workspace_original_paths_workspace_id');
|
||||||
|
|
||||||
|
if (tableExists(db, 'workspace_original_paths')) {
|
||||||
|
console.log('Running migration: Dropping legacy workspace_original_paths table');
|
||||||
|
db.exec('DROP TABLE workspace_original_paths');
|
||||||
|
}
|
||||||
|
|
||||||
|
db.exec(LAST_SCANNED_AT_SQL);
|
||||||
|
console.log('Database migrations completed successfully');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error running migrations:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
119
server/modules/database/repositories/api-keys.ts
Normal file
119
server/modules/database/repositories/api-keys.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* API keys repository.
|
||||||
|
*
|
||||||
|
* Manages API keys used for external/programmatic access to the backend.
|
||||||
|
* Keys are prefixed with `ck_` and tied to a user via foreign key.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
import { getConnection } from '@/modules/database/connection.js';
|
||||||
|
|
||||||
|
type ApiKeyRow = {
|
||||||
|
id: number;
|
||||||
|
key_name: string;
|
||||||
|
api_key: string;
|
||||||
|
created_at: string;
|
||||||
|
last_used: string | null;
|
||||||
|
is_active: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateApiKeyResult = {
|
||||||
|
id: number | bigint;
|
||||||
|
keyName: string;
|
||||||
|
apiKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ValidatedApiKeyUser = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
api_key_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Generates a cryptographically random API key with the `ck_` prefix. */
|
||||||
|
function generateApiKey(): string {
|
||||||
|
return 'ck_' + crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Queries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const apiKeysDb = {
|
||||||
|
generateApiKey,
|
||||||
|
|
||||||
|
/** Creates a new API key for the given user and returns it for one-time display. */
|
||||||
|
createApiKey(userId: number, keyName: string): CreateApiKeyResult {
|
||||||
|
const db = getConnection();
|
||||||
|
const apiKey = generateApiKey();
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)'
|
||||||
|
)
|
||||||
|
.run(userId, keyName, apiKey);
|
||||||
|
return { id: result.lastInsertRowid, keyName, apiKey };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Lists all API keys for a user, most recent first. */
|
||||||
|
getApiKeys(userId: number): ApiKeyRow[] {
|
||||||
|
const db = getConnection();
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, key_name, api_key, created_at, last_used, is_active FROM api_keys WHERE user_id = ? ORDER BY created_at DESC'
|
||||||
|
)
|
||||||
|
.all(userId) as ApiKeyRow[];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an API key and resolves the owning user.
|
||||||
|
* If the key is valid, its `last_used` timestamp is updated as a side effect.
|
||||||
|
* Returns undefined when the key is invalid or the user is inactive.
|
||||||
|
*/
|
||||||
|
validateApiKey(apiKey: string): ValidatedApiKeyUser | undefined {
|
||||||
|
const db = getConnection();
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.id, u.username, ak.id as api_key_id
|
||||||
|
FROM api_keys ak
|
||||||
|
JOIN users u ON ak.user_id = u.id
|
||||||
|
WHERE ak.api_key = ? AND ak.is_active = 1 AND u.is_active = 1`
|
||||||
|
)
|
||||||
|
.get(apiKey) as ValidatedApiKeyUser | undefined;
|
||||||
|
|
||||||
|
if (row) {
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?'
|
||||||
|
).run(row.api_key_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Permanently removes an API key. Returns true if a row was deleted. */
|
||||||
|
deleteApiKey(userId: number, apiKeyId: number): boolean {
|
||||||
|
const db = getConnection();
|
||||||
|
const result = db
|
||||||
|
.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?')
|
||||||
|
.run(apiKeyId, userId);
|
||||||
|
return result.changes > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Enables or disables an API key without deleting it. */
|
||||||
|
toggleApiKey(
|
||||||
|
userId: number,
|
||||||
|
apiKeyId: number,
|
||||||
|
isActive: boolean
|
||||||
|
): boolean {
|
||||||
|
const db = getConnection();
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE api_keys SET is_active = ? WHERE id = ? AND user_id = ?'
|
||||||
|
)
|
||||||
|
.run(isActive ? 1 : 0, apiKeyId, userId);
|
||||||
|
return result.changes > 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
53
server/modules/database/repositories/app-config.ts
Normal file
53
server/modules/database/repositories/app-config.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* App config repository.
|
||||||
|
*
|
||||||
|
* Key-value store for application-level configuration that persists
|
||||||
|
* across restarts (JWT secret, feature flags, etc.). Values are always
|
||||||
|
* stored as strings; callers handle parsing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
import { getConnection } from '@/modules/database/connection.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Queries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const appConfigDb = {
|
||||||
|
/** Returns the stored value for a config key, or null if missing. */
|
||||||
|
get(key: string): string | null {
|
||||||
|
try {
|
||||||
|
const db = getConnection();
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT value FROM app_config WHERE key = ?')
|
||||||
|
.get(key) as { value: string } | undefined;
|
||||||
|
return row?.value ?? null;
|
||||||
|
} catch {
|
||||||
|
// Swallow errors so early-startup reads (e.g. JWT secret) do not crash.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Inserts or updates a config key (upsert). */
|
||||||
|
set(key: string, value: string): void {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||||
|
).run(key, value);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the JWT signing secret, generating and persisting one
|
||||||
|
* if it does not already exist. This ensures the secret survives
|
||||||
|
* server restarts while being created automatically on first boot.
|
||||||
|
*/
|
||||||
|
getOrCreateJwtSecret(): string {
|
||||||
|
let secret = appConfigDb.get('jwt_secret');
|
||||||
|
if (!secret) {
|
||||||
|
secret = crypto.randomBytes(64).toString('hex');
|
||||||
|
appConfigDb.set('jwt_secret', secret);
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
},
|
||||||
|
};
|
||||||
106
server/modules/database/repositories/credentials.ts
Normal file
106
server/modules/database/repositories/credentials.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* User credentials repository.
|
||||||
|
*
|
||||||
|
* Manages external service tokens (GitHub, GitLab, Bitbucket, etc.)
|
||||||
|
* stored per-user. Each credential has a type discriminator so multiple
|
||||||
|
* credential kinds can coexist in the same table.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getConnection } from '@/modules/database/connection.js';
|
||||||
|
import type {
|
||||||
|
CreateCredentialResult,
|
||||||
|
CredentialPublicRow,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Queries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const credentialsDb = {
|
||||||
|
/** Stores a new credential and returns a safe (no raw value) result. */
|
||||||
|
createCredential(
|
||||||
|
userId: number,
|
||||||
|
credentialName: string,
|
||||||
|
credentialType: string,
|
||||||
|
credentialValue: string,
|
||||||
|
description: string | null = null
|
||||||
|
): CreateCredentialResult {
|
||||||
|
const db = getConnection();
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO user_credentials (user_id, credential_name, credential_type, credential_value, description) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.run(userId, credentialName, credentialType, credentialValue, description);
|
||||||
|
return {
|
||||||
|
id: result.lastInsertRowid,
|
||||||
|
credentialName,
|
||||||
|
credentialType,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists credentials for a user (excluding raw values).
|
||||||
|
* Optionally filters by credential type (e.g. 'github_token').
|
||||||
|
*/
|
||||||
|
getCredentials(
|
||||||
|
userId: number,
|
||||||
|
credentialType: string | null = null
|
||||||
|
): CredentialPublicRow[] {
|
||||||
|
const db = getConnection();
|
||||||
|
|
||||||
|
if (credentialType) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ? AND credential_type = ? ORDER BY created_at DESC'
|
||||||
|
)
|
||||||
|
.all(userId, credentialType) as CredentialPublicRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ? ORDER BY created_at DESC'
|
||||||
|
)
|
||||||
|
.all(userId) as CredentialPublicRow[];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the raw credential value for the most recent active
|
||||||
|
* credential of the given type, or null if none exists.
|
||||||
|
*/
|
||||||
|
getActiveCredential(
|
||||||
|
userId: number,
|
||||||
|
credentialType: string
|
||||||
|
): string | null {
|
||||||
|
const db = getConnection();
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT credential_value FROM user_credentials WHERE user_id = ? AND credential_type = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1'
|
||||||
|
)
|
||||||
|
.get(userId, credentialType) as { credential_value: string } | undefined;
|
||||||
|
return row?.credential_value ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Permanently removes a credential. Returns true if a row was deleted. */
|
||||||
|
deleteCredential(userId: number, credentialId: number): boolean {
|
||||||
|
const db = getConnection();
|
||||||
|
const result = db
|
||||||
|
.prepare('DELETE FROM user_credentials WHERE id = ? AND user_id = ?')
|
||||||
|
.run(credentialId, userId);
|
||||||
|
return result.changes > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Enables or disables a credential without deleting it. */
|
||||||
|
toggleCredential(
|
||||||
|
userId: number,
|
||||||
|
credentialId: number,
|
||||||
|
isActive: boolean
|
||||||
|
): boolean {
|
||||||
|
const db = getConnection();
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE user_credentials SET is_active = ? WHERE id = ? AND user_id = ?'
|
||||||
|
)
|
||||||
|
.run(isActive ? 1 : 0, credentialId, userId);
|
||||||
|
return result.changes > 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
100
server/modules/database/repositories/github-tokens.ts
Normal file
100
server/modules/database/repositories/github-tokens.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* GitHub tokens repository.
|
||||||
|
*
|
||||||
|
* Backward-compatible helper layer over generic credentials storage.
|
||||||
|
* Tokens are stored in `user_credentials` with `credential_type = 'github_token'`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getConnection } from '@/modules/database/connection.js';
|
||||||
|
import { credentialsDb } from '@/modules/database/repositories/credentials.js';
|
||||||
|
import type {
|
||||||
|
CredentialPublicRow,
|
||||||
|
CreateCredentialResult,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
|
||||||
|
const GITHUB_TOKEN_TYPE = 'github_token';
|
||||||
|
|
||||||
|
type CredentialRow = {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
credential_name: string;
|
||||||
|
credential_type: string;
|
||||||
|
credential_value: string;
|
||||||
|
description: string | null;
|
||||||
|
created_at: string;
|
||||||
|
is_active: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GithubTokenLookup = CredentialRow & {
|
||||||
|
github_token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const githubTokensDb = {
|
||||||
|
/** Creates a GitHub token credential entry. */
|
||||||
|
createGithubToken(
|
||||||
|
userId: number,
|
||||||
|
tokenName: string,
|
||||||
|
githubToken: string,
|
||||||
|
description: string | null = null
|
||||||
|
): CreateCredentialResult {
|
||||||
|
return credentialsDb.createCredential(
|
||||||
|
userId,
|
||||||
|
tokenName,
|
||||||
|
GITHUB_TOKEN_TYPE,
|
||||||
|
githubToken,
|
||||||
|
description
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Returns all GitHub tokens (safe shape: no credential value). */
|
||||||
|
getGithubTokens(userId: number): CredentialPublicRow[] {
|
||||||
|
return credentialsDb.getCredentials(userId, GITHUB_TOKEN_TYPE);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Returns the most recent active GitHub token value for a user. */
|
||||||
|
getActiveGithubToken(userId: number): string | null {
|
||||||
|
return credentialsDb.getActiveCredential(userId, GITHUB_TOKEN_TYPE);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a specific active GitHub token row by id/user, including
|
||||||
|
* a `github_token` compatibility field.
|
||||||
|
*/
|
||||||
|
getGithubTokenById(userId: number, tokenId: number): GithubTokenLookup | null {
|
||||||
|
const db = getConnection();
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT *
|
||||||
|
FROM user_credentials
|
||||||
|
WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1`
|
||||||
|
)
|
||||||
|
.get(tokenId, userId, GITHUB_TOKEN_TYPE) as CredentialRow | undefined;
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
github_token: row.credential_value,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Updates active state for a GitHub token. */
|
||||||
|
updateGithubToken(
|
||||||
|
userId: number,
|
||||||
|
tokenId: number,
|
||||||
|
isActive: boolean
|
||||||
|
): boolean {
|
||||||
|
return credentialsDb.toggleCredential(userId, tokenId, isActive);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Deletes a GitHub token. */
|
||||||
|
deleteGithubToken(userId: number, tokenId: number): boolean {
|
||||||
|
return credentialsDb.deleteCredential(userId, tokenId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Legacy alias used by existing routes
|
||||||
|
toggleGithubToken(userId: number, tokenId: number, isActive: boolean): boolean {
|
||||||
|
return githubTokensDb.updateGithubToken(userId, tokenId, isActive);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
106
server/modules/database/repositories/notification-preferences.ts
Normal file
106
server/modules/database/repositories/notification-preferences.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Notification preferences repository.
|
||||||
|
*
|
||||||
|
* Stores per-user notification channel/event preferences as JSON.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getConnection } from '@/modules/database/connection.js';
|
||||||
|
|
||||||
|
type NotificationPreferences = {
|
||||||
|
channels: {
|
||||||
|
inApp: boolean;
|
||||||
|
webPush: boolean;
|
||||||
|
sound: boolean;
|
||||||
|
};
|
||||||
|
events: {
|
||||||
|
actionRequired: boolean;
|
||||||
|
stop: boolean;
|
||||||
|
error: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
||||||
|
channels: {
|
||||||
|
inApp: false,
|
||||||
|
webPush: false,
|
||||||
|
sound: true,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
actionRequired: true,
|
||||||
|
stop: true,
|
||||||
|
error: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeNotificationPreferences(value: unknown): NotificationPreferences {
|
||||||
|
const source = value && typeof value === 'object' ? (value as Record<string, any>) : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
channels: {
|
||||||
|
inApp: source.channels?.inApp === true,
|
||||||
|
webPush: source.channels?.webPush === true,
|
||||||
|
sound: source.channels?.sound !== false,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
actionRequired: source.events?.actionRequired !== false,
|
||||||
|
stop: source.events?.stop !== false,
|
||||||
|
error: source.events?.error !== false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationPreferencesDb = {
|
||||||
|
/** Returns the normalized preferences for a user, creating defaults on first read. */
|
||||||
|
getNotificationPreferences(userId: number): NotificationPreferences {
|
||||||
|
const db = getConnection();
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?'
|
||||||
|
)
|
||||||
|
.get(userId) as { preferences_json: string } | undefined;
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
|
||||||
|
).run(userId, JSON.stringify(defaults));
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(row.preferences_json);
|
||||||
|
} catch {
|
||||||
|
parsed = DEFAULT_NOTIFICATION_PREFERENCES;
|
||||||
|
}
|
||||||
|
return normalizeNotificationPreferences(parsed);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Upserts normalized preferences for a user and returns the stored value. */
|
||||||
|
updateNotificationPreferences(
|
||||||
|
userId: number,
|
||||||
|
preferences: unknown
|
||||||
|
): NotificationPreferences {
|
||||||
|
const normalized = normalizeNotificationPreferences(preferences);
|
||||||
|
const db = getConnection();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
preferences_json = excluded.preferences_json,
|
||||||
|
updated_at = CURRENT_TIMESTAMP`
|
||||||
|
).run(userId, JSON.stringify(normalized));
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Legacy aliases used by existing services/routes
|
||||||
|
getPreferences(userId: number): NotificationPreferences {
|
||||||
|
return notificationPreferencesDb.getNotificationPreferences(userId);
|
||||||
|
},
|
||||||
|
updatePreferences(userId: number, preferences: unknown): NotificationPreferences {
|
||||||
|
return notificationPreferencesDb.updateNotificationPreferences(userId, preferences);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { closeConnection } from '@/modules/database/connection.js';
|
||||||
|
import { initializeDatabase } from '@/modules/database/init-db.js';
|
||||||
|
import { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
||||||
|
|
||||||
|
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
||||||
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||||
|
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'projects-db-'));
|
||||||
|
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||||
|
|
||||||
|
closeConnection();
|
||||||
|
process.env.DATABASE_PATH = databasePath;
|
||||||
|
await initializeDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runTest();
|
||||||
|
} finally {
|
||||||
|
closeConnection();
|
||||||
|
if (previousDatabasePath === undefined) {
|
||||||
|
delete process.env.DATABASE_PATH;
|
||||||
|
} else {
|
||||||
|
process.env.DATABASE_PATH = previousDatabasePath;
|
||||||
|
}
|
||||||
|
await rm(tempDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('projectsDb.createProjectPath returns created for fresh paths', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
const created = projectsDb.createProjectPath('/workspace/new-project');
|
||||||
|
|
||||||
|
assert.equal(created.outcome, 'created');
|
||||||
|
assert.ok(created.project);
|
||||||
|
assert.equal(created.project?.project_path, '/workspace/new-project');
|
||||||
|
assert.equal(created.project?.isArchived, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('projectsDb.createProjectPath returns reactivated_archived for archived duplicates', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
const initial = projectsDb.createProjectPath('/workspace/archived-project', 'Archived Project');
|
||||||
|
assert.equal(initial.outcome, 'created');
|
||||||
|
assert.ok(initial.project);
|
||||||
|
|
||||||
|
projectsDb.updateProjectIsArchived('/workspace/archived-project', true);
|
||||||
|
|
||||||
|
const reused = projectsDb.createProjectPath('/workspace/archived-project', 'Renamed Project');
|
||||||
|
assert.equal(reused.outcome, 'reactivated_archived');
|
||||||
|
assert.ok(reused.project);
|
||||||
|
assert.equal(reused.project?.project_id, initial.project?.project_id);
|
||||||
|
assert.equal(reused.project?.isArchived, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('projectsDb.createProjectPath returns active_conflict for active duplicates', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
const initial = projectsDb.createProjectPath('/workspace/active-project');
|
||||||
|
assert.equal(initial.outcome, 'created');
|
||||||
|
assert.ok(initial.project);
|
||||||
|
|
||||||
|
const conflict = projectsDb.createProjectPath('/workspace/active-project');
|
||||||
|
assert.equal(conflict.outcome, 'active_conflict');
|
||||||
|
assert.ok(conflict.project);
|
||||||
|
assert.equal(conflict.project?.project_id, initial.project?.project_id);
|
||||||
|
assert.equal(conflict.project?.isArchived, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
196
server/modules/database/repositories/projects.db.ts
Normal file
196
server/modules/database/repositories/projects.db.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { getConnection } from '@/modules/database/connection.js';
|
||||||
|
import type { CreateProjectPathResult, ProjectRepositoryRow } from '@/shared/types.js';
|
||||||
|
import { normalizeProjectPath } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
function normalizeProjectDisplayName(projectPath: string, customProjectName: string | null): string {
|
||||||
|
const trimmedCustomName = typeof customProjectName === 'string' ? customProjectName.trim() : '';
|
||||||
|
if (trimmedCustomName.length > 0) {
|
||||||
|
return trimmedCustomName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directoryName = path.basename(projectPath);
|
||||||
|
return directoryName || projectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projectsDb = {
|
||||||
|
createProjectPath(projectPath: string, customProjectName: string | null = null): CreateProjectPathResult {
|
||||||
|
const db = getConnection();
|
||||||
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
|
const normalizedProjectName = normalizeProjectDisplayName(normalizedProjectPath, customProjectName);
|
||||||
|
const attemptedId = randomUUID();
|
||||||
|
const row = db.prepare(`
|
||||||
|
INSERT INTO projects (project_id, project_path, custom_project_name, isArchived)
|
||||||
|
VALUES (?, ?, ?, 0)
|
||||||
|
ON CONFLICT(project_path) DO UPDATE SET
|
||||||
|
isArchived = 0
|
||||||
|
WHERE projects.isArchived = 1
|
||||||
|
RETURNING project_id, project_path, custom_project_name, isStarred, isArchived
|
||||||
|
`).get(attemptedId, normalizedProjectPath, normalizedProjectName) as ProjectRepositoryRow | undefined;
|
||||||
|
|
||||||
|
if (row) {
|
||||||
|
return {
|
||||||
|
outcome: row.project_id === attemptedId ? 'created' : 'reactivated_archived',
|
||||||
|
project: row,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingProject = projectsDb.getProjectPath(normalizedProjectPath);
|
||||||
|
return {
|
||||||
|
outcome: 'active_conflict',
|
||||||
|
project: existingProject,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getProjectPath(projectPath: string): ProjectRepositoryRow | null {
|
||||||
|
const db = getConnection();
|
||||||
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
||||||
|
FROM projects
|
||||||
|
WHERE project_path = ?
|
||||||
|
`).get(normalizedProjectPath) as ProjectRepositoryRow | undefined;
|
||||||
|
|
||||||
|
return row ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getProjectById(projectId: string): ProjectRepositoryRow | null {
|
||||||
|
const db = getConnection();
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
||||||
|
FROM projects
|
||||||
|
WHERE project_id = ?
|
||||||
|
`).get(projectId) as ProjectRepositoryRow | undefined;
|
||||||
|
|
||||||
|
return row ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the absolute project directory from a database project_id.
|
||||||
|
*
|
||||||
|
* This is the canonical lookup used after the projectName → projectId migration:
|
||||||
|
* API routes receive the DB-assigned `projectId` and must resolve the real folder
|
||||||
|
* path through this helper before touching the filesystem. Returns `null` when the
|
||||||
|
* project row does not exist so callers can respond with a 404.
|
||||||
|
*/
|
||||||
|
getProjectPathById(projectId: string): string | null {
|
||||||
|
const db = getConnection();
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT project_path
|
||||||
|
FROM projects
|
||||||
|
WHERE project_id = ?
|
||||||
|
`).get(projectId) as Pick<ProjectRepositoryRow, 'project_path'> | undefined;
|
||||||
|
|
||||||
|
return row?.project_path ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getProjectPaths(): ProjectRepositoryRow[] {
|
||||||
|
const db = getConnection();
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
||||||
|
FROM projects
|
||||||
|
WHERE isArchived = 0
|
||||||
|
`).all() as ProjectRepositoryRow[];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archived rows are queried separately so archive-focused UIs can present
|
||||||
|
* hidden workspaces without reintroducing them into the active sidebar list.
|
||||||
|
*/
|
||||||
|
getArchivedProjectPaths(): ProjectRepositoryRow[] {
|
||||||
|
const db = getConnection();
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
||||||
|
FROM projects
|
||||||
|
WHERE isArchived = 1
|
||||||
|
`).all() as ProjectRepositoryRow[];
|
||||||
|
},
|
||||||
|
|
||||||
|
getCustomProjectName(projectPath: string): string | null {
|
||||||
|
const db = getConnection();
|
||||||
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT custom_project_name
|
||||||
|
FROM projects
|
||||||
|
WHERE project_path = ?
|
||||||
|
`).get(normalizedProjectPath) as Pick<ProjectRepositoryRow, 'custom_project_name'> | undefined;
|
||||||
|
|
||||||
|
return row?.custom_project_name ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCustomProjectName(projectPath: string, customProjectName: string | null): void {
|
||||||
|
const db = getConnection();
|
||||||
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO projects (project_id, project_path, custom_project_name)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(project_path) DO UPDATE SET custom_project_name = excluded.custom_project_name
|
||||||
|
`).run(randomUUID(), normalizedProjectPath, customProjectName);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCustomProjectNameById(projectId: string, customProjectName: string | null): void {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE projects
|
||||||
|
SET custom_project_name = ?
|
||||||
|
WHERE project_id = ?
|
||||||
|
`).run(customProjectName, projectId);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProjectIsStarred(projectPath: string, isStarred: boolean): void {
|
||||||
|
const db = getConnection();
|
||||||
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE projects
|
||||||
|
SET isStarred = ?
|
||||||
|
WHERE project_path = ?
|
||||||
|
`).run(isStarred ? 1 : 0, normalizedProjectPath);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProjectIsStarredById(projectId: string, isStarred: boolean): void {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE projects
|
||||||
|
SET isStarred = ?
|
||||||
|
WHERE project_id = ?
|
||||||
|
`).run(isStarred ? 1 : 0, projectId);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProjectIsArchived(projectPath: string, isArchived: boolean): void {
|
||||||
|
const db = getConnection();
|
||||||
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE projects
|
||||||
|
SET isArchived = ?
|
||||||
|
WHERE project_path = ?
|
||||||
|
`).run(isArchived ? 1 : 0, normalizedProjectPath);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProjectIsArchivedById(projectId: string, isArchived: boolean): void {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE projects
|
||||||
|
SET isArchived = ?
|
||||||
|
WHERE project_id = ?
|
||||||
|
`).run(isArchived ? 1 : 0, projectId);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteProjectPath(projectPath: string): void {
|
||||||
|
const db = getConnection();
|
||||||
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
|
db.prepare(`
|
||||||
|
DELETE FROM projects
|
||||||
|
WHERE project_path = ?
|
||||||
|
`).run(normalizedProjectPath);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteProjectById(projectId: string): void {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare(`
|
||||||
|
DELETE FROM projects
|
||||||
|
WHERE project_id = ?
|
||||||
|
`).run(projectId);
|
||||||
|
},
|
||||||
|
};
|
||||||
80
server/modules/database/repositories/push-subscriptions.ts
Normal file
80
server/modules/database/repositories/push-subscriptions.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Push subscriptions repository.
|
||||||
|
*
|
||||||
|
* Persists browser push subscription endpoints and keys per user.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getConnection } from '@/modules/database/connection.js';
|
||||||
|
|
||||||
|
type PushSubscriptionLookupRow = {
|
||||||
|
endpoint: string;
|
||||||
|
keys_p256dh: string;
|
||||||
|
keys_auth: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pushSubscriptionsDb = {
|
||||||
|
/** Upserts a push subscription endpoint for a user. */
|
||||||
|
createPushSubscription(
|
||||||
|
userId: number,
|
||||||
|
endpoint: string,
|
||||||
|
keysP256dh: string,
|
||||||
|
keysAuth: string
|
||||||
|
): void {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(endpoint) DO UPDATE SET
|
||||||
|
user_id = excluded.user_id,
|
||||||
|
keys_p256dh = excluded.keys_p256dh,
|
||||||
|
keys_auth = excluded.keys_auth`
|
||||||
|
).run(userId, endpoint, keysP256dh, keysAuth);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Returns all subscriptions for a user. */
|
||||||
|
getPushSubscriptions(userId: number): PushSubscriptionLookupRow[] {
|
||||||
|
const db = getConnection();
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
'SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?'
|
||||||
|
)
|
||||||
|
.all(userId) as PushSubscriptionLookupRow[];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Deletes one subscription by endpoint. */
|
||||||
|
deletePushSubscription(endpoint: string): void {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Deletes all subscriptions for a user. */
|
||||||
|
deletePushSubscriptionsForUser(userId: number): void {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Legacy aliases used by existing services/routes
|
||||||
|
saveSubscription(
|
||||||
|
userId: number,
|
||||||
|
endpoint: string,
|
||||||
|
keysP256dh: string,
|
||||||
|
keysAuth: string
|
||||||
|
): void {
|
||||||
|
pushSubscriptionsDb.createPushSubscription(
|
||||||
|
userId,
|
||||||
|
endpoint,
|
||||||
|
keysP256dh,
|
||||||
|
keysAuth
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getSubscriptions(userId: number): PushSubscriptionLookupRow[] {
|
||||||
|
return pushSubscriptionsDb.getPushSubscriptions(userId);
|
||||||
|
},
|
||||||
|
removeSubscription(endpoint: string): void {
|
||||||
|
pushSubscriptionsDb.deletePushSubscription(endpoint);
|
||||||
|
},
|
||||||
|
removeAllForUser(userId: number): void {
|
||||||
|
pushSubscriptionsDb.deletePushSubscriptionsForUser(userId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
42
server/modules/database/repositories/scan-state.db.ts
Normal file
42
server/modules/database/repositories/scan-state.db.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { getConnection } from '@/modules/database/connection.js';
|
||||||
|
|
||||||
|
type ScanStateRow = {
|
||||||
|
last_scanned_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const scanStateDb = {
|
||||||
|
getLastScannedAt() {
|
||||||
|
const db = getConnection();
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.prepare(`SELECT last_scanned_at FROM scan_state WHERE id = 1`)
|
||||||
|
.get() as ScanStateRow;
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return null; // Before any scan, the row is undefined.
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastScannedDate: Date | null = null;
|
||||||
|
const lastScannedStr = row.last_scanned_at;
|
||||||
|
|
||||||
|
if (lastScannedStr) {
|
||||||
|
// SQLite CURRENT_TIMESTAMP returns UTC in "YYYY-MM-DD HH:MM:SS" format.
|
||||||
|
// Replace space with 'T' and append 'Z' to parse reliably in JS across all platforms.
|
||||||
|
lastScannedDate = new Date(lastScannedStr.replace(' ', 'T') + 'Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastScannedDate;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateLastScannedAt(scannedAt: Date = new Date()) {
|
||||||
|
const db = getConnection();
|
||||||
|
const sqliteTimestamp = scannedAt.toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO scan_state (id, last_scanned_at)
|
||||||
|
VALUES (1, ?)
|
||||||
|
ON CONFLICT (id)
|
||||||
|
DO UPDATE SET last_scanned_at = excluded.last_scanned_at
|
||||||
|
`).run(sqliteTimestamp);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { closeConnection } from '@/modules/database/connection.js';
|
||||||
|
import { initializeDatabase } from '@/modules/database/init-db.js';
|
||||||
|
import { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
|
||||||
|
|
||||||
|
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
||||||
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||||
|
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'sessions-db-'));
|
||||||
|
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||||
|
|
||||||
|
closeConnection();
|
||||||
|
process.env.DATABASE_PATH = databasePath;
|
||||||
|
await initializeDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runTest();
|
||||||
|
} finally {
|
||||||
|
closeConnection();
|
||||||
|
if (previousDatabasePath === undefined) {
|
||||||
|
delete process.env.DATABASE_PATH;
|
||||||
|
} else {
|
||||||
|
process.env.DATABASE_PATH = previousDatabasePath;
|
||||||
|
}
|
||||||
|
await rm(tempDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('session archive queries hide archived rows from active project views', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createSession('session-active', 'claude', '/workspace/demo-project', 'Active Session');
|
||||||
|
sessionsDb.createSession('session-archived', 'claude', '/workspace/demo-project', 'Archived Session');
|
||||||
|
sessionsDb.updateSessionIsArchived('session-archived', true);
|
||||||
|
|
||||||
|
const activeSessions = sessionsDb.getAllSessions();
|
||||||
|
const archivedSessions = sessionsDb.getArchivedSessions();
|
||||||
|
const activeProjectSessions = sessionsDb.getSessionsByProjectPath('/workspace/demo-project');
|
||||||
|
const allProjectSessions = sessionsDb.getSessionsByProjectPathIncludingArchived('/workspace/demo-project');
|
||||||
|
|
||||||
|
assert.deepEqual(activeSessions.map((session) => session.session_id), ['session-active']);
|
||||||
|
assert.deepEqual(archivedSessions.map((session) => session.session_id), ['session-archived']);
|
||||||
|
assert.deepEqual(activeProjectSessions.map((session) => session.session_id), ['session-active']);
|
||||||
|
assert.deepEqual(
|
||||||
|
allProjectSessions.map((session) => session.session_id).sort(),
|
||||||
|
['session-active', 'session-archived'],
|
||||||
|
);
|
||||||
|
assert.equal(sessionsDb.countSessionsByProjectPath('/workspace/demo-project'), 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createSession reactivates archived rows when the session becomes active again', async () => {
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
sessionsDb.createSession('session-reused', 'claude', '/workspace/demo-project', 'First Name');
|
||||||
|
sessionsDb.updateSessionIsArchived('session-reused', true);
|
||||||
|
|
||||||
|
sessionsDb.createSession('session-reused', 'claude', '/workspace/demo-project', 'Updated Name');
|
||||||
|
|
||||||
|
const activeSessions = sessionsDb.getAllSessions();
|
||||||
|
const archivedSessions = sessionsDb.getArchivedSessions();
|
||||||
|
const restoredSession = sessionsDb.getSessionById('session-reused');
|
||||||
|
|
||||||
|
assert.equal(activeSessions.length, 1);
|
||||||
|
assert.equal(activeSessions[0]?.session_id, 'session-reused');
|
||||||
|
assert.equal(activeSessions[0]?.custom_name, 'Updated Name');
|
||||||
|
assert.equal(archivedSessions.length, 0);
|
||||||
|
assert.equal(restoredSession?.isArchived, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
225
server/modules/database/repositories/sessions.db.ts
Normal file
225
server/modules/database/repositories/sessions.db.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { getConnection } from '@/modules/database/connection.js';
|
||||||
|
import { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
||||||
|
import { normalizeProjectPath } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type SessionRow = {
|
||||||
|
session_id: string;
|
||||||
|
provider: string;
|
||||||
|
project_path: string | null;
|
||||||
|
jsonl_path: string | null;
|
||||||
|
custom_name: string | null;
|
||||||
|
isArchived: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionMetadataLookupRow = Pick<
|
||||||
|
SessionRow,
|
||||||
|
'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'isArchived' | 'created_at' | 'updated_at'
|
||||||
|
>;
|
||||||
|
|
||||||
|
function normalizeTimestamp(value?: string): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProjectPathForProvider(provider: string, projectPath: string): string {
|
||||||
|
void provider;
|
||||||
|
return normalizeProjectPath(projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionsDb = {
|
||||||
|
createSession(
|
||||||
|
sessionId: string,
|
||||||
|
provider: string,
|
||||||
|
projectPath: string,
|
||||||
|
customName?: string,
|
||||||
|
createdAt?: string,
|
||||||
|
updatedAt?: string,
|
||||||
|
jsonlPath?: string | null
|
||||||
|
): string {
|
||||||
|
const db = getConnection();
|
||||||
|
const createdAtValue = normalizeTimestamp(createdAt);
|
||||||
|
const updatedAtValue = normalizeTimestamp(updatedAt);
|
||||||
|
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
|
||||||
|
|
||||||
|
// First, ensure the project path is recorded in the projects table,
|
||||||
|
// since it's a foreign key in the sessions table.
|
||||||
|
projectsDb.createProjectPath(normalizedProjectPath);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
|
||||||
|
ON CONFLICT(session_id) DO UPDATE SET
|
||||||
|
provider = excluded.provider,
|
||||||
|
updated_at = excluded.updated_at,
|
||||||
|
project_path = excluded.project_path,
|
||||||
|
jsonl_path = excluded.jsonl_path,
|
||||||
|
isArchived = 0,
|
||||||
|
custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
|
||||||
|
).run(
|
||||||
|
sessionId,
|
||||||
|
provider,
|
||||||
|
customName ?? null,
|
||||||
|
normalizedProjectPath,
|
||||||
|
jsonlPath ?? null,
|
||||||
|
createdAtValue,
|
||||||
|
updatedAtValue
|
||||||
|
);
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSessionCustomName(sessionId: string, customName: string): void {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE sessions
|
||||||
|
SET custom_name = ?
|
||||||
|
WHERE session_id = ?`
|
||||||
|
).run(customName, sessionId);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSessionById(sessionId: string): SessionMetadataLookupRow | null {
|
||||||
|
const db = getConnection();
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(sessionId) as SessionMetadataLookupRow | undefined;
|
||||||
|
|
||||||
|
return row ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAllSessions(): SessionRow[] {
|
||||||
|
const db = getConnection();
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE isArchived = 0`
|
||||||
|
)
|
||||||
|
.all() as SessionRow[];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archived rows are intentionally queried separately so the caller can render
|
||||||
|
* them in a dedicated view without reintroducing them into active session lists.
|
||||||
|
*/
|
||||||
|
getArchivedSessions(): SessionRow[] {
|
||||||
|
const db = getConnection();
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE isArchived = 1
|
||||||
|
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`
|
||||||
|
)
|
||||||
|
.all() as SessionRow[];
|
||||||
|
},
|
||||||
|
|
||||||
|
getSessionsByProjectPath(projectPath: string): SessionRow[] {
|
||||||
|
const db = getConnection();
|
||||||
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE project_path = ?
|
||||||
|
AND isArchived = 0`
|
||||||
|
)
|
||||||
|
.all(normalizedProjectPath) as SessionRow[];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanent project deletion must see every session row for the path,
|
||||||
|
* including archived ones, so their transcript files can be cleaned up.
|
||||||
|
*/
|
||||||
|
getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] {
|
||||||
|
const db = getConnection();
|
||||||
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE project_path = ?`
|
||||||
|
)
|
||||||
|
.all(normalizedProjectPath) as SessionRow[];
|
||||||
|
},
|
||||||
|
|
||||||
|
getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] {
|
||||||
|
const db = getConnection();
|
||||||
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE project_path = ?
|
||||||
|
AND isArchived = 0
|
||||||
|
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC
|
||||||
|
LIMIT ? OFFSET ?`
|
||||||
|
)
|
||||||
|
.all(normalizedProjectPath, limit, offset) as SessionRow[];
|
||||||
|
},
|
||||||
|
|
||||||
|
countSessionsByProjectPath(projectPath: string): number {
|
||||||
|
const db = getConnection();
|
||||||
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) AS count
|
||||||
|
FROM sessions
|
||||||
|
WHERE project_path = ?
|
||||||
|
AND isArchived = 0`
|
||||||
|
)
|
||||||
|
.get(normalizedProjectPath) as { count: number } | undefined;
|
||||||
|
|
||||||
|
return Number(row?.count ?? 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteSessionsByProjectPath(projectPath: string): void {
|
||||||
|
const db = getConnection();
|
||||||
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||||
|
db.prepare(`DELETE FROM sessions WHERE project_path = ?`).run(normalizedProjectPath);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSessionName(sessionId: string, provider: string): string | null {
|
||||||
|
const db = getConnection();
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT custom_name
|
||||||
|
FROM sessions
|
||||||
|
WHERE session_id = ? AND provider = ?`
|
||||||
|
)
|
||||||
|
.get(sessionId, provider) as { custom_name: string | null } | undefined;
|
||||||
|
|
||||||
|
return row?.custom_name ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete and restore both use the same flag update so callers keep the
|
||||||
|
* row, metadata, and file path intact while toggling visibility.
|
||||||
|
*/
|
||||||
|
updateSessionIsArchived(sessionId: string, isArchived: boolean): void {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE sessions
|
||||||
|
SET isArchived = ?
|
||||||
|
WHERE session_id = ?`
|
||||||
|
).run(isArchived ? 1 : 0, sessionId);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteSessionById(sessionId: string): boolean {
|
||||||
|
const db = getConnection();
|
||||||
|
return db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId).changes > 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
140
server/modules/database/repositories/users.ts
Normal file
140
server/modules/database/repositories/users.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* User repository.
|
||||||
|
*
|
||||||
|
* Provides typed CRUD operations for the `users` table.
|
||||||
|
* This is a single-user system, but the schema supports multiple
|
||||||
|
* users for forward compatibility.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getConnection } from '@/modules/database/connection.js';
|
||||||
|
|
||||||
|
type UserRow = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
created_at: string;
|
||||||
|
last_login: string | null;
|
||||||
|
is_active: number;
|
||||||
|
git_name: string | null;
|
||||||
|
git_email: string | null;
|
||||||
|
has_completed_onboarding: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserPublicRow = Pick<UserRow, 'id' | 'username' | 'created_at' | 'last_login'>;
|
||||||
|
|
||||||
|
type UserGitConfig = {
|
||||||
|
git_name: string | null;
|
||||||
|
git_email: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateUserResult = {
|
||||||
|
id: number | bigint;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Queries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const userDb = {
|
||||||
|
/** Returns true if at least one user exists in the database. */
|
||||||
|
hasUsers(): boolean {
|
||||||
|
const db = getConnection();
|
||||||
|
const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as {
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
return row.count > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Inserts a new user and returns the created ID + username. */
|
||||||
|
createUser(username: string, passwordHash: string): CreateUserResult {
|
||||||
|
const db = getConnection();
|
||||||
|
const result = db
|
||||||
|
.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
|
||||||
|
.run(username, passwordHash);
|
||||||
|
return { id: result.lastInsertRowid, username };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up an active user by username.
|
||||||
|
* Returns the full row (including password hash) for auth verification.
|
||||||
|
*/
|
||||||
|
getUserByUsername(username: string): UserRow | undefined {
|
||||||
|
const db = getConnection();
|
||||||
|
return db
|
||||||
|
.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1')
|
||||||
|
.get(username) as UserRow | undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Updates the last_login timestamp. Non-fatal — logs but does not throw. */
|
||||||
|
updateLastLogin(userId: number): void {
|
||||||
|
try {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?'
|
||||||
|
).run(userId);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error('Failed to update last login', { error: message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Returns public user fields by ID (no password hash). */
|
||||||
|
getUserById(userId: number): UserPublicRow | undefined {
|
||||||
|
const db = getConnection();
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1'
|
||||||
|
)
|
||||||
|
.get(userId) as UserPublicRow | undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Returns the first active user. Used for single-user mode lookups. */
|
||||||
|
getFirstUser(): UserPublicRow | undefined {
|
||||||
|
const db = getConnection();
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1'
|
||||||
|
)
|
||||||
|
.get() as UserPublicRow | undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Stores the user's preferred git name and email. */
|
||||||
|
updateGitConfig(
|
||||||
|
userId: number,
|
||||||
|
gitName: string,
|
||||||
|
gitEmail: string
|
||||||
|
): void {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare('UPDATE users SET git_name = ?, git_email = ? WHERE id = ?').run(
|
||||||
|
gitName,
|
||||||
|
gitEmail,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Retrieves the user's git identity (name + email). */
|
||||||
|
getGitConfig(userId: number): UserGitConfig | undefined {
|
||||||
|
const db = getConnection();
|
||||||
|
return db
|
||||||
|
.prepare('SELECT git_name, git_email FROM users WHERE id = ?')
|
||||||
|
.get(userId) as UserGitConfig | undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Marks onboarding as complete for the given user. */
|
||||||
|
completeOnboarding(userId: number): void {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE users SET has_completed_onboarding = 1 WHERE id = ?'
|
||||||
|
).run(userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Returns true if the user has finished the onboarding flow. */
|
||||||
|
hasCompletedOnboarding(userId: number): boolean {
|
||||||
|
const db = getConnection();
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT has_completed_onboarding FROM users WHERE id = ?')
|
||||||
|
.get(userId) as { has_completed_onboarding: number } | undefined;
|
||||||
|
return row?.has_completed_onboarding === 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
57
server/modules/database/repositories/vapid-keys.ts
Normal file
57
server/modules/database/repositories/vapid-keys.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* VAPID keys repository.
|
||||||
|
*
|
||||||
|
* Stores and retrieves the Web Push VAPID key pair.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getConnection } from '@/modules/database/connection.js';
|
||||||
|
|
||||||
|
type VapidKeyRow = {
|
||||||
|
public_key: string;
|
||||||
|
private_key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VapidKeyPair = {
|
||||||
|
publicKey: string;
|
||||||
|
privateKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const vapidKeysDb = {
|
||||||
|
/** Returns the latest stored VAPID key pair, or null when unset. */
|
||||||
|
getVapidKeys(): VapidKeyPair | null {
|
||||||
|
const db = getConnection();
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1'
|
||||||
|
)
|
||||||
|
.get() as Pick<VapidKeyRow, 'public_key' | 'private_key'> | undefined;
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
publicKey: row.public_key,
|
||||||
|
privateKey: row.private_key,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Persists a new VAPID key pair. */
|
||||||
|
createVapidKeys(publicKey: string, privateKey: string): void {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)'
|
||||||
|
).run(publicKey, privateKey);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Replaces all existing keys with a fresh pair. */
|
||||||
|
updateVapidKeys(publicKey: string, privateKey: string): void {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare('DELETE FROM vapid_keys').run();
|
||||||
|
vapidKeysDb.createVapidKeys(publicKey, privateKey);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Deletes all VAPID key rows. */
|
||||||
|
deleteVapidKeys(): void {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare('DELETE FROM vapid_keys').run();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
153
server/modules/database/schema.ts
Normal file
153
server/modules/database/schema.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
const USER_TABLE_SCHEMA_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login DATETIME,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
git_name TEXT,
|
||||||
|
git_email TEXT,
|
||||||
|
has_completed_onboarding BOOLEAN DEFAULT 0
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const API_KEYS_TABLE_SCHEMA_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
key_name TEXT NOT NULL,
|
||||||
|
api_key TEXT UNIQUE NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used DATETIME,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const USER_CREDENTIALS_TABLE_SCHEMA_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS user_credentials (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
credential_name TEXT NOT NULL,
|
||||||
|
credential_type TEXT NOT NULL, -- 'github_token', 'gitlab_token', 'bitbucket_token', etc.
|
||||||
|
credential_value TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
preferences_json TEXT NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const VAPID_KEYS_TABLE_SCHEMA_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS vapid_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
private_key TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL UNIQUE,
|
||||||
|
keys_p256dh TEXT NOT NULL,
|
||||||
|
keys_auth TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PROJECTS_TABLE_SCHEMA_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
project_id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
project_path TEXT NOT NULL UNIQUE,
|
||||||
|
custom_project_name TEXT DEFAULT NULL,
|
||||||
|
isStarred BOOLEAN DEFAULT 0,
|
||||||
|
isArchived BOOLEAN DEFAULT 0
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SESSIONS_TABLE_SCHEMA_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL DEFAULT 'claude',
|
||||||
|
custom_name TEXT,
|
||||||
|
project_path TEXT,
|
||||||
|
jsonl_path TEXT,
|
||||||
|
isArchived BOOLEAN DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (session_id),
|
||||||
|
FOREIGN KEY (project_path) REFERENCES projects(project_path)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LAST_SCANNED_AT_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS scan_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
last_scanned_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const APP_CONFIG_TABLE_SCHEMA_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS app_config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const INIT_SCHEMA_SQL = `
|
||||||
|
-- Initialize authentication database
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
${USER_TABLE_SCHEMA_SQL}
|
||||||
|
-- Indexes for performance for user lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
|
||||||
|
|
||||||
|
${API_KEYS_TABLE_SCHEMA_SQL}
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
|
||||||
|
|
||||||
|
${USER_CREDENTIALS_TABLE_SCHEMA_SQL}
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
|
||||||
|
|
||||||
|
${USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL}
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_notification_preferences_user_id ON user_notification_preferences(user_id);
|
||||||
|
|
||||||
|
${VAPID_KEYS_TABLE_SCHEMA_SQL}
|
||||||
|
|
||||||
|
${PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL}
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);
|
||||||
|
|
||||||
|
${PROJECTS_TABLE_SCHEMA_SQL}
|
||||||
|
-- NOTE: These indexes are created in migrations after legacy table-shape repairs.
|
||||||
|
-- Creating them here can fail on upgraded installs where projects lacks those columns.
|
||||||
|
|
||||||
|
${SESSIONS_TABLE_SCHEMA_SQL}
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id);
|
||||||
|
-- NOTE: This index is created in migrations after sessions is rebuilt to include project_path.
|
||||||
|
-- Creating it here can fail on upgraded installs where the legacy sessions table has no project_path.
|
||||||
|
|
||||||
|
${LAST_SCANNED_AT_SQL}
|
||||||
|
|
||||||
|
${APP_CONFIG_TABLE_SCHEMA_SQL}
|
||||||
|
`;
|
||||||
6
server/modules/projects/index.ts
Normal file
6
server/modules/projects/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
generateDisplayName,
|
||||||
|
getProjectsWithSessions,
|
||||||
|
} from './services/projects-with-sessions-fetch.service.js';
|
||||||
|
export { updateProjectDisplayName } from './services/project-management.service.js';
|
||||||
|
export { deleteOrArchiveProject, deleteSessionJsonlFilesForProjectPath } from './services/project-delete.service.js';
|
||||||
273
server/modules/projects/projects.routes.ts
Normal file
273
server/modules/projects/projects.routes.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
import { createProject, updateProjectDisplayName } from '@/modules/projects/services/project-management.service.js';
|
||||||
|
import { startCloneProject } from '@/modules/projects/services/project-clone.service.js';
|
||||||
|
import { getProjectTaskMaster } from '@/modules/projects/services/projects-has-taskmaster.service.js';
|
||||||
|
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
|
||||||
|
import { getArchivedProjectsWithSessions, getProjectSessionsPage, getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.js';
|
||||||
|
import { deleteOrArchiveProject, restoreArchivedProject } from '@/modules/projects/services/project-delete.service.js';
|
||||||
|
import { applyLegacyStarredProjectIds, toggleProjectStar } from '@/modules/projects/services/project-star.service.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
type AuthenticatedUser = {
|
||||||
|
id?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readQueryStringValue(value: unknown): string {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value) && typeof value[0] === 'string') {
|
||||||
|
return value[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalNumericQueryValue(value: unknown): number | null {
|
||||||
|
const rawValue = readQueryStringValue(value).trim();
|
||||||
|
if (!rawValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedValue = Number.parseInt(rawValue, 10);
|
||||||
|
return Number.isNaN(parsedValue) ? null : parsedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNonNegativeIntQuery(value: unknown, name: string, fallback: number): number {
|
||||||
|
const rawValue = readQueryStringValue(value).trim();
|
||||||
|
if (!rawValue) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedValue = Number.parseInt(rawValue, 10);
|
||||||
|
if (Number.isNaN(parsedValue) || parsedValue < 0) {
|
||||||
|
throw new AppError(`${name} must be a non-negative integer`, {
|
||||||
|
code: 'INVALID_QUERY_PARAMETER',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRouteErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Failed to clone repository';
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const skipSynchronization =
|
||||||
|
readQueryStringValue(req.query.skipSynchronization).trim() === '1' ||
|
||||||
|
readQueryStringValue(req.query.skipSync).trim() === '1';
|
||||||
|
const sessionsLimit = readOptionalNumericQueryValue(req.query.sessionsLimit) ?? undefined;
|
||||||
|
const sessionsOffset = readOptionalNumericQueryValue(req.query.sessionsOffset) ?? undefined;
|
||||||
|
const projects = await getProjectsWithSessions({
|
||||||
|
skipSynchronization,
|
||||||
|
sessionsLimit,
|
||||||
|
sessionsOffset,
|
||||||
|
});
|
||||||
|
res.json(projects);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/archived',
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
const projects = await getArchivedProjectsWithSessions();
|
||||||
|
res.json(createApiSuccessResponse({ projects }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:projectId/sessions',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
|
||||||
|
const limit = parseNonNegativeIntQuery(req.query.limit, 'limit', 20);
|
||||||
|
const offset = parseNonNegativeIntQuery(req.query.offset, 'offset', 0);
|
||||||
|
const sessionsPage = await getProjectSessionsPage(projectId, { limit, offset });
|
||||||
|
res.json(sessionsPage);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/create-project',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const requestBody = req.body as Record<string, unknown>;
|
||||||
|
const projectPath = typeof requestBody.path === 'string' ? requestBody.path : '';
|
||||||
|
const customName = typeof requestBody.customName === 'string' ? requestBody.customName : null;
|
||||||
|
|
||||||
|
if (requestBody.workspaceType !== undefined) {
|
||||||
|
throw new AppError('workspaceType is no longer supported. Use the single create-project flow.', {
|
||||||
|
code: 'LEGACY_WORKSPACE_TYPE_UNSUPPORTED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestBody.githubUrl || requestBody.githubTokenId || requestBody.newGithubToken) {
|
||||||
|
throw new AppError('Repository cloning is not supported on create-project', {
|
||||||
|
code: 'CLONE_NOT_SUPPORTED_ON_CREATE_PROJECT',
|
||||||
|
statusCode: 400,
|
||||||
|
details: 'Use /api/projects/clone-progress for cloning workflows',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectCreationResult = await createProject({
|
||||||
|
projectPath,
|
||||||
|
customName,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
project: projectCreationResult.project,
|
||||||
|
message:
|
||||||
|
projectCreationResult.outcome === 'reactivated_archived'
|
||||||
|
? 'Archived project path reused successfully'
|
||||||
|
: 'Project created successfully',
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time (or idempotent) migration: apply legacy `localStorage` starred projectIds to the DB, then clear client storage.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/migrate-legacy-stars',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const projectIds = Array.isArray((req.body as { projectIds?: unknown })?.projectIds)
|
||||||
|
? ((req.body as { projectIds: unknown[] }).projectIds as unknown[]).map((x) => String(x))
|
||||||
|
: [];
|
||||||
|
const { updated } = applyLegacyStarredProjectIds(projectIds);
|
||||||
|
res.json({ success: true, updated });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get('/clone-progress', async (req, res) => {
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.flushHeaders();
|
||||||
|
|
||||||
|
const sendEvent = (type: string, data: Record<string, unknown>) => {
|
||||||
|
if (res.writableEnded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
let cloneOperation: Awaited<ReturnType<typeof startCloneProject>> | null = null;
|
||||||
|
const closeListener = () => {
|
||||||
|
cloneOperation?.cancel();
|
||||||
|
};
|
||||||
|
req.on('close', closeListener);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queryParams = req.query as Record<string, unknown>;
|
||||||
|
const workspacePath = readQueryStringValue(queryParams.path);
|
||||||
|
const githubUrl = readQueryStringValue(queryParams.githubUrl);
|
||||||
|
const githubTokenId = readOptionalNumericQueryValue(queryParams.githubTokenId);
|
||||||
|
const newGithubToken = readQueryStringValue(queryParams.newGithubToken) || null;
|
||||||
|
|
||||||
|
const authenticatedUser = (req as typeof req & { user?: AuthenticatedUser }).user;
|
||||||
|
const userId = authenticatedUser?.id;
|
||||||
|
if (userId === undefined || userId === null) {
|
||||||
|
throw new AppError('Authenticated user is required', {
|
||||||
|
code: 'AUTHENTICATION_REQUIRED',
|
||||||
|
statusCode: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cloneOperation = await startCloneProject(
|
||||||
|
{
|
||||||
|
workspacePath,
|
||||||
|
githubUrl,
|
||||||
|
githubTokenId,
|
||||||
|
newGithubToken,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onProgress: (message) => {
|
||||||
|
sendEvent('progress', { message });
|
||||||
|
},
|
||||||
|
onComplete: ({ project, message }) => {
|
||||||
|
sendEvent('complete', { project, message });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await cloneOperation.waitForCompletion;
|
||||||
|
} catch (error) {
|
||||||
|
sendEvent('error', { message: resolveRouteErrorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
req.off('close', closeListener);
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:projectId/taskmaster',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
|
||||||
|
const taskMasterDetails = await getProjectTaskMaster(projectId);
|
||||||
|
res.json(taskMasterDetails);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/:projectId/rename', (req, res) => {
|
||||||
|
try {
|
||||||
|
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
|
||||||
|
const { displayName } = req.body as { displayName?: unknown };
|
||||||
|
updateProjectDisplayName(projectId, displayName);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to rename project' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:projectId/toggle-star',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
|
||||||
|
const { isStarred } = toggleProjectStar(projectId);
|
||||||
|
res.json({ success: true, isStarred });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:projectId/restore',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
|
||||||
|
restoreArchivedProject(projectId);
|
||||||
|
res.json(createApiSuccessResponse({ projectId, isArchived: false }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - `force` not set / false: archive project in DB only (`isArchived` = 1; hidden from active list).
|
||||||
|
* - `force=true`: remove DB row, delete session rows for that path, remove all `*.jsonl` under the Claude project dir.
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/:projectId',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
|
||||||
|
const force = req.query.force === 'true';
|
||||||
|
await deleteOrArchiveProject(projectId, force);
|
||||||
|
res.json({ success: true });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
321
server/modules/projects/services/project-clone.service.ts
Normal file
321
server/modules/projects/services/project-clone.service.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { access, mkdir, rm } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { githubTokensDb } from '@/modules/database/index.js';
|
||||||
|
import { createProject } from '@/modules/projects/services/project-management.service.js';
|
||||||
|
import type { WorkspacePathValidationResult } from '@/shared/types.js';
|
||||||
|
import { AppError, validateWorkspacePath } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type CloneProjectInput = {
|
||||||
|
workspacePath: string;
|
||||||
|
githubUrl: string;
|
||||||
|
githubTokenId?: number | null;
|
||||||
|
newGithubToken?: string | null;
|
||||||
|
userId: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CloneCompletePayload = {
|
||||||
|
project: Record<string, unknown>;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CloneProjectEventHandlers = {
|
||||||
|
onProgress: (message: string) => void;
|
||||||
|
onComplete: (payload: CloneCompletePayload) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitCloneProcess = {
|
||||||
|
stdout: NodeJS.ReadableStream | null;
|
||||||
|
stderr: NodeJS.ReadableStream | null;
|
||||||
|
on(event: 'close', listener: (code: number | null) => void): void;
|
||||||
|
on(event: 'error', listener: (error: NodeJS.ErrnoException) => void): void;
|
||||||
|
kill(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CloneProjectDependencies = {
|
||||||
|
validatePath: (requestedPath: string) => Promise<WorkspacePathValidationResult>;
|
||||||
|
ensureDirectory: (directoryPath: string) => Promise<void>;
|
||||||
|
pathExists: (targetPath: string) => Promise<boolean>;
|
||||||
|
removePath: (targetPath: string) => Promise<void>;
|
||||||
|
getGithubTokenById: (
|
||||||
|
tokenId: number,
|
||||||
|
userId: number,
|
||||||
|
) => Promise<{ github_token: string } | null>;
|
||||||
|
spawnGitClone: (cloneUrl: string, clonePath: string) => GitCloneProcess;
|
||||||
|
registerProject: (projectPath: string, customName: string) => Promise<{ project: Record<string, unknown> }>;
|
||||||
|
logError: (message: string, error: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CloneProjectOperation = {
|
||||||
|
waitForCompletion: Promise<void>;
|
||||||
|
cancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function defaultPathExists(targetPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(targetPath);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeGitError(message: string, token: string | null): string {
|
||||||
|
if (!message || !token) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapedToken = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
return message.replace(new RegExp(escapedToken, 'g'), '***');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCloneFailureMessage(lastError: string, sanitizedError: string): string {
|
||||||
|
if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) {
|
||||||
|
return 'Authentication failed. Please check your credentials.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastError.includes('Repository not found')) {
|
||||||
|
return 'Repository not found. Please check the URL and ensure you have access.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastError.includes('already exists')) {
|
||||||
|
return 'Directory already exists';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanitizedError) {
|
||||||
|
return sanitizedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Git clone failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unexpected error';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDependencies: CloneProjectDependencies = {
|
||||||
|
validatePath: validateWorkspacePath,
|
||||||
|
ensureDirectory: async (directoryPath: string): Promise<void> => {
|
||||||
|
await mkdir(directoryPath, { recursive: true });
|
||||||
|
},
|
||||||
|
pathExists: defaultPathExists,
|
||||||
|
removePath: async (targetPath: string): Promise<void> => {
|
||||||
|
await rm(targetPath, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
getGithubTokenById: async (
|
||||||
|
tokenId: number,
|
||||||
|
userId: number,
|
||||||
|
): Promise<{ github_token: string } | null> => {
|
||||||
|
const tokenRow = githubTokensDb.getGithubTokenById(userId, tokenId) as
|
||||||
|
| { github_token: string }
|
||||||
|
| null;
|
||||||
|
return tokenRow;
|
||||||
|
},
|
||||||
|
spawnGitClone: (cloneUrl: string, clonePath: string): GitCloneProcess =>
|
||||||
|
spawn('git', ['clone', '--progress', '--', cloneUrl, clonePath], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_TERMINAL_PROMPT: '0',
|
||||||
|
},
|
||||||
|
}) as unknown as GitCloneProcess,
|
||||||
|
registerProject: async (
|
||||||
|
projectPath: string,
|
||||||
|
customName: string,
|
||||||
|
): Promise<{ project: Record<string, unknown> }> =>
|
||||||
|
createProject({
|
||||||
|
projectPath,
|
||||||
|
customName,
|
||||||
|
}) as Promise<{ project: Record<string, unknown> }>,
|
||||||
|
logError: (message: string, error: unknown): void => {
|
||||||
|
console.error(message, error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function startCloneProject(
|
||||||
|
input: CloneProjectInput,
|
||||||
|
handlers: CloneProjectEventHandlers,
|
||||||
|
dependencies: CloneProjectDependencies = defaultDependencies,
|
||||||
|
): Promise<CloneProjectOperation> {
|
||||||
|
const normalizedWorkspacePath = input.workspacePath.trim();
|
||||||
|
const normalizedGithubUrl = input.githubUrl.trim();
|
||||||
|
|
||||||
|
if (!normalizedWorkspacePath) {
|
||||||
|
throw new AppError('workspacePath and githubUrl are required', {
|
||||||
|
code: 'WORKSPACE_PATH_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedGithubUrl) {
|
||||||
|
throw new AppError('workspacePath and githubUrl are required', {
|
||||||
|
code: 'GITHUB_URL_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedGithubUrl.startsWith('-')) {
|
||||||
|
throw new AppError('Invalid githubUrl', {
|
||||||
|
code: 'INVALID_GITHUB_URL',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathValidation = await dependencies.validatePath(normalizedWorkspacePath);
|
||||||
|
if (!pathValidation.valid || !pathValidation.resolvedPath) {
|
||||||
|
throw new AppError(pathValidation.error || 'Invalid workspace path', {
|
||||||
|
code: 'INVALID_PROJECT_PATH',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = pathValidation.resolvedPath;
|
||||||
|
await dependencies.ensureDirectory(absolutePath);
|
||||||
|
|
||||||
|
let githubToken: string | null = null;
|
||||||
|
if (typeof input.githubTokenId === 'number') {
|
||||||
|
const numericUserId =
|
||||||
|
typeof input.userId === 'number' ? input.userId : Number.parseInt(String(input.userId), 10);
|
||||||
|
if (Number.isNaN(numericUserId)) {
|
||||||
|
throw new AppError('Authenticated user is required', {
|
||||||
|
code: 'AUTHENTICATION_REQUIRED',
|
||||||
|
statusCode: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await dependencies.getGithubTokenById(input.githubTokenId, numericUserId);
|
||||||
|
if (!token) {
|
||||||
|
throw new AppError('GitHub token not found', {
|
||||||
|
code: 'GITHUB_TOKEN_NOT_FOUND',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
githubToken = token.github_token;
|
||||||
|
} else if (input.newGithubToken && input.newGithubToken.trim().length > 0) {
|
||||||
|
githubToken = input.newGithubToken.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedGithubUrl = normalizedGithubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
|
||||||
|
const repoName = sanitizedGithubUrl.split('/').pop() || 'repository';
|
||||||
|
const clonePath = path.join(absolutePath, repoName);
|
||||||
|
|
||||||
|
if (await dependencies.pathExists(clonePath)) {
|
||||||
|
throw new AppError(
|
||||||
|
`Directory "${repoName}" already exists. Please choose a different location or remove the existing directory.`,
|
||||||
|
{
|
||||||
|
code: 'CLONE_TARGET_ALREADY_EXISTS',
|
||||||
|
statusCode: 409,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cloneUrl = normalizedGithubUrl;
|
||||||
|
if (githubToken) {
|
||||||
|
try {
|
||||||
|
const url = new URL(normalizedGithubUrl);
|
||||||
|
url.username = githubToken;
|
||||||
|
url.password = '';
|
||||||
|
cloneUrl = url.toString();
|
||||||
|
} catch {
|
||||||
|
// SSH URLs cannot be represented by URL constructor and are used as-is.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.onProgress(`Cloning into '${repoName}'...`);
|
||||||
|
const gitProcess = dependencies.spawnGitClone(cloneUrl, clonePath);
|
||||||
|
let lastError = '';
|
||||||
|
|
||||||
|
gitProcess.stdout?.on('data', (data: Buffer | string) => {
|
||||||
|
const message = data.toString().trim();
|
||||||
|
if (message) {
|
||||||
|
handlers.onProgress(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gitProcess.stderr?.on('data', (data: Buffer | string) => {
|
||||||
|
const message = data.toString().trim();
|
||||||
|
lastError = message;
|
||||||
|
if (message) {
|
||||||
|
handlers.onProgress(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const waitForCompletion = new Promise<void>((resolve, reject) => {
|
||||||
|
gitProcess.on('close', async (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
try {
|
||||||
|
const createdProject = await dependencies.registerProject(clonePath, repoName);
|
||||||
|
handlers.onComplete({
|
||||||
|
project: createdProject.project,
|
||||||
|
message: 'Repository cloned successfully',
|
||||||
|
});
|
||||||
|
resolve();
|
||||||
|
} catch (error) {
|
||||||
|
reject(
|
||||||
|
new AppError(`Clone succeeded but failed to add project: ${resolveErrorMessage(error)}`, {
|
||||||
|
code: 'CLONE_PROJECT_REGISTRATION_FAILED',
|
||||||
|
statusCode: 500,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedError = sanitizeGitError(lastError, githubToken);
|
||||||
|
const errorMessage = resolveCloneFailureMessage(lastError, sanitizedError);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dependencies.removePath(clonePath);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
dependencies.logError('Failed to clean up after clone failure:', cleanupError);
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(
|
||||||
|
new AppError(errorMessage, {
|
||||||
|
code: 'GIT_CLONE_FAILED',
|
||||||
|
statusCode: 500,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
gitProcess.on('error', (error) => {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
reject(
|
||||||
|
new AppError('Git is not installed or not in PATH', {
|
||||||
|
code: 'GIT_NOT_FOUND',
|
||||||
|
statusCode: 500,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(
|
||||||
|
new AppError(error.message, {
|
||||||
|
code: 'GIT_EXECUTION_FAILED',
|
||||||
|
statusCode: 500,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
waitForCompletion,
|
||||||
|
cancel: () => {
|
||||||
|
gitProcess.kill();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
90
server/modules/projects/services/project-delete.service.ts
Normal file
90
server/modules/projects/services/project-delete.service.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import { AppError } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
function uniqueJsonlPathsFromSessions(
|
||||||
|
sessions: Array<{ jsonl_path: string | null }>,
|
||||||
|
): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
|
||||||
|
for (const row of sessions) {
|
||||||
|
const raw = row.jsonl_path?.trim();
|
||||||
|
if (!raw) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const absolute = path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(raw);
|
||||||
|
if (seen.has(absolute)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(absolute);
|
||||||
|
result.push(absolute);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkJsonlIfExists(filePath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
if (code === 'ENOENT') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn(`[project-delete] Failed to remove ${filePath}:`, (error as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads all session rows for the project path and removes each distinct `jsonl_path` file on disk.
|
||||||
|
*/
|
||||||
|
export async function deleteSessionJsonlFilesForProjectPath(projectPath: string): Promise<void> {
|
||||||
|
const sessions = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath);
|
||||||
|
const paths = uniqueJsonlPathsFromSessions(sessions);
|
||||||
|
|
||||||
|
for (const filePath of paths) {
|
||||||
|
await unlinkJsonlIfExists(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - **Soft delete** (`force` false): set `isArchived` on the `projects` row (hide from the active list; DB only).
|
||||||
|
* - **Force** (`force` true): for each session row for that `project_path`, delete the file at `jsonl_path`
|
||||||
|
* (when set), then remove session rows and the `projects` row.
|
||||||
|
*/
|
||||||
|
export async function deleteOrArchiveProject(projectId: string, force: boolean): Promise<void> {
|
||||||
|
const row = projectsDb.getProjectById(projectId);
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError(`Unknown projectId: ${projectId}`, {
|
||||||
|
code: 'PROJECT_NOT_FOUND',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force) {
|
||||||
|
projectsDb.updateProjectIsArchivedById(projectId, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteSessionJsonlFilesForProjectPath(row.project_path);
|
||||||
|
sessionsDb.deleteSessionsByProjectPath(row.project_path);
|
||||||
|
projectsDb.deleteProjectById(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores one archived project row back into the active project list.
|
||||||
|
*/
|
||||||
|
export function restoreArchivedProject(projectId: string): void {
|
||||||
|
const row = projectsDb.getProjectById(projectId);
|
||||||
|
if (!row) {
|
||||||
|
throw new AppError(`Unknown projectId: ${projectId}`, {
|
||||||
|
code: 'PROJECT_NOT_FOUND',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
projectsDb.updateProjectIsArchivedById(projectId, false);
|
||||||
|
}
|
||||||
152
server/modules/projects/services/project-management.service.ts
Normal file
152
server/modules/projects/services/project-management.service.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { projectsDb } from '@/modules/database/index.js';
|
||||||
|
import type {
|
||||||
|
CreateProjectPathResult,
|
||||||
|
ProjectRepositoryRow,
|
||||||
|
WorkspacePathValidationResult,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
import { AppError, normalizeProjectPath, validateWorkspacePath } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type CreateProjectInput = {
|
||||||
|
projectPath: string;
|
||||||
|
customName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateProjectDependencies = {
|
||||||
|
validatePath: (projectPath: string) => Promise<WorkspacePathValidationResult>;
|
||||||
|
ensureWorkspaceDirectory: (projectPath: string) => Promise<void>;
|
||||||
|
persistProjectPath: (projectPath: string, customName: string | null) => CreateProjectPathResult;
|
||||||
|
getProjectByPath: (projectPath: string) => ProjectRepositoryRow | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectApiView = {
|
||||||
|
projectId: string;
|
||||||
|
path: string;
|
||||||
|
fullPath: string;
|
||||||
|
displayName: string;
|
||||||
|
customName: string | null;
|
||||||
|
isArchived: boolean;
|
||||||
|
isStarred: boolean;
|
||||||
|
sessions: [];
|
||||||
|
cursorSessions: [];
|
||||||
|
codexSessions: [];
|
||||||
|
geminiSessions: [];
|
||||||
|
opencodeSessions: [];
|
||||||
|
sessionMeta: {
|
||||||
|
hasMore: false;
|
||||||
|
total: 0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateProjectServiceResult = {
|
||||||
|
outcome: 'created' | 'reactivated_archived';
|
||||||
|
project: ProjectApiView;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultDependencies: CreateProjectDependencies = {
|
||||||
|
validatePath: validateWorkspacePath,
|
||||||
|
ensureWorkspaceDirectory: async (projectPath: string): Promise<void> => {
|
||||||
|
await fs.mkdir(projectPath, { recursive: true });
|
||||||
|
const directoryStats = await fs.stat(projectPath);
|
||||||
|
if (!directoryStats.isDirectory()) {
|
||||||
|
throw new AppError('Path exists but is not a directory', {
|
||||||
|
code: 'PROJECT_PATH_NOT_DIRECTORY',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
persistProjectPath: (projectPath: string, customName: string | null): CreateProjectPathResult =>
|
||||||
|
projectsDb.createProjectPath(projectPath, customName),
|
||||||
|
getProjectByPath: (projectPath: string): ProjectRepositoryRow | null =>
|
||||||
|
projectsDb.getProjectPath(projectPath),
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveDisplayName(customName: string | null | undefined, projectPath: string): string {
|
||||||
|
const trimmedCustomName = typeof customName === 'string' ? customName.trim() : '';
|
||||||
|
if (trimmedCustomName.length > 0) {
|
||||||
|
return trimmedCustomName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.basename(projectPath) || projectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiView {
|
||||||
|
return {
|
||||||
|
projectId: projectRow.project_id,
|
||||||
|
path: projectRow.project_path,
|
||||||
|
fullPath: projectRow.project_path,
|
||||||
|
displayName: resolveDisplayName(projectRow.custom_project_name, projectRow.project_path),
|
||||||
|
customName: projectRow.custom_project_name,
|
||||||
|
isArchived: Boolean(projectRow.isArchived),
|
||||||
|
isStarred: Boolean(projectRow.isStarred),
|
||||||
|
sessions: [],
|
||||||
|
cursorSessions: [],
|
||||||
|
codexSessions: [],
|
||||||
|
geminiSessions: [],
|
||||||
|
opencodeSessions: [],
|
||||||
|
sessionMeta: {
|
||||||
|
hasMore: false,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProject(
|
||||||
|
input: CreateProjectInput,
|
||||||
|
dependencies: CreateProjectDependencies = defaultDependencies,
|
||||||
|
): Promise<CreateProjectServiceResult> {
|
||||||
|
const normalizedPath = normalizeProjectPath(input.projectPath || '');
|
||||||
|
if (!normalizedPath) {
|
||||||
|
throw new AppError('path is required', {
|
||||||
|
code: 'PROJECT_PATH_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathValidation = await dependencies.validatePath(normalizedPath);
|
||||||
|
if (!pathValidation.valid || !pathValidation.resolvedPath) {
|
||||||
|
throw new AppError('Invalid project path', {
|
||||||
|
code: 'INVALID_PROJECT_PATH',
|
||||||
|
statusCode: 400,
|
||||||
|
details: pathValidation.error ?? 'Path validation failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedProjectPath = normalizeProjectPath(pathValidation.resolvedPath);
|
||||||
|
await dependencies.ensureWorkspaceDirectory(resolvedProjectPath);
|
||||||
|
|
||||||
|
const normalizedCustomName = resolveDisplayName(input.customName ?? null, resolvedProjectPath);
|
||||||
|
const persistedProject = dependencies.persistProjectPath(resolvedProjectPath, normalizedCustomName);
|
||||||
|
|
||||||
|
if (persistedProject.outcome === 'active_conflict') {
|
||||||
|
throw new AppError('Project path already exists and is active', {
|
||||||
|
code: 'PROJECT_ALREADY_EXISTS',
|
||||||
|
statusCode: 409,
|
||||||
|
details: `Project path already exists: ${resolvedProjectPath}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectRow = persistedProject.project ?? dependencies.getProjectByPath(resolvedProjectPath);
|
||||||
|
if (!projectRow) {
|
||||||
|
throw new AppError('Failed to resolve project after creation', {
|
||||||
|
code: 'PROJECT_CREATE_FAILED',
|
||||||
|
statusCode: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archived rows intentionally remain archived when reused, as requested.
|
||||||
|
return {
|
||||||
|
outcome: persistedProject.outcome,
|
||||||
|
project: mapProjectRowToApiView(projectRow),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets `projects.custom_project_name` for the given `projectId` (or clears it when empty).
|
||||||
|
*/
|
||||||
|
export function updateProjectDisplayName(projectId: string, newDisplayName: unknown): void {
|
||||||
|
const trimmed = typeof newDisplayName === 'string' ? newDisplayName.trim() : '';
|
||||||
|
projectsDb.updateCustomProjectNameById(projectId, trimmed.length > 0 ? trimmed : null);
|
||||||
|
}
|
||||||
78
server/modules/projects/services/project-star.service.ts
Normal file
78
server/modules/projects/services/project-star.service.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { projectsDb } from '@/modules/database/index.js';
|
||||||
|
import { AppError } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type ToggleProjectStarResult = {
|
||||||
|
isStarred: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApplyLegacyStarredProjectIdsResult = {
|
||||||
|
updated: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeProjectId(projectId: string): string {
|
||||||
|
return projectId.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueProjectIds(projectIds: string[]): string[] {
|
||||||
|
const uniqueIds = new Set<string>();
|
||||||
|
for (const projectId of projectIds) {
|
||||||
|
const normalizedProjectId = normalizeProjectId(projectId);
|
||||||
|
if (!normalizedProjectId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
uniqueIds.add(normalizedProjectId);
|
||||||
|
}
|
||||||
|
return [...uniqueIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies legacy `localStorage` stars keyed by DB `projectId` onto `projects.isStarred`.
|
||||||
|
*
|
||||||
|
* The operation is idempotent: already-starred projects are ignored, unknown ids are skipped.
|
||||||
|
*/
|
||||||
|
export function applyLegacyStarredProjectIds(projectIds: string[]): ApplyLegacyStarredProjectIdsResult {
|
||||||
|
const normalizedProjectIds = uniqueProjectIds(projectIds);
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
for (const projectId of normalizedProjectIds) {
|
||||||
|
const project = projectsDb.getProjectById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Boolean(project.isStarred)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
projectsDb.updateProjectIsStarredById(projectId, true);
|
||||||
|
updated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flips `projects.isStarred` for one project and returns the new state.
|
||||||
|
*/
|
||||||
|
export function toggleProjectStar(projectId: string): ToggleProjectStarResult {
|
||||||
|
const normalizedProjectId = normalizeProjectId(projectId);
|
||||||
|
if (!normalizedProjectId) {
|
||||||
|
throw new AppError('projectId is required', {
|
||||||
|
code: 'PROJECT_ID_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = projectsDb.getProjectById(normalizedProjectId);
|
||||||
|
if (!project) {
|
||||||
|
throw new AppError('Project not found', {
|
||||||
|
code: 'PROJECT_NOT_FOUND',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStarredState = !Boolean(project.isStarred);
|
||||||
|
projectsDb.updateProjectIsStarredById(normalizedProjectId, nextStarredState);
|
||||||
|
|
||||||
|
return { isStarred: nextStarredState };
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { access, readFile, stat } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { projectsDb } from '@/modules/database/index.js';
|
||||||
|
import { AppError } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type TaskMasterTask = {
|
||||||
|
status?: string;
|
||||||
|
subtasks?: Array<{
|
||||||
|
status?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskMasterMetadata =
|
||||||
|
| {
|
||||||
|
taskCount: number;
|
||||||
|
subtaskCount: number;
|
||||||
|
completed: number;
|
||||||
|
pending: number;
|
||||||
|
inProgress: number;
|
||||||
|
review: number;
|
||||||
|
completionPercentage: number;
|
||||||
|
lastModified: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
|
||||||
|
type TaskMasterDetectionResult = {
|
||||||
|
hasTaskmaster: boolean;
|
||||||
|
hasEssentialFiles?: boolean;
|
||||||
|
files?: Record<string, boolean>;
|
||||||
|
metadata?: TaskMasterMetadata;
|
||||||
|
path?: string;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NormalizedTaskMasterInfo = {
|
||||||
|
hasTaskmaster: boolean;
|
||||||
|
hasEssentialFiles: boolean;
|
||||||
|
metadata: TaskMasterMetadata;
|
||||||
|
status: 'configured' | 'not-configured';
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetProjectTaskMasterByIdResult = {
|
||||||
|
projectId: string;
|
||||||
|
projectPath: string;
|
||||||
|
taskmaster: NormalizedTaskMasterInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetProjectTaskMasterDependencies = {
|
||||||
|
resolveProjectPathById: (projectId: string) => string | null;
|
||||||
|
detectTaskMasterFolder: (projectPath: string) => Promise<TaskMasterDetectionResult>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetProjectTaskMasterResolver = (projectId: string) => Promise<GetProjectTaskMasterByIdResult | null>;
|
||||||
|
|
||||||
|
function extractTasksFromJson(tasksData: unknown): TaskMasterTask[] {
|
||||||
|
if (!tasksData || typeof tasksData !== 'object') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyTasks = (tasksData as { tasks?: unknown }).tasks;
|
||||||
|
if (Array.isArray(legacyTasks)) {
|
||||||
|
return legacyTasks as TaskMasterTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const taggedTaskCollections: TaskMasterTask[] = [];
|
||||||
|
for (const tagValue of Object.values(tasksData)) {
|
||||||
|
if (!tagValue || typeof tagValue !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagTasks = (tagValue as { tasks?: unknown }).tasks;
|
||||||
|
if (Array.isArray(tagTasks)) {
|
||||||
|
taggedTaskCollections.push(...(tagTasks as TaskMasterTask[]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return taggedTaskCollections;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectTaskMasterFolder(projectPath: string): Promise<TaskMasterDetectionResult> {
|
||||||
|
try {
|
||||||
|
const taskMasterPath = path.join(projectPath, '.taskmaster');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const taskMasterStats = await stat(taskMasterPath);
|
||||||
|
if (!taskMasterStats.isDirectory()) {
|
||||||
|
return {
|
||||||
|
hasTaskmaster: false,
|
||||||
|
reason: '.taskmaster exists but is not a directory',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const fileError = error as NodeJS.ErrnoException;
|
||||||
|
if (fileError.code === 'ENOENT') {
|
||||||
|
return {
|
||||||
|
hasTaskmaster: false,
|
||||||
|
reason: '.taskmaster directory not found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw fileError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyFiles = ['tasks/tasks.json', 'config.json'];
|
||||||
|
const fileStatus: Record<string, boolean> = {};
|
||||||
|
let hasEssentialFiles = true;
|
||||||
|
|
||||||
|
for (const fileName of keyFiles) {
|
||||||
|
const absoluteFilePath = path.join(taskMasterPath, fileName);
|
||||||
|
try {
|
||||||
|
await access(absoluteFilePath);
|
||||||
|
fileStatus[fileName] = true;
|
||||||
|
} catch {
|
||||||
|
fileStatus[fileName] = false;
|
||||||
|
if (fileName === 'tasks/tasks.json') {
|
||||||
|
hasEssentialFiles = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let taskMetadata: TaskMasterMetadata = null;
|
||||||
|
if (fileStatus['tasks/tasks.json']) {
|
||||||
|
const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');
|
||||||
|
try {
|
||||||
|
const tasksContent = await readFile(tasksPath, 'utf8');
|
||||||
|
const parsedTasksJson = JSON.parse(tasksContent) as unknown;
|
||||||
|
const tasks = extractTasksFromJson(parsedTasksJson);
|
||||||
|
|
||||||
|
const stats = tasks.reduce(
|
||||||
|
(accumulator, currentTask) => {
|
||||||
|
accumulator.total += 1;
|
||||||
|
const normalizedTaskStatus = currentTask.status || 'pending';
|
||||||
|
accumulator.byStatus[normalizedTaskStatus] = (accumulator.byStatus[normalizedTaskStatus] || 0) + 1;
|
||||||
|
|
||||||
|
if (Array.isArray(currentTask.subtasks)) {
|
||||||
|
for (const subtask of currentTask.subtasks) {
|
||||||
|
accumulator.subtotalTasks += 1;
|
||||||
|
const normalizedSubtaskStatus = subtask.status || 'pending';
|
||||||
|
accumulator.subtaskByStatus[normalizedSubtaskStatus] =
|
||||||
|
(accumulator.subtaskByStatus[normalizedSubtaskStatus] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accumulator;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
total: 0,
|
||||||
|
subtotalTasks: 0,
|
||||||
|
byStatus: {} as Record<string, number>,
|
||||||
|
subtaskByStatus: {} as Record<string, number>,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const tasksStat = await stat(tasksPath);
|
||||||
|
taskMetadata = {
|
||||||
|
taskCount: stats.total,
|
||||||
|
subtaskCount: stats.subtotalTasks,
|
||||||
|
completed: stats.byStatus.done || 0,
|
||||||
|
pending: stats.byStatus.pending || 0,
|
||||||
|
inProgress: stats.byStatus['in-progress'] || 0,
|
||||||
|
review: stats.byStatus.review || 0,
|
||||||
|
completionPercentage: stats.total > 0 ? Math.round(((stats.byStatus.done || 0) / stats.total) * 100) : 0,
|
||||||
|
lastModified: tasksStat.mtime.toISOString(),
|
||||||
|
};
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn('Failed to parse tasks.json:', (parseError as Error).message);
|
||||||
|
taskMetadata = {
|
||||||
|
error: 'Failed to parse tasks.json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasTaskmaster: true,
|
||||||
|
hasEssentialFiles,
|
||||||
|
files: fileStatus,
|
||||||
|
metadata: taskMetadata,
|
||||||
|
path: taskMasterPath,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error detecting TaskMaster folder:', error);
|
||||||
|
return {
|
||||||
|
hasTaskmaster: false,
|
||||||
|
reason: `Error checking directory: ${(error as Error).message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTaskMasterInfo(taskMasterResult: TaskMasterDetectionResult | null = null): NormalizedTaskMasterInfo {
|
||||||
|
const hasTaskmaster = Boolean(taskMasterResult?.hasTaskmaster);
|
||||||
|
const hasEssentialFiles = Boolean(taskMasterResult?.hasEssentialFiles);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasTaskmaster,
|
||||||
|
hasEssentialFiles,
|
||||||
|
metadata: taskMasterResult?.metadata ?? null,
|
||||||
|
status: hasTaskmaster && hasEssentialFiles ? 'configured' : 'not-configured',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDependencies: GetProjectTaskMasterDependencies = {
|
||||||
|
resolveProjectPathById: (projectId: string): string | null => projectsDb.getProjectPathById(projectId),
|
||||||
|
detectTaskMasterFolder,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getProjectTaskMasterById(
|
||||||
|
projectId: string,
|
||||||
|
dependencies: GetProjectTaskMasterDependencies = defaultDependencies,
|
||||||
|
): Promise<GetProjectTaskMasterByIdResult | null> {
|
||||||
|
const projectPath = dependencies.resolveProjectPathById(projectId);
|
||||||
|
if (!projectPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskMasterResult = await dependencies.detectTaskMasterFolder(projectPath);
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
projectPath,
|
||||||
|
taskmaster: normalizeTaskMasterInfo(taskMasterResult),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjectTaskMaster(
|
||||||
|
projectId: string,
|
||||||
|
resolveById: GetProjectTaskMasterResolver = getProjectTaskMasterById,
|
||||||
|
): Promise<GetProjectTaskMasterByIdResult> {
|
||||||
|
const normalizedProjectId = projectId.trim();
|
||||||
|
if (!normalizedProjectId) {
|
||||||
|
throw new AppError('projectId is required', {
|
||||||
|
code: 'PROJECT_ID_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskMasterDetails = await resolveById(normalizedProjectId);
|
||||||
|
if (!taskMasterDetails) {
|
||||||
|
throw new AppError('Project not found', {
|
||||||
|
code: 'PROJECT_NOT_FOUND',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return taskMasterDetails;
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import { sessionSynchronizerService } from '@/modules/providers/index.js';
|
||||||
|
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
|
||||||
|
import type { RealtimeClientConnection } from '@/shared/types.js';
|
||||||
|
import { AppError } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type SessionSummary = {
|
||||||
|
id: string;
|
||||||
|
summary: string;
|
||||||
|
messageCount: number;
|
||||||
|
lastActivity: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode', SessionSummary[]>;
|
||||||
|
|
||||||
|
type SessionRepositoryRow = {
|
||||||
|
provider: string;
|
||||||
|
session_id: string;
|
||||||
|
custom_name?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectListItem = {
|
||||||
|
projectId: string;
|
||||||
|
path: string;
|
||||||
|
displayName: string;
|
||||||
|
fullPath: string;
|
||||||
|
isStarred: boolean;
|
||||||
|
sessions: SessionSummary[];
|
||||||
|
cursorSessions: SessionSummary[];
|
||||||
|
codexSessions: SessionSummary[];
|
||||||
|
geminiSessions: SessionSummary[];
|
||||||
|
opencodeSessions: SessionSummary[];
|
||||||
|
sessionMeta: {
|
||||||
|
hasMore: boolean;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArchivedProjectListItem = ProjectListItem & {
|
||||||
|
isArchived: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProgressUpdate = {
|
||||||
|
phase: 'loading' | 'complete';
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
currentProject?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetProjectsWithSessionsOptions = {
|
||||||
|
skipSynchronization?: boolean;
|
||||||
|
sessionsLimit?: number;
|
||||||
|
sessionsOffset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionPaginationOptions = {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectSessionsPageResult = {
|
||||||
|
sessionsByProvider: SessionsByProvider;
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectSessionsPageApiView = {
|
||||||
|
projectId: string;
|
||||||
|
sessions: SessionSummary[];
|
||||||
|
cursorSessions: SessionSummary[];
|
||||||
|
codexSessions: SessionSummary[];
|
||||||
|
geminiSessions: SessionSummary[];
|
||||||
|
opencodeSessions: SessionSummary[];
|
||||||
|
sessionMeta: {
|
||||||
|
hasMore: boolean;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PROJECT_SESSIONS_PAGE_SIZE = 20;
|
||||||
|
const MAX_PROJECT_SESSIONS_PAGE_SIZE = 200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate better display name from path.
|
||||||
|
*/
|
||||||
|
export async function generateDisplayName(projectName: string, actualProjectDir: string | null = null): Promise<string> {
|
||||||
|
// Use actual project directory if provided, otherwise decode from project name.
|
||||||
|
const projectPath = actualProjectDir || projectName.replace(/-/g, '/');
|
||||||
|
|
||||||
|
// Try to read package.json from the project path.
|
||||||
|
try {
|
||||||
|
const packageJsonPath = path.join(projectPath, 'package.json');
|
||||||
|
const packageData = await fs.readFile(packageJsonPath, 'utf8');
|
||||||
|
const packageJson = JSON.parse(packageData) as { name?: string };
|
||||||
|
|
||||||
|
// Return the name from package.json if it exists.
|
||||||
|
if (packageJson.name) {
|
||||||
|
return packageJson.name;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to path-based naming if package.json doesn't exist or can't be read.
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it starts with /, it's an absolute path.
|
||||||
|
if (projectPath.startsWith('/')) {
|
||||||
|
const parts = projectPath.split('/').filter(Boolean);
|
||||||
|
// Return only the last folder name.
|
||||||
|
return parts[parts.length - 1] || projectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSessionPagination(options: SessionPaginationOptions = {}): { limit: number; offset: number } {
|
||||||
|
const rawLimit = Number.isFinite(options.limit) ? Math.floor(Number(options.limit)) : DEFAULT_PROJECT_SESSIONS_PAGE_SIZE;
|
||||||
|
const rawOffset = Number.isFinite(options.offset) ? Math.floor(Number(options.offset)) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
limit: Math.min(Math.max(1, rawLimit), MAX_PROJECT_SESSIONS_PAGE_SIZE),
|
||||||
|
offset: Math.max(0, rawOffset),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary {
|
||||||
|
return {
|
||||||
|
id: row.session_id,
|
||||||
|
summary: row.custom_name || '',
|
||||||
|
messageCount: 0,
|
||||||
|
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByProvider {
|
||||||
|
const byProvider: SessionsByProvider = {
|
||||||
|
claude: [],
|
||||||
|
cursor: [],
|
||||||
|
codex: [],
|
||||||
|
gemini: [],
|
||||||
|
opencode: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const provider = row.provider as keyof SessionsByProvider;
|
||||||
|
const bucket = byProvider[provider];
|
||||||
|
if (!bucket) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket.push(mapSessionRowToSummary(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
return byProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProjectSessionsIncludingArchived(projectPath: string): ProjectSessionsPageResult {
|
||||||
|
const rows = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath) as SessionRepositoryRow[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionsByProvider: bucketSessionRowsByProvider(rows),
|
||||||
|
total: rows.length,
|
||||||
|
hasMore: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads one paginated project session slice from the DB and groups rows by provider.
|
||||||
|
*/
|
||||||
|
function readProjectSessionsPageByPath(
|
||||||
|
projectPath: string,
|
||||||
|
options: SessionPaginationOptions = {},
|
||||||
|
): ProjectSessionsPageResult {
|
||||||
|
const pagination = normalizeSessionPagination(options);
|
||||||
|
const rows = sessionsDb.getSessionsByProjectPathPage(
|
||||||
|
projectPath,
|
||||||
|
pagination.limit,
|
||||||
|
pagination.offset,
|
||||||
|
) as SessionRepositoryRow[];
|
||||||
|
const total = sessionsDb.countSessionsByProjectPath(projectPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionsByProvider: bucketSessionRowsByProvider(rows),
|
||||||
|
total,
|
||||||
|
hasMore: pagination.offset + rows.length < total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast progress to all connected WebSocket clients
|
||||||
|
function broadcastProgress(progress: ProgressUpdate) {
|
||||||
|
const message = JSON.stringify({
|
||||||
|
type: 'loading_progress',
|
||||||
|
...progress,
|
||||||
|
});
|
||||||
|
|
||||||
|
connectedClients.forEach((client: RealtimeClientConnection) => {
|
||||||
|
if (client.readyState === WS_OPEN_STATE) {
|
||||||
|
client.send(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads all projects from DB and returns provider-bucketed session summaries.
|
||||||
|
*/
|
||||||
|
export async function getProjectsWithSessions(
|
||||||
|
options: GetProjectsWithSessionsOptions = {}
|
||||||
|
): Promise<ProjectListItem[]> {
|
||||||
|
if (!options.skipSynchronization) {
|
||||||
|
await sessionSynchronizerService.synchronizeSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectRows = projectsDb.getProjectPaths() as Array<{
|
||||||
|
project_id: string;
|
||||||
|
project_path: string;
|
||||||
|
custom_project_name?: string | null;
|
||||||
|
isStarred?: number;
|
||||||
|
}>;
|
||||||
|
const totalProjects = projectRows.length;
|
||||||
|
const projects: ProjectListItem[] = [];
|
||||||
|
let processedProjects = 0;
|
||||||
|
|
||||||
|
for (const row of projectRows) {
|
||||||
|
processedProjects += 1;
|
||||||
|
|
||||||
|
const projectId = row.project_id;
|
||||||
|
const projectPath = row.project_path;
|
||||||
|
|
||||||
|
broadcastProgress({
|
||||||
|
phase: 'loading',
|
||||||
|
current: processedProjects,
|
||||||
|
total: totalProjects,
|
||||||
|
currentProject: projectPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
row.custom_project_name && row.custom_project_name.trim().length > 0
|
||||||
|
? row.custom_project_name
|
||||||
|
: await generateDisplayName(path.basename(projectPath) || projectPath, projectPath);
|
||||||
|
|
||||||
|
const sessionsPage = readProjectSessionsPageByPath(projectPath, {
|
||||||
|
limit: options.sessionsLimit,
|
||||||
|
offset: options.sessionsOffset,
|
||||||
|
});
|
||||||
|
|
||||||
|
projects.push({
|
||||||
|
projectId,
|
||||||
|
path: projectPath,
|
||||||
|
displayName,
|
||||||
|
fullPath: projectPath,
|
||||||
|
isStarred: Boolean(row.isStarred),
|
||||||
|
sessions: sessionsPage.sessionsByProvider.claude,
|
||||||
|
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||||
|
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||||
|
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||||
|
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||||
|
sessionMeta: {
|
||||||
|
hasMore: sessionsPage.hasMore,
|
||||||
|
total: sessionsPage.total,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastProgress({
|
||||||
|
phase: 'complete',
|
||||||
|
current: totalProjects,
|
||||||
|
total: totalProjects,
|
||||||
|
});
|
||||||
|
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads archived projects from DB and includes every session row for each
|
||||||
|
* project path, because an archived workspace should surface all preserved
|
||||||
|
* conversation history in the archive view regardless of each session's flag.
|
||||||
|
*/
|
||||||
|
export async function getArchivedProjectsWithSessions(
|
||||||
|
options: Pick<GetProjectsWithSessionsOptions, 'skipSynchronization'> = {},
|
||||||
|
): Promise<ArchivedProjectListItem[]> {
|
||||||
|
if (!options.skipSynchronization) {
|
||||||
|
await sessionSynchronizerService.synchronizeSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectRows = projectsDb.getArchivedProjectPaths() as Array<{
|
||||||
|
project_id: string;
|
||||||
|
project_path: string;
|
||||||
|
custom_project_name?: string | null;
|
||||||
|
isStarred?: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const archivedProjects: ArchivedProjectListItem[] = [];
|
||||||
|
|
||||||
|
for (const row of projectRows) {
|
||||||
|
const displayName =
|
||||||
|
row.custom_project_name && row.custom_project_name.trim().length > 0
|
||||||
|
? row.custom_project_name
|
||||||
|
: await generateDisplayName(path.basename(row.project_path) || row.project_path, row.project_path);
|
||||||
|
|
||||||
|
const sessionsPage = readProjectSessionsIncludingArchived(row.project_path);
|
||||||
|
|
||||||
|
archivedProjects.push({
|
||||||
|
projectId: row.project_id,
|
||||||
|
path: row.project_path,
|
||||||
|
displayName,
|
||||||
|
fullPath: row.project_path,
|
||||||
|
isStarred: Boolean(row.isStarred),
|
||||||
|
isArchived: true,
|
||||||
|
sessions: sessionsPage.sessionsByProvider.claude,
|
||||||
|
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||||
|
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||||
|
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||||
|
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||||
|
sessionMeta: {
|
||||||
|
hasMore: sessionsPage.hasMore,
|
||||||
|
total: sessionsPage.total,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return archivedProjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads one paginated session slice for a specific project id.
|
||||||
|
*/
|
||||||
|
export async function getProjectSessionsPage(
|
||||||
|
projectId: string,
|
||||||
|
options: SessionPaginationOptions = {},
|
||||||
|
): Promise<ProjectSessionsPageApiView> {
|
||||||
|
const projectRow = projectsDb.getProjectById(projectId);
|
||||||
|
if (!projectRow) {
|
||||||
|
throw new AppError(`Project "${projectId}" was not found.`, {
|
||||||
|
code: 'PROJECT_NOT_FOUND',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options);
|
||||||
|
return {
|
||||||
|
projectId: projectRow.project_id,
|
||||||
|
sessions: sessionsPage.sessionsByProvider.claude,
|
||||||
|
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||||
|
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||||
|
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||||
|
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||||
|
sessionMeta: {
|
||||||
|
hasMore: sessionsPage.hasMore,
|
||||||
|
total: sessionsPage.total,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
183
server/modules/projects/tests/project-clone.service.test.ts
Normal file
183
server/modules/projects/tests/project-clone.service.test.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { PassThrough } from 'node:stream';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { startCloneProject } from '@/modules/projects/services/project-clone.service.js';
|
||||||
|
import { AppError } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type TestDependencies = Parameters<typeof startCloneProject>[2];
|
||||||
|
|
||||||
|
function buildDependencies(overrides: Partial<NonNullable<TestDependencies>> = {}): NonNullable<TestDependencies> {
|
||||||
|
return {
|
||||||
|
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/root' }),
|
||||||
|
ensureDirectory: async () => undefined,
|
||||||
|
pathExists: async () => false,
|
||||||
|
removePath: async () => undefined,
|
||||||
|
getGithubTokenById: async () => ({ github_token: 'token-value' }),
|
||||||
|
spawnGitClone: () => {
|
||||||
|
throw new Error('spawnGitClone should be overridden in this test');
|
||||||
|
},
|
||||||
|
registerProject: async () => ({ project: { projectId: 'project-1' } }),
|
||||||
|
logError: () => undefined,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockGitProcess() {
|
||||||
|
const emitter = new EventEmitter() as EventEmitter & {
|
||||||
|
stdout: PassThrough;
|
||||||
|
stderr: PassThrough;
|
||||||
|
kill: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
emitter.stdout = new PassThrough();
|
||||||
|
emitter.stderr = new PassThrough();
|
||||||
|
emitter.kill = () => {
|
||||||
|
emitter.emit('close', null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('startCloneProject rejects when workspace path is missing', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
async () =>
|
||||||
|
startCloneProject(
|
||||||
|
{
|
||||||
|
workspacePath: '',
|
||||||
|
githubUrl: 'https://github.com/example/repo',
|
||||||
|
userId: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onProgress: () => undefined,
|
||||||
|
onComplete: () => undefined,
|
||||||
|
},
|
||||||
|
buildDependencies(),
|
||||||
|
),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof AppError);
|
||||||
|
assert.equal(error.code, 'WORKSPACE_PATH_REQUIRED');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startCloneProject rejects when github URL is missing', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
async () =>
|
||||||
|
startCloneProject(
|
||||||
|
{
|
||||||
|
workspacePath: '/workspace/root',
|
||||||
|
githubUrl: '',
|
||||||
|
userId: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onProgress: () => undefined,
|
||||||
|
onComplete: () => undefined,
|
||||||
|
},
|
||||||
|
buildDependencies(),
|
||||||
|
),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof AppError);
|
||||||
|
assert.equal(error.code, 'GITHUB_URL_REQUIRED');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startCloneProject rejects github URL values that begin with option prefixes', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
async () =>
|
||||||
|
startCloneProject(
|
||||||
|
{
|
||||||
|
workspacePath: '/workspace/root',
|
||||||
|
githubUrl: '--upload-pack=malicious',
|
||||||
|
userId: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onProgress: () => undefined,
|
||||||
|
onComplete: () => undefined,
|
||||||
|
},
|
||||||
|
buildDependencies(),
|
||||||
|
),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof AppError);
|
||||||
|
assert.equal(error.code, 'INVALID_GITHUB_URL');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startCloneProject rejects when selected github token does not exist', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
async () =>
|
||||||
|
startCloneProject(
|
||||||
|
{
|
||||||
|
workspacePath: '/workspace/root',
|
||||||
|
githubUrl: 'https://github.com/example/repo',
|
||||||
|
githubTokenId: 12,
|
||||||
|
userId: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onProgress: () => undefined,
|
||||||
|
onComplete: () => undefined,
|
||||||
|
},
|
||||||
|
buildDependencies({
|
||||||
|
getGithubTokenById: async () => null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof AppError);
|
||||||
|
assert.equal(error.code, 'GITHUB_TOKEN_NOT_FOUND');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startCloneProject completes and emits complete payload when git exits successfully', async () => {
|
||||||
|
const gitProcess = createMockGitProcess();
|
||||||
|
const progressMessages: string[] = [];
|
||||||
|
let completePayload: { project: Record<string, unknown>; message: string } | null = null;
|
||||||
|
let capturedProjectPath = '';
|
||||||
|
let capturedCustomName = '';
|
||||||
|
|
||||||
|
const operation = await startCloneProject(
|
||||||
|
{
|
||||||
|
workspacePath: '/workspace/root',
|
||||||
|
githubUrl: 'https://github.com/example/repo.git',
|
||||||
|
userId: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onProgress: (message) => {
|
||||||
|
progressMessages.push(message);
|
||||||
|
},
|
||||||
|
onComplete: (payload: { project: Record<string, unknown>; message: string }) => {
|
||||||
|
completePayload = payload;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
buildDependencies({
|
||||||
|
spawnGitClone: () => gitProcess as any,
|
||||||
|
registerProject: async (projectPath, customName) => {
|
||||||
|
capturedProjectPath = projectPath;
|
||||||
|
capturedCustomName = customName;
|
||||||
|
return { project: { projectId: 'project-1', path: projectPath } };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
gitProcess.emit('close', 0);
|
||||||
|
await operation.waitForCompletion;
|
||||||
|
|
||||||
|
assert.ok(progressMessages.some((message) => message.includes("Cloning into 'repo'")));
|
||||||
|
assert.equal(capturedCustomName, 'repo');
|
||||||
|
assert.equal(path.basename(capturedProjectPath), 'repo');
|
||||||
|
assert.notEqual(completePayload, null);
|
||||||
|
const resolvedCompletePayload = completePayload as unknown as {
|
||||||
|
project: Record<string, unknown>;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
assert.equal(resolvedCompletePayload.message, 'Repository cloned successfully');
|
||||||
|
assert.equal((resolvedCompletePayload.project.projectId as string) || '', 'project-1');
|
||||||
|
});
|
||||||
117
server/modules/projects/tests/project-management.service.test.ts
Normal file
117
server/modules/projects/tests/project-management.service.test.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createProject } from '@/modules/projects/services/project-management.service.js';
|
||||||
|
import { AppError } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
const projectRow = {
|
||||||
|
project_id: 'project-1',
|
||||||
|
project_path: '/workspace/my-project',
|
||||||
|
custom_project_name: 'my-project',
|
||||||
|
isStarred: 0,
|
||||||
|
isArchived: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('createProject throws when project path is missing', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
async () => createProject({ projectPath: '' }),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof AppError);
|
||||||
|
assert.equal(error.code, 'PROJECT_PATH_REQUIRED');
|
||||||
|
assert.equal(error.statusCode, 400);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createProject throws when path validation fails', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
async () =>
|
||||||
|
createProject(
|
||||||
|
{ projectPath: '/invalid/path' },
|
||||||
|
{
|
||||||
|
validatePath: async () => ({ valid: false, error: 'blocked path' }),
|
||||||
|
ensureWorkspaceDirectory: async () => undefined,
|
||||||
|
persistProjectPath: () => ({ outcome: 'created', project: projectRow }),
|
||||||
|
getProjectByPath: () => projectRow,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof AppError);
|
||||||
|
assert.equal(error.code, 'INVALID_PROJECT_PATH');
|
||||||
|
assert.equal(error.statusCode, 400);
|
||||||
|
assert.equal(error.details, 'blocked path');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createProject throws conflict when active project path already exists', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
async () =>
|
||||||
|
createProject(
|
||||||
|
{ projectPath: '/workspace/my-project' },
|
||||||
|
{
|
||||||
|
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/my-project' }),
|
||||||
|
ensureWorkspaceDirectory: async () => undefined,
|
||||||
|
persistProjectPath: () => ({ outcome: 'active_conflict', project: projectRow }),
|
||||||
|
getProjectByPath: () => projectRow,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof AppError);
|
||||||
|
assert.equal(error.code, 'PROJECT_ALREADY_EXISTS');
|
||||||
|
assert.equal(error.statusCode, 409);
|
||||||
|
assert.equal(error.details, 'Project path already exists: /workspace/my-project');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createProject falls back to directory name when custom name is not provided', async () => {
|
||||||
|
let capturedCustomName: string | null = null;
|
||||||
|
|
||||||
|
const result = await createProject(
|
||||||
|
{ projectPath: '/workspace/my-project', customName: '' },
|
||||||
|
{
|
||||||
|
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/my-project' }),
|
||||||
|
ensureWorkspaceDirectory: async () => undefined,
|
||||||
|
persistProjectPath: (_projectPath, customName) => {
|
||||||
|
capturedCustomName = customName;
|
||||||
|
return {
|
||||||
|
outcome: 'created',
|
||||||
|
project: {
|
||||||
|
...projectRow,
|
||||||
|
custom_project_name: customName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getProjectByPath: () => projectRow,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(capturedCustomName, 'my-project');
|
||||||
|
assert.equal(result.outcome, 'created');
|
||||||
|
assert.equal(result.project.displayName, 'my-project');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createProject returns archived reuse outcome when archived row is reused', async () => {
|
||||||
|
const result = await createProject(
|
||||||
|
{ projectPath: '/workspace/my-project' },
|
||||||
|
{
|
||||||
|
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/my-project' }),
|
||||||
|
ensureWorkspaceDirectory: async () => undefined,
|
||||||
|
persistProjectPath: () => ({
|
||||||
|
outcome: 'reactivated_archived',
|
||||||
|
project: {
|
||||||
|
...projectRow,
|
||||||
|
isArchived: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getProjectByPath: () => projectRow,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.outcome, 'reactivated_archived');
|
||||||
|
assert.equal(result.project.isArchived, true);
|
||||||
|
});
|
||||||
123
server/modules/projects/tests/project-star.service.test.ts
Normal file
123
server/modules/projects/tests/project-star.service.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { projectsDb } from '@/modules/database/index.js';
|
||||||
|
import { applyLegacyStarredProjectIds, toggleProjectStar } from '@/modules/projects/services/project-star.service.js';
|
||||||
|
import { AppError } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type ProjectRow = {
|
||||||
|
project_id: string;
|
||||||
|
project_path: string;
|
||||||
|
custom_project_name: string | null;
|
||||||
|
isStarred: number;
|
||||||
|
isArchived: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('toggleProjectStar throws when projectId is missing', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => toggleProjectStar(' '),
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof AppError
|
||||||
|
&& error.code === 'PROJECT_ID_REQUIRED'
|
||||||
|
&& error.statusCode === 400,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleProjectStar throws when project does not exist', () => {
|
||||||
|
const originalGetProjectById = projectsDb.getProjectById;
|
||||||
|
try {
|
||||||
|
projectsDb.getProjectById = () => null;
|
||||||
|
assert.throws(
|
||||||
|
() => toggleProjectStar('project-1'),
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof AppError
|
||||||
|
&& error.code === 'PROJECT_NOT_FOUND'
|
||||||
|
&& error.statusCode === 404,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
projectsDb.getProjectById = originalGetProjectById;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleProjectStar flips star state and persists it', () => {
|
||||||
|
const originalGetProjectById = projectsDb.getProjectById;
|
||||||
|
const originalUpdateProjectIsStarredById = projectsDb.updateProjectIsStarredById;
|
||||||
|
|
||||||
|
let capturedProjectId = '';
|
||||||
|
let capturedState = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
projectsDb.getProjectById = () =>
|
||||||
|
({
|
||||||
|
project_id: 'project-1',
|
||||||
|
project_path: '/workspace/project-1',
|
||||||
|
custom_project_name: 'project-1',
|
||||||
|
isStarred: 0,
|
||||||
|
isArchived: 0,
|
||||||
|
}) as ProjectRow;
|
||||||
|
projectsDb.updateProjectIsStarredById = (projectId: string, isStarred: boolean) => {
|
||||||
|
capturedProjectId = projectId;
|
||||||
|
capturedState = isStarred;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = toggleProjectStar('project-1');
|
||||||
|
|
||||||
|
assert.equal(result.isStarred, true);
|
||||||
|
assert.equal(capturedProjectId, 'project-1');
|
||||||
|
assert.equal(capturedState, true);
|
||||||
|
} finally {
|
||||||
|
projectsDb.getProjectById = originalGetProjectById;
|
||||||
|
projectsDb.updateProjectIsStarredById = originalUpdateProjectIsStarredById;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyLegacyStarredProjectIds stars only valid, unstarred projects', () => {
|
||||||
|
const originalGetProjectById = projectsDb.getProjectById;
|
||||||
|
const originalUpdateProjectIsStarredById = projectsDb.updateProjectIsStarredById;
|
||||||
|
|
||||||
|
const updatedProjectIds: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
projectsDb.getProjectById = (projectId: string) => {
|
||||||
|
if (projectId === 'project-a') {
|
||||||
|
return {
|
||||||
|
project_id: 'project-a',
|
||||||
|
project_path: '/workspace/project-a',
|
||||||
|
custom_project_name: 'A',
|
||||||
|
isStarred: 0,
|
||||||
|
isArchived: 0,
|
||||||
|
} as ProjectRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectId === 'project-b') {
|
||||||
|
return {
|
||||||
|
project_id: 'project-b',
|
||||||
|
project_path: '/workspace/project-b',
|
||||||
|
custom_project_name: 'B',
|
||||||
|
isStarred: 1,
|
||||||
|
isArchived: 0,
|
||||||
|
} as ProjectRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
projectsDb.updateProjectIsStarredById = (projectId: string) => {
|
||||||
|
updatedProjectIds.push(projectId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyLegacyStarredProjectIds([
|
||||||
|
'project-a',
|
||||||
|
'project-b',
|
||||||
|
'missing-project',
|
||||||
|
'project-a',
|
||||||
|
'',
|
||||||
|
' ',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(result.updated, 1);
|
||||||
|
assert.deepEqual(updatedProjectIds, ['project-a']);
|
||||||
|
} finally {
|
||||||
|
projectsDb.getProjectById = originalGetProjectById;
|
||||||
|
projectsDb.updateProjectIsStarredById = originalUpdateProjectIsStarredById;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getProjectTaskMaster,
|
||||||
|
getProjectTaskMasterById,
|
||||||
|
} from '@/modules/projects/services/projects-has-taskmaster.service.js';
|
||||||
|
import { AppError } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
test('getProjectTaskMasterById returns null when project path is missing', async () => {
|
||||||
|
const result = await getProjectTaskMasterById('project-1', {
|
||||||
|
resolveProjectPathById: () => null,
|
||||||
|
detectTaskMasterFolder: async () => {
|
||||||
|
throw new Error('detectTaskMasterFolder should not be called when path is missing');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getProjectTaskMasterById returns configured status when taskmaster exists with essential files', async () => {
|
||||||
|
const result = await getProjectTaskMasterById('project-1', {
|
||||||
|
resolveProjectPathById: () => '/workspace/project-1',
|
||||||
|
detectTaskMasterFolder: async () => ({
|
||||||
|
hasTaskmaster: true,
|
||||||
|
hasEssentialFiles: true,
|
||||||
|
metadata: {
|
||||||
|
taskCount: 3,
|
||||||
|
subtaskCount: 0,
|
||||||
|
completed: 1,
|
||||||
|
pending: 2,
|
||||||
|
inProgress: 0,
|
||||||
|
review: 0,
|
||||||
|
completionPercentage: 33,
|
||||||
|
lastModified: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(result);
|
||||||
|
assert.equal(result.projectId, 'project-1');
|
||||||
|
assert.equal(result.projectPath, '/workspace/project-1');
|
||||||
|
assert.equal(result.taskmaster.hasTaskmaster, true);
|
||||||
|
assert.equal(result.taskmaster.hasEssentialFiles, true);
|
||||||
|
assert.equal(result.taskmaster.status, 'configured');
|
||||||
|
assert.deepEqual(result.taskmaster.metadata, {
|
||||||
|
taskCount: 3,
|
||||||
|
subtaskCount: 0,
|
||||||
|
completed: 1,
|
||||||
|
pending: 2,
|
||||||
|
inProgress: 0,
|
||||||
|
review: 0,
|
||||||
|
completionPercentage: 33,
|
||||||
|
lastModified: '2026-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getProjectTaskMasterById returns not-configured status when taskmaster is missing', async () => {
|
||||||
|
const result = await getProjectTaskMasterById('project-1', {
|
||||||
|
resolveProjectPathById: () => '/workspace/project-1',
|
||||||
|
detectTaskMasterFolder: async () => ({
|
||||||
|
hasTaskmaster: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(result);
|
||||||
|
assert.equal(result.taskmaster.hasTaskmaster, false);
|
||||||
|
assert.equal(result.taskmaster.hasEssentialFiles, false);
|
||||||
|
assert.equal(result.taskmaster.status, 'not-configured');
|
||||||
|
assert.equal(result.taskmaster.metadata, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getProjectTaskMaster throws when project id is missing', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
async () =>
|
||||||
|
getProjectTaskMaster('', async () => ({
|
||||||
|
projectId: 'project-1',
|
||||||
|
projectPath: '/workspace/project-1',
|
||||||
|
taskmaster: {
|
||||||
|
hasTaskmaster: true,
|
||||||
|
hasEssentialFiles: true,
|
||||||
|
metadata: null,
|
||||||
|
status: 'configured',
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof AppError);
|
||||||
|
assert.equal(error.code, 'PROJECT_ID_REQUIRED');
|
||||||
|
assert.equal(error.statusCode, 400);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getProjectTaskMaster throws when project does not exist', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
async () => getProjectTaskMaster('project-that-does-not-exist', async () => null),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof AppError);
|
||||||
|
assert.equal(error.code, 'PROJECT_NOT_FOUND');
|
||||||
|
assert.equal(error.statusCode, 404);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
354
server/modules/providers/README.md
Normal file
354
server/modules/providers/README.md
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
# Providers Module Guide
|
||||||
|
|
||||||
|
This file documents the current provider contract in `server/modules/providers`.
|
||||||
|
Keep it current whenever provider wiring, skill discovery, or session sync
|
||||||
|
behavior changes. The goal is that a human or AI agent can add a new provider
|
||||||
|
without guessing which files need to move.
|
||||||
|
|
||||||
|
## Current Provider Shape
|
||||||
|
|
||||||
|
Every provider wrapper exposes five facets:
|
||||||
|
|
||||||
|
- `auth`
|
||||||
|
- `mcp`
|
||||||
|
- `skills`
|
||||||
|
- `sessions`
|
||||||
|
- `sessionSynchronizer`
|
||||||
|
|
||||||
|
These correspond to the shared interfaces in `server/shared/interfaces.ts`:
|
||||||
|
|
||||||
|
- `IProviderAuth`
|
||||||
|
- `IProviderMcp`
|
||||||
|
- `IProviderSkills`
|
||||||
|
- `IProviderSessions`
|
||||||
|
- `IProviderSessionSynchronizer`
|
||||||
|
|
||||||
|
The services that consume them are:
|
||||||
|
|
||||||
|
- `providerAuthService`
|
||||||
|
- `providerMcpService`
|
||||||
|
- `providerSkillsService`
|
||||||
|
- `sessionsService`
|
||||||
|
- `sessionSynchronizerService`
|
||||||
|
|
||||||
|
Current provider ids in this repo are:
|
||||||
|
|
||||||
|
- `claude`
|
||||||
|
- `codex`
|
||||||
|
- `cursor`
|
||||||
|
- `gemini`
|
||||||
|
- `opencode`
|
||||||
|
|
||||||
|
Those ids are mirrored in backend unions and frontend provider constants. If
|
||||||
|
adding a new provider, update every place that hardcodes this list.
|
||||||
|
|
||||||
|
## Current File Layout
|
||||||
|
|
||||||
|
Each provider lives under its own folder in `server/modules/providers/list/`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
server/modules/providers/list/<provider>/
|
||||||
|
<provider>.provider.ts
|
||||||
|
<provider>-auth.provider.ts
|
||||||
|
<provider>-mcp.provider.ts
|
||||||
|
<provider>-skills.provider.ts
|
||||||
|
<provider>-sessions.provider.ts
|
||||||
|
<provider>-session-synchronizer.provider.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and
|
||||||
|
`opencode`.
|
||||||
|
|
||||||
|
## What Each Facet Does
|
||||||
|
|
||||||
|
| Facet | Responsibility | Base / Service |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `auth` | Report install/auth state for the provider runtime | `IProviderAuth` -> `providerAuthService` |
|
||||||
|
| `mcp` | Read, list, write, and remove provider-native MCP config | `McpProvider` -> `providerMcpService` |
|
||||||
|
| `skills` | Discover provider-native skill markdown files | `SkillsProvider` -> `providerSkillsService` |
|
||||||
|
| `sessions` | Normalize live events and fetch session history | `IProviderSessions` -> `sessionsService` |
|
||||||
|
| `sessionSynchronizer` | Scan transcript artifacts and upsert session metadata | `IProviderSessionSynchronizer` -> `sessionSynchronizerService` |
|
||||||
|
|
||||||
|
`sessions` and `sessionSynchronizer` are separate concerns:
|
||||||
|
|
||||||
|
- `sessions` handles runtime event normalization and history fetches.
|
||||||
|
- `sessionSynchronizer` handles file-backed session indexing into `sessionsDb`.
|
||||||
|
|
||||||
|
## How To Add A Provider
|
||||||
|
|
||||||
|
1. Add the provider id everywhere it is part of the contract.
|
||||||
|
|
||||||
|
- Update `server/shared/types.ts` `LLMProvider`.
|
||||||
|
- Update `src/types/app.ts` `LLMProvider` if the frontend should know about it.
|
||||||
|
- Update `server/modules/providers/provider.routes.ts`.
|
||||||
|
- Update `server/routes/agent.js` if the provider is launchable from the agent runtime.
|
||||||
|
- Update `server/index.js` if the provider needs runtime boot or shutdown wiring.
|
||||||
|
- Update `public/modelConstants.js` if the provider appears in README or public API docs.
|
||||||
|
- Update `src/components/chat/hooks/useChatProviderState.ts` and
|
||||||
|
`src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` if
|
||||||
|
the provider should be selectable in chat.
|
||||||
|
- Update `src/components/provider-auth/view/ProviderLoginModal.tsx` if the
|
||||||
|
provider has a login/setup flow.
|
||||||
|
|
||||||
|
2. Create the wrapper class.
|
||||||
|
|
||||||
|
- Add `server/modules/providers/list/<provider>/<provider>.provider.ts`.
|
||||||
|
- Extend `AbstractProvider`.
|
||||||
|
- Expose readonly `auth`, `mcp`, `skills`, `sessions`, and `sessionSynchronizer`.
|
||||||
|
- Call `super('<provider>')`.
|
||||||
|
|
||||||
|
3. Implement auth.
|
||||||
|
|
||||||
|
- Return a full `ProviderAuthStatus`.
|
||||||
|
- Treat normal `not installed` / `not authenticated` states as data, not exceptions.
|
||||||
|
- Keep provider-specific credential discovery inside the auth provider.
|
||||||
|
- If the provider has no auth step, return a stable unauthenticated or not-installed status instead of omitting the facet.
|
||||||
|
|
||||||
|
4. Implement MCP.
|
||||||
|
|
||||||
|
- Extend `McpProvider`.
|
||||||
|
- Pass the supported scopes and transports to `super(...)`.
|
||||||
|
- Implement the four required methods:
|
||||||
|
- `readScopedServers(...)`
|
||||||
|
- `writeScopedServers(...)`
|
||||||
|
- `buildServerConfig(...)`
|
||||||
|
- `normalizeServerConfig(...)`
|
||||||
|
- Use the shared validation and normalization behavior from `McpProvider`.
|
||||||
|
- Keep the provider-specific config format local to the provider implementation.
|
||||||
|
|
||||||
|
Current MCP formats in this repo are:
|
||||||
|
|
||||||
|
| Provider | User / Project Storage | Supported Scopes | Supported Transports |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Claude | `.mcp.json` in user / local / project locations | `user`, `local`, `project` | `stdio`, `http`, `sse` |
|
||||||
|
| Codex | `.codex/config.toml` | `user`, `project` | `stdio`, `http` |
|
||||||
|
| Cursor | `.cursor/mcp.json` | `user`, `project` | `stdio`, `http` |
|
||||||
|
| Gemini | `.gemini/settings.json` | `user`, `project` | `stdio`, `http` |
|
||||||
|
| OpenCode | `~/.config/opencode/opencode.json` or `<workspace>/opencode.json` (`.jsonc` is read when present) | `user`, `project` | `stdio`, `http` |
|
||||||
|
|
||||||
|
5. Implement skills.
|
||||||
|
|
||||||
|
- Extend `SkillsProvider`.
|
||||||
|
- Implement `getSkillSources(workspacePath)`.
|
||||||
|
- Return the actual discovery roots for the provider.
|
||||||
|
- Skills are discovered from `SKILL.md` files.
|
||||||
|
- `readProviderSkillMarkdownDefinition(...)` reads front matter `name` and `description`.
|
||||||
|
- If `name` is missing, the parent directory name is used as a fallback.
|
||||||
|
- Use `recursive: true` only when the provider stores skills in nested trees.
|
||||||
|
- Keep the emitted `command` string aligned with the provider's real skill syntax.
|
||||||
|
|
||||||
|
Current skill discovery roots are:
|
||||||
|
|
||||||
|
| Provider | User Roots | Project / Repo Roots | Prefix | Notes |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Claude | `~/.claude/skills` | `<workspace>/.claude/skills` | `/` | Also discovers Claude plugin skills from enabled plugin installs. Command skills live under `commands/`; markdown skills live under `skills/` and are scanned recursively. |
|
||||||
|
| Codex | `~/.agents/skills`, `~/.codex/skills/.system`, `/etc/codex/skills` | `<workspace>/.agents/skills`, `path.dirname(workspacePath)/.agents/skills`, topmost git root `.agents/skills` | `$` | Overlapping roots are deduplicated before scanning. |
|
||||||
|
| Cursor | `~/.cursor/skills` | `<workspace>/.cursor/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. |
|
||||||
|
| Gemini | `~/.gemini/skills`, `~/.agents/skills` | `<workspace>/.gemini/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. |
|
||||||
|
| OpenCode | `~/.config/opencode/skills`, `~/.claude/skills`, `~/.agents/skills` | Cwd-to-topmost-git-root `.opencode/skills`, `.claude/skills`, and `.agents/skills` | `/` | Reuses OpenCode, Claude, and Agents skill locations. Overlapping roots are deduplicated before scanning. |
|
||||||
|
|
||||||
|
Command forms currently used by the providers are:
|
||||||
|
|
||||||
|
- Claude user/project skills: `/skill-name`
|
||||||
|
- Claude plugin skills: `/plugin-name:skill-name`
|
||||||
|
- Codex skills: `$skill-name`
|
||||||
|
- Cursor skills: `/skill-name`
|
||||||
|
- Gemini skills: `/skill-name`
|
||||||
|
- OpenCode skills: `/skill-name`
|
||||||
|
|
||||||
|
6. Implement sessions.
|
||||||
|
|
||||||
|
- Implement `normalizeMessage(raw, sessionId)` and `fetchHistory(sessionId, options)`.
|
||||||
|
- Use `createNormalizedMessage(...)` and `generateMessageId(...)` for emitted messages.
|
||||||
|
- Keep normalized message ids unique. If one raw event produces multiple text
|
||||||
|
parts, append a discriminator so ids do not collide.
|
||||||
|
- Keep pagination consistent:
|
||||||
|
- `limit: null` means unbounded/full history.
|
||||||
|
- `limit: 0` means an empty page.
|
||||||
|
- always return `total`, `hasMore`, `offset`, and `limit` when paginating.
|
||||||
|
- Sanitize any filesystem-derived ids before using them in file or database paths.
|
||||||
|
- Do not assume a provider's history format matches another provider's format.
|
||||||
|
|
||||||
|
7. Implement session synchronization.
|
||||||
|
|
||||||
|
- Implement `synchronize(since?: Date)` to scan provider artifacts and upsert
|
||||||
|
sessions into `sessionsDb`.
|
||||||
|
- Implement `synchronizeFile(filePath)` for single-file watcher updates.
|
||||||
|
- Use the existing helpers when they fit:
|
||||||
|
- `buildLookupMap(...)`
|
||||||
|
- `extractFirstValidJsonlData(...)`
|
||||||
|
- `findFilesRecursivelyCreatedAfter(...)`
|
||||||
|
- `normalizeSessionName(...)`
|
||||||
|
- `readFileTimestamps(...)`
|
||||||
|
- Make the sync resilient to partial, malformed, or missing provider files.
|
||||||
|
- The orchestration service runs all provider synchronizers and only advances
|
||||||
|
`scan_state.last_scanned_at` when every provider succeeds.
|
||||||
|
|
||||||
|
Current session sync roots are:
|
||||||
|
|
||||||
|
| Provider | Scan Roots | Metadata Helpers / Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Claude | `~/.claude/projects/**/*.jsonl` | Uses `~/.claude/history.jsonl` for name lookup and the trailing `ai-title`, `last-prompt`, or `custom-title` entries for title recovery. |
|
||||||
|
| Codex | `~/.codex/sessions/**/*.jsonl` | Uses `~/.codex/session_index.jsonl` for title lookup and the last `task_complete` message for a fallback title. |
|
||||||
|
| Cursor | `~/.cursor/projects/**/*.jsonl` | Uses sibling `worker.log` to recover `workspacePath`, then derives the session title from the first user prompt. |
|
||||||
|
| Gemini | `~/.gemini/tmp/**/*.jsonl` | Current full scans only index temp JSONL chat artifacts. Single-file sync also accepts legacy `.json` files. |
|
||||||
|
| OpenCode | `~/.local/share/opencode/opencode.db` | Reads active sessions/messages/parts from OpenCode's shared SQLite database and stores `jsonl_path` as `null` so deleting one app session cannot remove the shared DB. |
|
||||||
|
|
||||||
|
8. Register the provider.
|
||||||
|
|
||||||
|
- Add the new provider class to `server/modules/providers/provider.registry.ts`.
|
||||||
|
- Update `server/modules/providers/provider.routes.ts` provider parsing.
|
||||||
|
- If the provider introduces a new service or lifecycle hook, export it from the module entrypoint that consumes providers.
|
||||||
|
|
||||||
|
9. Wire runtime and UI surfaces outside the providers module when needed.
|
||||||
|
|
||||||
|
If the provider can run live chat sessions, update the runtime entrypoints too:
|
||||||
|
|
||||||
|
- `server/routes/agent.js`
|
||||||
|
- `server/index.js`
|
||||||
|
|
||||||
|
If the provider is visible in the UI, update:
|
||||||
|
|
||||||
|
- provider model fallback files under `server/modules/providers/list/<provider>/`
|
||||||
|
- `src/components/chat/hooks/useChatProviderState.ts`
|
||||||
|
- `src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx`
|
||||||
|
- `src/components/provider-auth/view/ProviderLoginModal.tsx`
|
||||||
|
- `src/components/mcp/constants.ts`
|
||||||
|
|
||||||
|
## Minimal Wrapper Template
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||||
|
import { <Provider>ProviderAuth } from './<provider>-auth.provider.js';
|
||||||
|
import { <Provider>McpProvider } from './<provider>-mcp.provider.js';
|
||||||
|
import { <Provider>SkillsProvider } from './<provider>-skills.provider.js';
|
||||||
|
import { <Provider>SessionsProvider } from './<provider>-sessions.provider.js';
|
||||||
|
import { <Provider>SessionSynchronizer } from './<provider>-session-synchronizer.provider.js';
|
||||||
|
import type {
|
||||||
|
IProviderAuth,
|
||||||
|
IProviderMcp,
|
||||||
|
IProviderSessionSynchronizer,
|
||||||
|
IProviderSessions,
|
||||||
|
IProviderSkills,
|
||||||
|
} from '@/shared/interfaces.js';
|
||||||
|
|
||||||
|
export class <Provider>Provider extends AbstractProvider {
|
||||||
|
readonly auth: IProviderAuth = new <Provider>ProviderAuth();
|
||||||
|
readonly mcp: IProviderMcp = new <Provider>McpProvider();
|
||||||
|
readonly skills: IProviderSkills = new <Provider>SkillsProvider();
|
||||||
|
readonly sessions: IProviderSessions = new <Provider>SessionsProvider();
|
||||||
|
readonly sessionSynchronizer: IProviderSessionSynchronizer =
|
||||||
|
new <Provider>SessionSynchronizer();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('<provider>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimal Skills Template
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||||
|
import type { ProviderSkillSource } from '@/shared/types.js';
|
||||||
|
|
||||||
|
export class <Provider>SkillsProvider extends SkillsProvider {
|
||||||
|
constructor() {
|
||||||
|
super('<provider>');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
scope: 'project',
|
||||||
|
rootDir: path.join(workspacePath, '.<provider>', 'skills'),
|
||||||
|
commandPrefix: '/',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimal Session Sync Template
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||||
|
|
||||||
|
export class <Provider>SessionSynchronizer implements IProviderSessionSynchronizer {
|
||||||
|
async synchronize(since?: Date): Promise<number> {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## AI Prompt Template
|
||||||
|
|
||||||
|
Use this prompt when asking an AI agent to add a provider:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Add a new provider "<provider>" using the current provider module architecture.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
1) Create:
|
||||||
|
- server/modules/providers/list/<provider>/<provider>.provider.ts
|
||||||
|
- server/modules/providers/list/<provider>/<provider>-auth.provider.ts
|
||||||
|
- server/modules/providers/list/<provider>/<provider>-mcp.provider.ts
|
||||||
|
- server/modules/providers/list/<provider>/<provider>-skills.provider.ts
|
||||||
|
- server/modules/providers/list/<provider>/<provider>-sessions.provider.ts
|
||||||
|
- server/modules/providers/list/<provider>/<provider>-session-synchronizer.provider.ts
|
||||||
|
2) Register in:
|
||||||
|
- server/modules/providers/provider.registry.ts
|
||||||
|
- server/modules/providers/provider.routes.ts
|
||||||
|
- server/shared/types.ts LLMProvider
|
||||||
|
- src/types/app.ts LLMProvider
|
||||||
|
3) Mirror the nearest existing provider implementation for file naming, style,
|
||||||
|
and error handling.
|
||||||
|
4) Implement skills support with SkillsProvider and the current skill roots.
|
||||||
|
5) Implement session synchronization if the provider stores transcript files.
|
||||||
|
6) Ensure sessions use unique ids, safe path handling, and correct pagination.
|
||||||
|
7) Keep `sessions` and `sessionSynchronizer` separate.
|
||||||
|
8) Run:
|
||||||
|
- npx eslint <touched files>
|
||||||
|
- npx tsc --noEmit -p server/tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
After adding or changing a provider, run the relevant checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx eslint server/modules/providers/**/*.ts server/shared/types.ts server/shared/interfaces.ts
|
||||||
|
npx tsc --noEmit -p server/tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful tests in this repo:
|
||||||
|
|
||||||
|
- `server/modules/providers/tests/mcp.test.ts`
|
||||||
|
- `server/modules/providers/tests/skills.test.ts`
|
||||||
|
- `server/modules/providers/tests/opencode-sessions.test.ts`
|
||||||
|
|
||||||
|
If you touch sessions or session synchronization, add or update focused tests
|
||||||
|
alongside the implementation.
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
- Adding provider files but forgetting `provider.registry.ts` or
|
||||||
|
`provider.routes.ts`.
|
||||||
|
- Updating backend provider ids but not `src/types/app.ts` or the frontend
|
||||||
|
provider constants.
|
||||||
|
- Omitting `skills` or `sessionSynchronizer` from the wrapper.
|
||||||
|
- Returning duplicate normalized message ids for split content.
|
||||||
|
- Treating `limit === 0` as unbounded history.
|
||||||
|
- Building file paths from raw session ids without validation.
|
||||||
|
- Hardcoding a skill root without checking the provider's actual discovery rules.
|
||||||
|
- Forgetting that Claude plugin skills are discovered differently from normal
|
||||||
|
user/project skill folders.
|
||||||
|
- Assuming one provider's MCP config file format works for the others.
|
||||||
|
|
||||||
|
|
||||||
5
server/modules/providers/index.ts
Normal file
5
server/modules/providers/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { sessionSynchronizerService } from './services/session-synchronizer.service.js';
|
||||||
|
export { providerSkillsService } from './services/skills.service.js';
|
||||||
|
|
||||||
|
export { initializeSessionsWatcher } from './services/sessions-watcher.service.js';
|
||||||
|
export { closeSessionsWatcher } from './services/sessions-watcher.service.js';
|
||||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import spawn from 'cross-spawn';
|
import spawn from 'cross-spawn';
|
||||||
|
|
||||||
|
import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
|
||||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||||
@@ -15,18 +16,22 @@ type ClaudeCredentialsStatus = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasErrorCode = (error: unknown, code: string): boolean => (
|
||||||
|
error instanceof Error && 'code' in error && error.code === code
|
||||||
|
);
|
||||||
|
|
||||||
export class ClaudeProviderAuth implements IProviderAuth {
|
export class ClaudeProviderAuth implements IProviderAuth {
|
||||||
/**
|
/**
|
||||||
* Checks whether the Claude Code CLI is available on this host.
|
* Checks whether the Claude Code CLI is available on this host.
|
||||||
*/
|
*/
|
||||||
private checkInstalled(): boolean {
|
private checkInstalled(): boolean {
|
||||||
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
|
const cliPath = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
|
||||||
try {
|
try {
|
||||||
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,6 +81,12 @@ export class ClaudeProviderAuth implements IProviderAuth {
|
|||||||
* Checks Claude credentials in the same priority order used by Claude Code.
|
* Checks Claude credentials in the same priority order used by Claude Code.
|
||||||
*/
|
*/
|
||||||
private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
|
private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
|
||||||
|
const missingCredentialsError = 'Claude CLI is not authenticated. Run claude /login or configure ANTHROPIC_API_KEY.';
|
||||||
|
|
||||||
|
if (process.env.ANTHROPIC_AUTH_TOKEN?.trim()) {
|
||||||
|
return { authenticated: true, email: 'Auth Token', method: 'api_key' };
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.ANTHROPIC_API_KEY?.trim()) {
|
if (process.env.ANTHROPIC_API_KEY?.trim()) {
|
||||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||||
}
|
}
|
||||||
@@ -109,15 +120,33 @@ export class ClaudeProviderAuth implements IProviderAuth {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
email,
|
email: null,
|
||||||
method: 'credentials_file',
|
method: null,
|
||||||
error: 'OAuth token has expired. Please re-authenticate with claude login',
|
error: 'Claude login has expired. Run claude /login again.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { authenticated: false, email: null, method: null };
|
return {
|
||||||
} catch {
|
authenticated: false,
|
||||||
return { authenticated: false, email: null, method: null };
|
email: null,
|
||||||
|
method: null,
|
||||||
|
error: missingCredentialsError,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
let errorMessage = 'Unable to read Claude credentials. Run claude /login again.';
|
||||||
|
|
||||||
|
if (hasErrorCode(error, 'ENOENT')) {
|
||||||
|
errorMessage = missingCredentialsError;
|
||||||
|
} else if (error instanceof SyntaxError) {
|
||||||
|
errorMessage = 'Claude credentials are unreadable. Run claude /login again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
email: null,
|
||||||
|
method: null,
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
201
server/modules/providers/list/claude/claude-models.provider.ts
Normal file
201
server/modules/providers/list/claude/claude-models.provider.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||||
|
import type {
|
||||||
|
ProviderChangeActiveModelInput,
|
||||||
|
ProviderCurrentActiveModel,
|
||||||
|
ProviderModelsDefinition,
|
||||||
|
ProviderSessionActiveModelChange,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
import {
|
||||||
|
buildDefaultProviderCurrentActiveModel,
|
||||||
|
writeProviderSessionActiveModelChange,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
|
export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||||
|
OPTIONS: [
|
||||||
|
{
|
||||||
|
value: 'default',
|
||||||
|
label: 'Default (recommended)',
|
||||||
|
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'fable',
|
||||||
|
label: 'Fable',
|
||||||
|
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "sonnet",
|
||||||
|
label: "Sonnet",
|
||||||
|
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'sonnet[1m]',
|
||||||
|
label: 'Sonnet (1M context)',
|
||||||
|
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'opus[1m]',
|
||||||
|
label: 'Opus 4.8 (1M context)',
|
||||||
|
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'haiku',
|
||||||
|
label: 'Haiku',
|
||||||
|
description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
DEFAULT: 'default',
|
||||||
|
};
|
||||||
|
type ClaudeInitEvent = {
|
||||||
|
sessionId?: string;
|
||||||
|
session_id?: string;
|
||||||
|
type?: string;
|
||||||
|
subtype?: string;
|
||||||
|
model?: string;
|
||||||
|
message?: {
|
||||||
|
content?: unknown;
|
||||||
|
model?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANSI_PATTERN = new RegExp(
|
||||||
|
'[\\u001B\\u009B][[\\]()#;?]*(?:'
|
||||||
|
+ '(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]'
|
||||||
|
+ '|(?:[\\dA-PR-TZcf-ntqry=><~]))',
|
||||||
|
'g',
|
||||||
|
);
|
||||||
|
|
||||||
|
const extractClaudeEventModel = (event: ClaudeInitEvent, sessionId: string): string | null => {
|
||||||
|
const eventSessionId = event.sessionId ?? event.session_id;
|
||||||
|
if (eventSessionId && eventSessionId !== sessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentModel = extractClaudeModelFromMessageContent(event.message?.content);
|
||||||
|
if (contentModel) {
|
||||||
|
return contentModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directModel = event.model?.trim();
|
||||||
|
if (directModel) {
|
||||||
|
return directModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageModel = event.message?.model?.trim();
|
||||||
|
return messageModel || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripAnsi = (value: string): string => value.replace(ANSI_PATTERN, '');
|
||||||
|
|
||||||
|
const extractTaggedContent = (content: string, tagName: string): string | null => {
|
||||||
|
const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const match = new RegExp(`<${escapedTagName}>([\\s\\S]*?)<\\/${escapedTagName}>`).exec(content);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractClaudeModelFromTextContent = (content: string): string | null => {
|
||||||
|
const localCommandStdout = extractTaggedContent(content, 'local-command-stdout');
|
||||||
|
if (localCommandStdout !== null) {
|
||||||
|
const cleanedStdout = stripAnsi(localCommandStdout).replace(/\s+/g, ' ').trim();
|
||||||
|
const changedModel = /(?:set|changed|switched)\s+model\s+to\s+(.+?)\.?$/i.exec(cleanedStdout);
|
||||||
|
if (changedModel?.[1]?.trim()) {
|
||||||
|
return changedModel[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelTag = extractTaggedContent(content, 'model')?.trim();
|
||||||
|
return modelTag || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractClaudeModelFromMessageContent = (content: unknown): string | null => {
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return extractClaudeModelFromTextContent(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const part of content) {
|
||||||
|
if (!part || typeof part !== 'object' || !('text' in part) || typeof part.text !== 'string') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = extractClaudeModelFromTextContent(part.text);
|
||||||
|
if (model) {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readClaudeSessionModelFromJsonl = async (
|
||||||
|
sessionId: string,
|
||||||
|
jsonlPath: string,
|
||||||
|
): Promise<ProviderCurrentActiveModel | null> => {
|
||||||
|
const content = await readFile(jsonlPath, 'utf8');
|
||||||
|
const lines = content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(lines[index]) as ClaudeInitEvent;
|
||||||
|
const model = extractClaudeEventModel(event, sessionId);
|
||||||
|
if (model) {
|
||||||
|
return { model };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed JSONL lines that can happen during concurrent writes.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ClaudeProviderModels implements IProviderModels {
|
||||||
|
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||||
|
// claude creates a new jsonl file as a separate session for this request.
|
||||||
|
// As a result, it lists the workspace where this is invoked when it shouldn't.
|
||||||
|
//
|
||||||
|
// Disabled for now:
|
||||||
|
// const queryInstance = query({
|
||||||
|
// prompt: 'Get supported models',
|
||||||
|
// options: buildClaudeQueryOptions(),
|
||||||
|
// });
|
||||||
|
// const supportedModels = await queryInstance.supportedModels();
|
||||||
|
// queryInstance.close();
|
||||||
|
// return buildClaudeModelsDefinition(supportedModels);
|
||||||
|
return CLAUDE_FALLBACK_MODELS;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
|
||||||
|
if (!sessionId?.trim()) {
|
||||||
|
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jsonlPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||||
|
const activeModel = jsonlPath
|
||||||
|
? await readClaudeSessionModelFromJsonl(sessionId, jsonlPath)
|
||||||
|
: null;
|
||||||
|
if (activeModel?.model) {
|
||||||
|
return activeModel;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to the provider default when the session-backed lookup fails.
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeActiveModel(
|
||||||
|
input: ProviderChangeActiveModelInput,
|
||||||
|
): Promise<ProviderSessionActiveModelChange> {
|
||||||
|
return writeProviderSessionActiveModelChange('claude', input);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import {
|
||||||
|
buildLookupMap,
|
||||||
|
extractFirstValidJsonlData,
|
||||||
|
findFilesRecursivelyCreatedAfter,
|
||||||
|
normalizeSessionName,
|
||||||
|
readFileTimestamps,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||||
|
|
||||||
|
type ParsedSession = {
|
||||||
|
sessionId: string;
|
||||||
|
projectPath: string;
|
||||||
|
sessionName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session indexer for Claude transcript artifacts.
|
||||||
|
*/
|
||||||
|
export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||||
|
private readonly provider = 'claude' as const;
|
||||||
|
private readonly claudeHome = path.join(os.homedir(), '.claude');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans ~/.claude/projects and upserts discovered sessions into DB.
|
||||||
|
*/
|
||||||
|
async synchronize(since?: Date): Promise<number> {
|
||||||
|
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
|
||||||
|
const files = await findFilesRecursivelyCreatedAfter(
|
||||||
|
path.join(this.claudeHome, 'projects'),
|
||||||
|
'.jsonl',
|
||||||
|
since ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
for (const filePath of files) {
|
||||||
|
const parsed = await this.processSessionFile(filePath, nameMap);
|
||||||
|
if (!parsed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamps = await readFileTimestamps(filePath);
|
||||||
|
sessionsDb.createSession(
|
||||||
|
parsed.sessionId,
|
||||||
|
this.provider,
|
||||||
|
parsed.projectPath,
|
||||||
|
parsed.sessionName,
|
||||||
|
timestamps.createdAt,
|
||||||
|
timestamps.updatedAt,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
processed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and upserts one Claude session JSONL file.
|
||||||
|
*/
|
||||||
|
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||||
|
if (!filePath.endsWith('.jsonl')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
|
||||||
|
const parsed = await this.processSessionFile(filePath, nameMap);
|
||||||
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamps = await readFileTimestamps(filePath);
|
||||||
|
return sessionsDb.createSession(
|
||||||
|
parsed.sessionId,
|
||||||
|
this.provider,
|
||||||
|
parsed.projectPath,
|
||||||
|
parsed.sessionName,
|
||||||
|
timestamps.createdAt,
|
||||||
|
timestamps.updatedAt,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts session metadata from one Claude JSONL session file.
|
||||||
|
*/
|
||||||
|
private async processSessionFile(
|
||||||
|
filePath: string,
|
||||||
|
nameMap: Map<string, string>
|
||||||
|
): Promise<ParsedSession | null> {
|
||||||
|
const parsed = await extractFirstValidJsonlData(filePath, (rawData) => {
|
||||||
|
const data = rawData as Record<string, unknown>;
|
||||||
|
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
|
||||||
|
const projectPath = typeof data.cwd === 'string' ? data.cwd : undefined;
|
||||||
|
|
||||||
|
if (!sessionId || !projectPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
projectPath,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
||||||
|
const existingSessionName = existingSession?.custom_name;
|
||||||
|
if (existingSessionName && existingSessionName !== 'Untitled Claude Session') {
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
sessionName: normalizeSessionName(existingSessionName, 'Untitled Claude Session'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessionName = nameMap.get(parsed.sessionId);
|
||||||
|
if (!sessionName) {
|
||||||
|
sessionName = await this.extractSessionAiTitleFromEnd(filePath, parsed.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
sessionName: normalizeSessionName(sessionName, 'Untitled Claude Session'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async extractSessionAiTitleFromEnd(
|
||||||
|
filePath: string,
|
||||||
|
sessionId: string
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const content = await readFile(filePath, 'utf8');
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
|
||||||
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||||
|
const line = lines[index]?.trim();
|
||||||
|
if (!line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed as Record<string, unknown>;
|
||||||
|
const eventType = typeof data.type === 'string' ? data.type : undefined;
|
||||||
|
const eventSessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
|
||||||
|
const aiTitle = typeof data.aiTitle === 'string' ? data.aiTitle : undefined;
|
||||||
|
const lastPrompt = typeof data.lastPrompt === 'string' ? data.lastPrompt : undefined;
|
||||||
|
const claudeRenamedTitle = typeof data.customTitle === 'string' ? data.customTitle : undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(eventType === 'ai-title' && eventSessionId === sessionId && aiTitle?.trim()) ||
|
||||||
|
(eventType === 'last-prompt' && eventSessionId === sessionId && lastPrompt?.trim()) ||
|
||||||
|
(eventType === "custom-title" && eventSessionId === sessionId && claudeRenamedTitle?.trim())
|
||||||
|
) {
|
||||||
|
return aiTitle || lastPrompt || claudeRenamedTitle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore missing/unreadable files so sync can continue.
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { getSessionMessages } from '@/projects.js';
|
import fs from 'node:fs';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import readline from 'node:readline';
|
||||||
|
|
||||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||||
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
|
|
||||||
const PROVIDER = 'claude';
|
const PROVIDER = 'claude';
|
||||||
|
|
||||||
@@ -15,30 +20,198 @@ type ClaudeToolResult = {
|
|||||||
type ClaudeHistoryResult =
|
type ClaudeHistoryResult =
|
||||||
| AnyRecord[]
|
| AnyRecord[]
|
||||||
| {
|
| {
|
||||||
messages?: AnyRecord[];
|
messages?: AnyRecord[];
|
||||||
total?: number;
|
total?: number;
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadClaudeSessionMessages = getSessionMessages as unknown as (
|
type ClaudeHistoryMessagesResult =
|
||||||
projectName: string,
|
| AnyRecord[]
|
||||||
|
| {
|
||||||
|
messages: AnyRecord[];
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function parseAgentTools(filePath: string): Promise<AnyRecord[]> {
|
||||||
|
const tools: AnyRecord[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileStream = fs.createReadStream(filePath);
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: fileStream,
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const line of rl) {
|
||||||
|
if (!line.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line) as AnyRecord;
|
||||||
|
|
||||||
|
if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) {
|
||||||
|
for (const part of entry.message.content as AnyRecord[]) {
|
||||||
|
if (part.type === 'tool_use') {
|
||||||
|
tools.push({
|
||||||
|
toolId: part.id,
|
||||||
|
toolName: part.name,
|
||||||
|
toolInput: part.input,
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) {
|
||||||
|
for (const part of entry.message.content as AnyRecord[]) {
|
||||||
|
if (part.type !== 'tool_result') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tool = tools.find((candidate) => candidate.toolId === part.tool_use_id);
|
||||||
|
if (!tool) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tool.toolResult = {
|
||||||
|
content: typeof part.content === 'string'
|
||||||
|
? part.content
|
||||||
|
: Array.isArray(part.content)
|
||||||
|
? part.content
|
||||||
|
.map((contentPart: AnyRecord) => contentPart?.text || '')
|
||||||
|
.join('\n')
|
||||||
|
: JSON.stringify(part.content),
|
||||||
|
isError: Boolean(part.is_error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed lines that can happen during concurrent writes.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.warn(`Error parsing agent file ${filePath}:`, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSessionMessages(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
limit: number | null,
|
limit: number | null,
|
||||||
offset: number,
|
offset: number,
|
||||||
) => Promise<ClaudeHistoryResult>;
|
): Promise<ClaudeHistoryMessagesResult> {
|
||||||
|
try {
|
||||||
|
const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||||
|
|
||||||
|
if (!jsonLPath) {
|
||||||
|
return { messages: [], total: 0, hasMore: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectDir = path.dirname(jsonLPath);
|
||||||
|
const files = await fsp.readdir(projectDir);
|
||||||
|
const agentFiles = files.filter((file) => file.endsWith('.jsonl') && file.startsWith('agent-'));
|
||||||
|
|
||||||
|
const messages: AnyRecord[] = [];
|
||||||
|
const agentToolsCache = new Map<string, AnyRecord[]>();
|
||||||
|
|
||||||
|
const fileStream = fs.createReadStream(jsonLPath);
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: fileStream,
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const line of rl) {
|
||||||
|
if (!line.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line) as AnyRecord;
|
||||||
|
if (entry.sessionId === sessionId) {
|
||||||
|
messages.push(entry);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed JSONL lines that can happen during concurrent writes.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentIds = new Set<string>();
|
||||||
|
for (const message of messages) {
|
||||||
|
const agentId = message.toolUseResult?.agentId;
|
||||||
|
if (agentId) {
|
||||||
|
agentIds.add(String(agentId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agentId of agentIds) {
|
||||||
|
const agentFileName = `agent-${agentId}.jsonl`;
|
||||||
|
if (!agentFiles.includes(agentFileName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentFilePath = path.join(projectDir, agentFileName);
|
||||||
|
const tools = await parseAgentTools(agentFilePath);
|
||||||
|
agentToolsCache.set(agentId, tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const agentId = message.toolUseResult?.agentId;
|
||||||
|
if (!agentId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentTools = agentToolsCache.get(String(agentId));
|
||||||
|
if (agentTools && agentTools.length > 0) {
|
||||||
|
message.subagentTools = agentTools;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedMessages = messages.sort(
|
||||||
|
(a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(),
|
||||||
|
);
|
||||||
|
const total = sortedMessages.length;
|
||||||
|
|
||||||
|
if (limit === null) {
|
||||||
|
return sortedMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = Math.max(0, total - offset - limit);
|
||||||
|
const endIndex = total - offset;
|
||||||
|
const paginatedMessages = sortedMessages.slice(startIndex, endIndex);
|
||||||
|
const hasMore = startIndex > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: paginatedMessages,
|
||||||
|
total,
|
||||||
|
hasMore,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading messages for session ${sessionId}:`, error);
|
||||||
|
return limit === null ? [] : { messages: [], total: 0, hasMore: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claude writes internal command and system reminder entries into history.
|
* Claude writes a mix of truly internal transcript rows and "UI-hidden" local
|
||||||
* Those are useful for the CLI but should not appear in the user-facing chat.
|
* command artifacts into the same JSONL stream.
|
||||||
|
*
|
||||||
|
* Important distinction:
|
||||||
|
* - system reminders / caveats / interruption banners should stay hidden
|
||||||
|
* - local command payloads (`<command-name>...`) and stdout wrappers
|
||||||
|
* (`<local-command-stdout>...`) should be remapped into normal chat messages
|
||||||
|
* instead of being discarded as internal content
|
||||||
*/
|
*/
|
||||||
const INTERNAL_CONTENT_PREFIXES = [
|
const INTERNAL_CONTENT_PREFIXES = [
|
||||||
'<command-name>',
|
|
||||||
'<command-message>',
|
|
||||||
'<command-args>',
|
|
||||||
'<local-command-stdout>',
|
|
||||||
'<system-reminder>',
|
'<system-reminder>',
|
||||||
'Caveat:',
|
'Caveat:',
|
||||||
'This session is being continued from a previous',
|
|
||||||
'[Request interrupted',
|
'[Request interrupted',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -46,6 +219,73 @@ function isInternalContent(content: string): boolean {
|
|||||||
return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix));
|
return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude wraps local slash-command metadata in lightweight XML-like tags inside
|
||||||
|
* a plain string payload. We intentionally parse only the small tag surface we
|
||||||
|
* care about instead of introducing a generic XML parser for untrusted history.
|
||||||
|
*/
|
||||||
|
function extractTaggedContent(content: string, tagName: string): string | null {
|
||||||
|
const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const match = new RegExp(`<${escapedTagName}>([\\s\\S]*?)<\\/${escapedTagName}>`).exec(content);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClaudeLocalCommandPayload = {
|
||||||
|
commandName: string;
|
||||||
|
commandMessage: string;
|
||||||
|
commandArgs: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts Claude's hidden local command wrapper into structured metadata.
|
||||||
|
*
|
||||||
|
* The three tags often coexist in one string payload. Returning `null` lets the
|
||||||
|
* normal text path continue untouched for unrelated messages.
|
||||||
|
*/
|
||||||
|
function parseLocalCommandPayload(content: string): ClaudeLocalCommandPayload | null {
|
||||||
|
const commandName = extractTaggedContent(content, 'command-name');
|
||||||
|
const commandMessage = extractTaggedContent(content, 'command-message');
|
||||||
|
const commandArgs = extractTaggedContent(content, 'command-args');
|
||||||
|
|
||||||
|
if (commandName === null && commandMessage === null && commandArgs === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
commandName: commandName ?? '',
|
||||||
|
commandMessage: commandMessage ?? '',
|
||||||
|
commandArgs: commandArgs ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces the short user-visible command string that should appear in chat.
|
||||||
|
*
|
||||||
|
* We prefer the slash-prefixed command name because that most closely matches
|
||||||
|
* what the user actually typed, and only fall back to the message body when the
|
||||||
|
* command name is unavailable in older transcript variants.
|
||||||
|
*/
|
||||||
|
function buildLocalCommandDisplayText(payload: ClaudeLocalCommandPayload): string {
|
||||||
|
const commandName = payload.commandName.trim();
|
||||||
|
const commandMessage = payload.commandMessage.trim();
|
||||||
|
const commandArgs = payload.commandArgs.trim();
|
||||||
|
const baseCommand = commandName || commandMessage;
|
||||||
|
|
||||||
|
if (!baseCommand) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return commandArgs ? `${baseCommand} ${commandArgs}` : baseCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude local-command stdout may contain ANSI styling codes because it was
|
||||||
|
* captured from the terminal. The web chat should receive readable plain text.
|
||||||
|
*/
|
||||||
|
function stripAnsiFormatting(text: string): string {
|
||||||
|
return text.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
export class ClaudeSessionsProvider implements IProviderSessions {
|
export class ClaudeSessionsProvider implements IProviderSessions {
|
||||||
/**
|
/**
|
||||||
* Normalizes one Claude JSONL entry or live SDK stream event into the shared
|
* Normalizes one Claude JSONL entry or live SDK stream event into the shared
|
||||||
@@ -68,7 +308,7 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
|||||||
const ts = raw.timestamp || new Date().toISOString();
|
const ts = raw.timestamp || new Date().toISOString();
|
||||||
const baseId = raw.uuid || generateMessageId('claude');
|
const baseId = raw.uuid || generateMessageId('claude');
|
||||||
|
|
||||||
if (raw.message?.role === 'user' && raw.message?.content) {
|
if (raw.message?.role === 'user' && raw.message?.content && raw.isMeta !== true) {
|
||||||
if (Array.isArray(raw.message.content)) {
|
if (Array.isArray(raw.message.content)) {
|
||||||
for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) {
|
for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) {
|
||||||
const part = raw.message.content[partIndex];
|
const part = raw.message.content[partIndex];
|
||||||
@@ -121,6 +361,80 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
} else if (typeof raw.message.content === 'string') {
|
} else if (typeof raw.message.content === 'string') {
|
||||||
const text = raw.message.content;
|
const text = raw.message.content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude stores compact summaries as synthetic "user" rows so the CLI
|
||||||
|
* can resume the next session turn with the summary in-context.
|
||||||
|
*
|
||||||
|
* For the web UI this is much more useful as assistant-authored summary
|
||||||
|
* text; otherwise it is both filtered by the generic internal-prefix
|
||||||
|
* check and visually mislabeled as a user message.
|
||||||
|
*/
|
||||||
|
if (raw.isCompactSummary === true && text.trim()) {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role: 'assistant',
|
||||||
|
content: text,
|
||||||
|
isCompactSummary: true,
|
||||||
|
}));
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local slash commands are serialized as tagged text even though they
|
||||||
|
* are semantically a user action. Expose the parsed fields to the
|
||||||
|
* frontend and emit a plain user-visible command string so the command
|
||||||
|
* no longer disappears from history.
|
||||||
|
*/
|
||||||
|
const localCommandPayload = parseLocalCommandPayload(text);
|
||||||
|
if (localCommandPayload) {
|
||||||
|
const displayText = buildLocalCommandDisplayText(localCommandPayload);
|
||||||
|
if (displayText) {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role: 'user',
|
||||||
|
content: displayText,
|
||||||
|
commandName: localCommandPayload.commandName,
|
||||||
|
commandMessage: localCommandPayload.commandMessage,
|
||||||
|
commandArgs: localCommandPayload.commandArgs,
|
||||||
|
isLocalCommand: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local command stdout is also written as a "user" row in Claude's
|
||||||
|
* transcript, but it is terminal output produced in response to the
|
||||||
|
* command. Re-label it as assistant text so the chat transcript matches
|
||||||
|
* the actual conversational flow seen by the user.
|
||||||
|
*/
|
||||||
|
const localCommandStdout = extractTaggedContent(text, 'local-command-stdout');
|
||||||
|
if (localCommandStdout !== null) {
|
||||||
|
const stdoutText = stripAnsiFormatting(localCommandStdout).trim();
|
||||||
|
if (stdoutText) {
|
||||||
|
messages.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role: 'assistant',
|
||||||
|
content: stdoutText,
|
||||||
|
isLocalCommandStdout: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
if (text && !isInternalContent(text)) {
|
if (text && !isInternalContent(text)) {
|
||||||
messages.push(createNormalizedMessage({
|
messages.push(createNormalizedMessage({
|
||||||
id: baseId,
|
id: baseId,
|
||||||
@@ -238,14 +552,13 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
options: FetchHistoryOptions = {},
|
options: FetchHistoryOptions = {},
|
||||||
): Promise<FetchHistoryResult> {
|
): Promise<FetchHistoryResult> {
|
||||||
const { projectName, limit = null, offset = 0 } = options;
|
const { limit = null, offset = 0 } = options;
|
||||||
if (!projectName) {
|
|
||||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: ClaudeHistoryResult;
|
let result: ClaudeHistoryResult;
|
||||||
try {
|
try {
|
||||||
result = await loadClaudeSessionMessages(projectName, sessionId, limit, offset);
|
// Load full history first so `total` reflects frontend-normalized messages,
|
||||||
|
// not raw JSONL records.
|
||||||
|
result = await getSessionMessages(sessionId, null, 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
|
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
|
||||||
@@ -253,8 +566,6 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||||
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
|
||||||
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
|
||||||
|
|
||||||
const toolResultMap = new Map<string, ClaudeToolResult>();
|
const toolResultMap = new Map<string, ClaudeToolResult>();
|
||||||
for (const raw of rawMessages) {
|
for (const raw of rawMessages) {
|
||||||
@@ -295,12 +606,31 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalNormalized = normalized.length;
|
||||||
|
let total = 0;
|
||||||
|
for (const msg of normalized) {
|
||||||
|
if (msg.kind !== 'tool_result') {
|
||||||
|
total += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const normalizedOffset = Math.max(0, offset);
|
||||||
|
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||||
|
const messages = normalizedLimit === null
|
||||||
|
? normalized
|
||||||
|
: normalized.slice(
|
||||||
|
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
|
||||||
|
Math.max(0, totalNormalized - normalizedOffset),
|
||||||
|
);
|
||||||
|
const hasMore = normalizedLimit === null
|
||||||
|
? false
|
||||||
|
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: normalized,
|
messages,
|
||||||
total,
|
total,
|
||||||
hasMore,
|
hasMore,
|
||||||
offset,
|
offset: normalizedOffset,
|
||||||
limit,
|
limit: normalizedLimit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
257
server/modules/providers/list/claude/claude-skills.provider.ts
Normal file
257
server/modules/providers/list/claude/claude-skills.provider.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||||
|
import { parseFrontMatter } from '@/shared/frontmatter.js';
|
||||||
|
import type {
|
||||||
|
ProviderSkill,
|
||||||
|
ProviderSkillListOptions,
|
||||||
|
ProviderSkillSource,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
import {
|
||||||
|
findProviderSkillMarkdownFiles,
|
||||||
|
readJsonConfig,
|
||||||
|
readObjectRecord,
|
||||||
|
readOptionalString,
|
||||||
|
readProviderSkillMarkdownDefinition,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
|
const getClaudeHomePath = (): string => path.join(os.homedir(), '.claude');
|
||||||
|
|
||||||
|
const getClaudePluginName = (pluginId: string): string | null => {
|
||||||
|
const normalizedPluginId = pluginId.trim();
|
||||||
|
if (!normalizedPluginId || normalizedPluginId === '@') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [pluginName] = normalizedPluginId.split('@');
|
||||||
|
return readOptionalString(pluginName) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripMarkdownExtension = (filename: string): string =>
|
||||||
|
filename.replace(/\.md$/i, '');
|
||||||
|
|
||||||
|
const pathExistsAsDirectory = async (directoryPath: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const directoryStats = await stat(directoryPath);
|
||||||
|
return directoryStats.isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listChildDirectories = async (directoryPath: string): Promise<string[]> => {
|
||||||
|
try {
|
||||||
|
const entries = await readdir(directoryPath, { withFileTypes: true });
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => path.join(directoryPath, entry.name))
|
||||||
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const readClaudePluginName = async (
|
||||||
|
installPath: string,
|
||||||
|
pluginId: string,
|
||||||
|
): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const pluginConfig = await readJsonConfig(
|
||||||
|
path.join(installPath, '.claude-plugin', 'plugin.json'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Older or partial plugin installs may not have plugin.json yet. Falling
|
||||||
|
// back keeps discovery useful without inventing a separate namespace.
|
||||||
|
return readOptionalString(pluginConfig.name) ?? getClaudePluginName(pluginId);
|
||||||
|
} catch {
|
||||||
|
return getClaudePluginName(pluginId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ClaudeSkillsProvider extends SkillsProvider {
|
||||||
|
constructor() {
|
||||||
|
super('claude');
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSkills(options?: ProviderSkillListOptions): Promise<ProviderSkill[]> {
|
||||||
|
return [
|
||||||
|
...(await super.listSkills(options)),
|
||||||
|
...(await this.listPluginSkills(getClaudeHomePath())),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||||
|
const claudeHomePath = getClaudeHomePath();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
scope: 'user',
|
||||||
|
rootDir: path.join(claudeHomePath, 'skills'),
|
||||||
|
commandPrefix: '/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'project',
|
||||||
|
rootDir: path.join(workspacePath, '.claude', 'skills'),
|
||||||
|
commandPrefix: '/',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listPluginSkills(claudeHomePath: string): Promise<ProviderSkill[]> {
|
||||||
|
const settings = await readJsonConfig(path.join(claudeHomePath, 'settings.json'));
|
||||||
|
const enabledPlugins = readObjectRecord(settings.enabledPlugins);
|
||||||
|
if (!enabledPlugins) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const installedConfig = await readJsonConfig(
|
||||||
|
path.join(claudeHomePath, 'plugins', 'installed_plugins.json'),
|
||||||
|
);
|
||||||
|
const installedPlugins = readObjectRecord(installedConfig.plugins);
|
||||||
|
if (!installedPlugins) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const skills: ProviderSkill[] = [];
|
||||||
|
const visitedPluginFolders = new Set<string>();
|
||||||
|
const pluginEntries = Object.entries(enabledPlugins)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right));
|
||||||
|
for (const [pluginId, enabled] of pluginEntries) {
|
||||||
|
if (enabled !== true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installs = installedPlugins[pluginId];
|
||||||
|
if (!Array.isArray(installs)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const install of installs) {
|
||||||
|
const installRecord = readObjectRecord(install);
|
||||||
|
const installPath = readOptionalString(installRecord?.installPath);
|
||||||
|
if (!installPath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude's installed path points at one version folder; the usable
|
||||||
|
// plugin payloads live in the direct child folders beside it.
|
||||||
|
const pluginFolders = await listChildDirectories(path.dirname(installPath));
|
||||||
|
for (const pluginFolder of pluginFolders) {
|
||||||
|
const pluginFolderKey = `${pluginId}:${path.resolve(pluginFolder)}`;
|
||||||
|
if (visitedPluginFolders.has(pluginFolderKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visitedPluginFolders.add(pluginFolderKey);
|
||||||
|
|
||||||
|
const pluginName = await readClaudePluginName(pluginFolder, pluginId);
|
||||||
|
if (!pluginName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandsPath = path.join(pluginFolder, 'commands');
|
||||||
|
if (await pathExistsAsDirectory(commandsPath)) {
|
||||||
|
skills.push(
|
||||||
|
...(await this.listPluginCommandSkills(commandsPath, pluginId, pluginName)),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillsPath = path.join(pluginFolder, 'skills');
|
||||||
|
if (!(await pathExistsAsDirectory(skillsPath))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
skills.push(
|
||||||
|
...(await this.listPluginSkillMarkdowns(pluginFolder, pluginId, pluginName)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listPluginCommandSkills(
|
||||||
|
commandsPath: string,
|
||||||
|
pluginId: string,
|
||||||
|
pluginName: string,
|
||||||
|
): Promise<ProviderSkill[]> {
|
||||||
|
const skills: ProviderSkill[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await readdir(commandsPath, { withFileTypes: true });
|
||||||
|
const commandFiles = entries
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.md'))
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
|
||||||
|
for (const commandFile of commandFiles) {
|
||||||
|
const sourcePath = path.join(commandsPath, commandFile.name);
|
||||||
|
try {
|
||||||
|
const definition = await this.readPluginCommandDefinition(sourcePath);
|
||||||
|
skills.push({
|
||||||
|
provider: this.provider,
|
||||||
|
name: definition.name,
|
||||||
|
description: definition.description,
|
||||||
|
command: `/${pluginName}:${definition.name}`,
|
||||||
|
scope: 'plugin',
|
||||||
|
sourcePath,
|
||||||
|
pluginName,
|
||||||
|
pluginId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Malformed command markdown should not block sibling plugin commands.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Missing or unreadable command folders are treated as empty plugin command sets.
|
||||||
|
}
|
||||||
|
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readPluginCommandDefinition(
|
||||||
|
commandPath: string,
|
||||||
|
): Promise<{ name: string; description: string }> {
|
||||||
|
const content = await readFile(commandPath, 'utf8');
|
||||||
|
const parsed = parseFrontMatter(content);
|
||||||
|
const data = readObjectRecord(parsed.data) ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: stripMarkdownExtension(path.basename(commandPath)),
|
||||||
|
description: readOptionalString(data.description) ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listPluginSkillMarkdowns(
|
||||||
|
installPath: string,
|
||||||
|
pluginId: string,
|
||||||
|
pluginName: string,
|
||||||
|
): Promise<ProviderSkill[]> {
|
||||||
|
const skillFiles = await findProviderSkillMarkdownFiles(path.join(installPath, 'skills'), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
const skills: ProviderSkill[] = [];
|
||||||
|
|
||||||
|
for (const skillPath of skillFiles) {
|
||||||
|
try {
|
||||||
|
const definition = await readProviderSkillMarkdownDefinition(skillPath);
|
||||||
|
skills.push({
|
||||||
|
provider: this.provider,
|
||||||
|
name: definition.name,
|
||||||
|
description: definition.description,
|
||||||
|
command: `/${pluginName}:${definition.name}`,
|
||||||
|
scope: 'plugin',
|
||||||
|
sourcePath: skillPath,
|
||||||
|
pluginName,
|
||||||
|
pluginId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// A bad plugin skill file should not block other installed plugin skills.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||||
import { ClaudeProviderAuth } from '@/modules/providers/list/claude/claude-auth.provider.js';
|
import { ClaudeProviderAuth } from '@/modules/providers/list/claude/claude-auth.provider.js';
|
||||||
|
import { ClaudeProviderModels } from '@/modules/providers/list/claude/claude-models.provider.js';
|
||||||
import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js';
|
import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js';
|
||||||
|
import { ClaudeSessionSynchronizer } from '@/modules/providers/list/claude/claude-session-synchronizer.provider.js';
|
||||||
import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js';
|
import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js';
|
||||||
import type { IProviderAuth, IProviderSessions } from '@/shared/interfaces.js';
|
import { ClaudeSkillsProvider } from '@/modules/providers/list/claude/claude-skills.provider.js';
|
||||||
|
import type {
|
||||||
|
IProviderAuth,
|
||||||
|
IProviderModels,
|
||||||
|
IProviderSessionSynchronizer,
|
||||||
|
IProviderSkills,
|
||||||
|
IProviderSessions,
|
||||||
|
} from '@/shared/interfaces.js';
|
||||||
|
|
||||||
export class ClaudeProvider extends AbstractProvider {
|
export class ClaudeProvider extends AbstractProvider {
|
||||||
|
readonly models: IProviderModels = new ClaudeProviderModels();
|
||||||
readonly mcp = new ClaudeMcpProvider();
|
readonly mcp = new ClaudeMcpProvider();
|
||||||
readonly auth: IProviderAuth = new ClaudeProviderAuth();
|
readonly auth: IProviderAuth = new ClaudeProviderAuth();
|
||||||
|
readonly skills: IProviderSkills = new ClaudeSkillsProvider();
|
||||||
readonly sessions: IProviderSessions = new ClaudeSessionsProvider();
|
readonly sessions: IProviderSessions = new ClaudeSessionsProvider();
|
||||||
|
readonly sessionSynchronizer: IProviderSessionSynchronizer = new ClaudeSessionSynchronizer();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('claude');
|
super('claude');
|
||||||
|
|||||||
125
server/modules/providers/list/codex/codex-models.provider.ts
Normal file
125
server/modules/providers/list/codex/codex-models.provider.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import TOML from '@iarna/toml';
|
||||||
|
|
||||||
|
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||||
|
import type {
|
||||||
|
ProviderChangeActiveModelInput,
|
||||||
|
ProviderCurrentActiveModel,
|
||||||
|
ProviderModelOption,
|
||||||
|
ProviderModelsDefinition,
|
||||||
|
ProviderSessionActiveModelChange,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
import {
|
||||||
|
buildDefaultProviderCurrentActiveModel,
|
||||||
|
readObjectRecord,
|
||||||
|
readOptionalString,
|
||||||
|
writeProviderSessionActiveModelChange,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
|
export const CODEX_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||||
|
OPTIONS: [
|
||||||
|
{ value: 'gpt-5.5', label: 'gpt-5.5' },
|
||||||
|
{ value: 'gpt-5.4', label: 'gpt-5.4' },
|
||||||
|
{ value: 'gpt-5.4-mini', label: 'gpt-5.4-mini' },
|
||||||
|
{ value: 'gpt-5.3-codex', label: 'gpt-5.3-codex' },
|
||||||
|
{ value: 'gpt-5.2', label: 'gpt-5.2' },
|
||||||
|
],
|
||||||
|
DEFAULT: 'gpt-5.4',
|
||||||
|
};
|
||||||
|
|
||||||
|
type CodexCachedModel = {
|
||||||
|
slug?: string;
|
||||||
|
display_name?: string;
|
||||||
|
description?: string;
|
||||||
|
priority?: number;
|
||||||
|
visibility?: string;
|
||||||
|
supported_in_api?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CODEX_MODELS_CACHE_PATH = path.join(os.homedir(), '.codex', 'models_cache.json');
|
||||||
|
const CODEX_CONFIG_PATH = path.join(os.homedir(), '.codex', 'config.toml');
|
||||||
|
|
||||||
|
const isCodexCachedModel = (value: unknown): value is CodexCachedModel => {
|
||||||
|
const record = readObjectRecord(value);
|
||||||
|
return Boolean(record && readOptionalString(record.slug));
|
||||||
|
};
|
||||||
|
|
||||||
|
const readCodexPriority = (value: unknown): number => (
|
||||||
|
typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => ({
|
||||||
|
value: model.slug as string,
|
||||||
|
label: readOptionalString(model.display_name) ?? (model.slug as string),
|
||||||
|
description: readOptionalString(model.description),
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => {
|
||||||
|
const sortedModels = [...models]
|
||||||
|
.filter((model) => model.visibility !== 'hidden' && model.supported_in_api !== false)
|
||||||
|
.sort((left, right) => readCodexPriority(left.priority) - readCodexPriority(right.priority));
|
||||||
|
|
||||||
|
const options: ProviderModelOption[] = [];
|
||||||
|
const seenValues = new Set<string>();
|
||||||
|
|
||||||
|
for (const model of sortedModels) {
|
||||||
|
const mappedModel = mapCodexModel(model);
|
||||||
|
if (seenValues.has(mappedModel.value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenValues.add(mappedModel.value);
|
||||||
|
options.push(mappedModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.length === 0) {
|
||||||
|
return CODEX_FALLBACK_MODELS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
OPTIONS: options,
|
||||||
|
DEFAULT: options[0]?.value ?? CODEX_FALLBACK_MODELS.DEFAULT,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CodexProviderModels implements IProviderModels {
|
||||||
|
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(CODEX_MODELS_CACHE_PATH, 'utf8');
|
||||||
|
const parsed = readObjectRecord(JSON.parse(raw));
|
||||||
|
const models = Array.isArray(parsed?.models)
|
||||||
|
? parsed.models.filter(isCodexCachedModel)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return buildCodexModelsDefinition(models);
|
||||||
|
} catch {
|
||||||
|
return CODEX_FALLBACK_MODELS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(CODEX_CONFIG_PATH, 'utf8');
|
||||||
|
const parsed = readObjectRecord(TOML.parse(raw));
|
||||||
|
const model = readOptionalString(parsed?.model);
|
||||||
|
if (!model) {
|
||||||
|
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeActiveModel(
|
||||||
|
input: ProviderChangeActiveModelInput,
|
||||||
|
): Promise<ProviderSessionActiveModelChange> {
|
||||||
|
return writeProviderSessionActiveModelChange('codex', input);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import {
|
||||||
|
buildLookupMap,
|
||||||
|
extractFirstValidJsonlData,
|
||||||
|
findFilesRecursivelyCreatedAfter,
|
||||||
|
normalizeSessionName,
|
||||||
|
readFileTimestamps,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||||
|
|
||||||
|
type ParsedSession = {
|
||||||
|
sessionId: string;
|
||||||
|
projectPath: string;
|
||||||
|
sessionName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session indexer for Codex transcript artifacts.
|
||||||
|
*/
|
||||||
|
export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||||
|
private readonly provider = 'codex' as const;
|
||||||
|
private readonly codexHome = path.join(os.homedir(), '.codex');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans ~/.codex/sessions and upserts discovered sessions into DB.
|
||||||
|
*/
|
||||||
|
async synchronize(since?: Date): Promise<number> {
|
||||||
|
const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name');
|
||||||
|
const files = await findFilesRecursivelyCreatedAfter(
|
||||||
|
path.join(this.codexHome, 'sessions'),
|
||||||
|
'.jsonl',
|
||||||
|
since ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
for (const filePath of files) {
|
||||||
|
const parsed = await this.processSessionFile(filePath, nameMap);
|
||||||
|
if (!parsed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
||||||
|
if (existingSession) {
|
||||||
|
// If session name is untitled and we now have a name, update it
|
||||||
|
if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') {
|
||||||
|
sessionsDb.updateSessionCustomName(parsed.sessionId, parsed.sessionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamps = await readFileTimestamps(filePath);
|
||||||
|
sessionsDb.createSession(
|
||||||
|
parsed.sessionId,
|
||||||
|
this.provider,
|
||||||
|
parsed.projectPath,
|
||||||
|
parsed.sessionName,
|
||||||
|
timestamps.createdAt,
|
||||||
|
timestamps.updatedAt,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
processed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and upserts one Codex session JSONL file.
|
||||||
|
*/
|
||||||
|
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||||
|
if (!filePath.endsWith('.jsonl')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name');
|
||||||
|
const parsed = await this.processSessionFile(filePath, nameMap);
|
||||||
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamps = await readFileTimestamps(filePath);
|
||||||
|
return sessionsDb.createSession(
|
||||||
|
parsed.sessionId,
|
||||||
|
this.provider,
|
||||||
|
parsed.projectPath,
|
||||||
|
parsed.sessionName,
|
||||||
|
timestamps.createdAt,
|
||||||
|
timestamps.updatedAt,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts session metadata from one Codex JSONL session file.
|
||||||
|
*/
|
||||||
|
private async processSessionFile(
|
||||||
|
filePath: string,
|
||||||
|
nameMap: Map<string, string>
|
||||||
|
): Promise<ParsedSession | null> {
|
||||||
|
const parsed = await extractFirstValidJsonlData(filePath, (rawData) => {
|
||||||
|
const data = rawData as Record<string, unknown>;
|
||||||
|
const payload = data.payload as Record<string, unknown> | undefined;
|
||||||
|
const sessionId = typeof payload?.id === 'string' ? payload.id : undefined;
|
||||||
|
const projectPath = typeof payload?.cwd === 'string' ? payload.cwd : undefined;
|
||||||
|
|
||||||
|
if (!sessionId || !projectPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
projectPath,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
||||||
|
const existingSessionName = existingSession?.custom_name;
|
||||||
|
if (existingSessionName && existingSessionName !== 'Untitled Codex Session') {
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
sessionName: normalizeSessionName(existingSessionName, 'Untitled Codex Session'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessionName = nameMap.get(parsed.sessionId);
|
||||||
|
if (!sessionName) {
|
||||||
|
sessionName = await this.extractLastAgentMessageFromEnd(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
sessionName: normalizeSessionName(sessionName, 'Untitled Codex Session'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async extractLastAgentMessageFromEnd(filePath: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const content = await readFile(filePath, 'utf8');
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
|
||||||
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||||
|
const line = lines[index]?.trim();
|
||||||
|
if (!line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed as Record<string, unknown>;
|
||||||
|
const eventType = typeof data.type === 'string' ? data.type : undefined;
|
||||||
|
const payload = data.payload as Record<string, unknown> | undefined;
|
||||||
|
const payloadType = typeof payload?.type === 'string' ? payload.type : undefined;
|
||||||
|
const lastAgentMessage = typeof payload?.last_agent_message === 'string'
|
||||||
|
? payload.last_agent_message
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (eventType === 'event_msg' && payloadType === 'task_complete' && lastAgentMessage?.trim()) {
|
||||||
|
return lastAgentMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore missing/unreadable files so sync can continue.
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { getCodexSessionMessages } from '@/projects.js';
|
import fsSync from 'node:fs';
|
||||||
|
import readline from 'node:readline';
|
||||||
|
|
||||||
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||||
@@ -11,14 +14,250 @@ type CodexHistoryResult =
|
|||||||
messages?: AnyRecord[];
|
messages?: AnyRecord[];
|
||||||
total?: number;
|
total?: number;
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number | null;
|
||||||
tokenUsage?: unknown;
|
tokenUsage?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadCodexSessionMessages = getCodexSessionMessages as unknown as (
|
function isVisibleCodexUserMessage(payload: AnyRecord | null | undefined): boolean {
|
||||||
|
if (!payload || payload.type !== 'user_message') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.kind && payload.kind !== 'plain') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof payload.message === 'string' && payload.message.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCodexTextContent(content: unknown): string {
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return typeof content === 'string' ? content : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = item as AnyRecord;
|
||||||
|
if (
|
||||||
|
(record.type === 'input_text' || record.type === 'output_text' || record.type === 'text')
|
||||||
|
&& typeof record.text === 'string'
|
||||||
|
) {
|
||||||
|
return record.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCodexSessionMessages(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
limit: number | null,
|
limit: number | null = null,
|
||||||
offset: number,
|
offset = 0,
|
||||||
) => Promise<CodexHistoryResult>;
|
): Promise<CodexHistoryResult> {
|
||||||
|
try {
|
||||||
|
const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||||
|
|
||||||
|
if (!sessionFilePath) {
|
||||||
|
console.warn(`Codex session file not found for session ${sessionId}`);
|
||||||
|
return { messages: [], total: 0, hasMore: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages: AnyRecord[] = [];
|
||||||
|
let tokenUsage: AnyRecord | null = null;
|
||||||
|
const fileStream = fsSync.createReadStream(sessionFilePath);
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: fileStream,
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const line of rl) {
|
||||||
|
if (!line.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line) as AnyRecord;
|
||||||
|
|
||||||
|
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
||||||
|
const info = entry.payload.info as AnyRecord;
|
||||||
|
if (info.total_token_usage) {
|
||||||
|
const usage = info.total_token_usage as AnyRecord;
|
||||||
|
tokenUsage = {
|
||||||
|
used: usage.total_tokens || 0,
|
||||||
|
total: info.model_context_window || 200000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload as AnyRecord)) {
|
||||||
|
messages.push({
|
||||||
|
type: 'user',
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: entry.payload.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
entry.type === 'response_item' &&
|
||||||
|
entry.payload?.type === 'message' &&
|
||||||
|
entry.payload.role === 'assistant'
|
||||||
|
) {
|
||||||
|
const textContent = extractCodexTextContent(entry.payload.content);
|
||||||
|
if (textContent.trim()) {
|
||||||
|
messages.push({
|
||||||
|
type: 'assistant',
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: textContent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') {
|
||||||
|
const summaryText = Array.isArray(entry.payload.summary)
|
||||||
|
? entry.payload.summary
|
||||||
|
.map((item: AnyRecord) => item?.text)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (summaryText.trim()) {
|
||||||
|
messages.push({
|
||||||
|
type: 'thinking',
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: summaryText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
|
||||||
|
let toolName = entry.payload.name;
|
||||||
|
let toolInput = entry.payload.arguments;
|
||||||
|
|
||||||
|
if (toolName === 'shell_command') {
|
||||||
|
toolName = 'Bash';
|
||||||
|
try {
|
||||||
|
const args = JSON.parse(entry.payload.arguments) as AnyRecord;
|
||||||
|
toolInput = JSON.stringify({ command: args.command });
|
||||||
|
} catch {
|
||||||
|
// Keep original arguments when parsing fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
type: 'tool_use',
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
toolName,
|
||||||
|
toolInput,
|
||||||
|
toolCallId: entry.payload.call_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') {
|
||||||
|
messages.push({
|
||||||
|
type: 'tool_result',
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
toolCallId: entry.payload.call_id,
|
||||||
|
output: entry.payload.output,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') {
|
||||||
|
const toolName = entry.payload.name || 'custom_tool';
|
||||||
|
const input = entry.payload.input || '';
|
||||||
|
|
||||||
|
if (toolName === 'apply_patch') {
|
||||||
|
const fileMatch = String(input).match(/\*\*\* Update File: (.+)/);
|
||||||
|
const filePath = fileMatch ? fileMatch[1].trim() : 'unknown';
|
||||||
|
const lines = String(input).split('\n');
|
||||||
|
const oldLines: string[] = [];
|
||||||
|
const newLines: string[] = [];
|
||||||
|
|
||||||
|
for (const lineContent of lines) {
|
||||||
|
if (lineContent.startsWith('-') && !lineContent.startsWith('---')) {
|
||||||
|
oldLines.push(lineContent.slice(1));
|
||||||
|
} else if (lineContent.startsWith('+') && !lineContent.startsWith('+++')) {
|
||||||
|
newLines.push(lineContent.slice(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
type: 'tool_use',
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
toolName: 'Edit',
|
||||||
|
toolInput: JSON.stringify({
|
||||||
|
file_path: filePath,
|
||||||
|
old_string: oldLines.join('\n'),
|
||||||
|
new_string: newLines.join('\n'),
|
||||||
|
}),
|
||||||
|
toolCallId: entry.payload.call_id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
messages.push({
|
||||||
|
type: 'tool_use',
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
toolName,
|
||||||
|
toolInput: input,
|
||||||
|
toolCallId: entry.payload.call_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {
|
||||||
|
messages.push({
|
||||||
|
type: 'tool_result',
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
toolCallId: entry.payload.call_id,
|
||||||
|
output: entry.payload.output || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed lines.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.sort(
|
||||||
|
(a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(),
|
||||||
|
);
|
||||||
|
const total = messages.length;
|
||||||
|
|
||||||
|
if (limit !== null) {
|
||||||
|
const startIndex = Math.max(0, total - offset - limit);
|
||||||
|
const endIndex = total - offset;
|
||||||
|
const paginatedMessages = messages.slice(startIndex, endIndex);
|
||||||
|
const hasMore = startIndex > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: paginatedMessages,
|
||||||
|
total,
|
||||||
|
hasMore,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
tokenUsage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messages, tokenUsage };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading Codex session messages for ${sessionId}:`, error);
|
||||||
|
return { messages: [], total: 0, hasMore: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class CodexSessionsProvider implements IProviderSessions {
|
export class CodexSessionsProvider implements IProviderSessions {
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +270,23 @@ export class CodexSessionsProvider implements IProviderSessions {
|
|||||||
const ts = raw.timestamp || new Date().toISOString();
|
const ts = raw.timestamp || new Date().toISOString();
|
||||||
const baseId = raw.uuid || generateMessageId('codex');
|
const baseId = raw.uuid || generateMessageId('codex');
|
||||||
|
|
||||||
|
if (raw.type === 'thinking' || raw.isReasoning) {
|
||||||
|
const thinkingContent = typeof raw.message?.content === 'string'
|
||||||
|
? raw.message.content
|
||||||
|
: '';
|
||||||
|
if (!thinkingContent.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'thinking',
|
||||||
|
content: thinkingContent,
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
if (raw.message?.role === 'user') {
|
if (raw.message?.role === 'user') {
|
||||||
const content = typeof raw.message.content === 'string'
|
const content = typeof raw.message.content === 'string'
|
||||||
? raw.message.content
|
? raw.message.content
|
||||||
@@ -77,17 +333,6 @@ export class CodexSessionsProvider implements IProviderSessions {
|
|||||||
})];
|
})];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw.type === 'thinking' || raw.isReasoning) {
|
|
||||||
return [createNormalizedMessage({
|
|
||||||
id: baseId,
|
|
||||||
sessionId,
|
|
||||||
timestamp: ts,
|
|
||||||
provider: PROVIDER,
|
|
||||||
kind: 'thinking',
|
|
||||||
content: raw.message?.content || '',
|
|
||||||
})];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (raw.type === 'tool_use' || raw.toolName) {
|
if (raw.type === 'tool_use' || raw.toolName) {
|
||||||
return [createNormalizedMessage({
|
return [createNormalizedMessage({
|
||||||
id: baseId,
|
id: baseId,
|
||||||
@@ -275,7 +520,9 @@ export class CodexSessionsProvider implements IProviderSessions {
|
|||||||
|
|
||||||
let result: CodexHistoryResult;
|
let result: CodexHistoryResult;
|
||||||
try {
|
try {
|
||||||
result = await loadCodexSessionMessages(sessionId, limit, offset);
|
// Load full history first so `total` reflects frontend-normalized messages,
|
||||||
|
// not raw JSONL records.
|
||||||
|
result = await getCodexSessionMessages(sessionId, null, 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.warn(`[CodexProvider] Failed to load session ${sessionId}:`, message);
|
console.warn(`[CodexProvider] Failed to load session ${sessionId}:`, message);
|
||||||
@@ -283,8 +530,6 @@ export class CodexSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||||
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
|
||||||
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
|
||||||
const tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage;
|
const tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage;
|
||||||
|
|
||||||
const normalized: NormalizedMessage[] = [];
|
const normalized: NormalizedMessage[] = [];
|
||||||
@@ -307,12 +552,31 @@ export class CodexSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalNormalized = normalized.length;
|
||||||
|
let total = 0;
|
||||||
|
for (const msg of normalized) {
|
||||||
|
if (msg.kind !== 'tool_result') {
|
||||||
|
total += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const normalizedOffset = Math.max(0, offset);
|
||||||
|
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||||
|
const messages = normalizedLimit === null
|
||||||
|
? normalized
|
||||||
|
: normalized.slice(
|
||||||
|
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
|
||||||
|
Math.max(0, totalNormalized - normalizedOffset),
|
||||||
|
);
|
||||||
|
const hasMore = normalizedLimit === null
|
||||||
|
? false
|
||||||
|
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: normalized,
|
messages,
|
||||||
total,
|
total,
|
||||||
hasMore,
|
hasMore,
|
||||||
offset,
|
offset: normalizedOffset,
|
||||||
limit,
|
limit: normalizedLimit,
|
||||||
tokenUsage,
|
tokenUsage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
60
server/modules/providers/list/codex/codex-skills.provider.ts
Normal file
60
server/modules/providers/list/codex/codex-skills.provider.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||||
|
import type { ProviderSkillSource } from '@/shared/types.js';
|
||||||
|
import {
|
||||||
|
addUniqueProviderSkillSource,
|
||||||
|
findTopmostGitRoot,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
|
export class CodexSkillsProvider extends SkillsProvider {
|
||||||
|
constructor() {
|
||||||
|
super('codex');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||||
|
const sources: ProviderSkillSource[] = [];
|
||||||
|
const seenRootDirs = new Set<string>();
|
||||||
|
const repoRoot = await findTopmostGitRoot(workspacePath);
|
||||||
|
|
||||||
|
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||||
|
scope: 'repo',
|
||||||
|
rootDir: path.join(workspacePath, '.agents', 'skills'),
|
||||||
|
commandPrefix: '$',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (repoRoot) {
|
||||||
|
// Codex checks repository skills at the launch folder, one folder above it,
|
||||||
|
// and the topmost git root; these can collapse to the same directory.
|
||||||
|
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||||
|
scope: 'repo',
|
||||||
|
rootDir: path.join(path.dirname(workspacePath), '.agents', 'skills'),
|
||||||
|
commandPrefix: '$',
|
||||||
|
});
|
||||||
|
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||||
|
scope: 'repo',
|
||||||
|
rootDir: path.join(repoRoot, '.agents', 'skills'),
|
||||||
|
commandPrefix: '$',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||||
|
scope: 'user',
|
||||||
|
rootDir: path.join(os.homedir(), '.agents', 'skills'),
|
||||||
|
commandPrefix: '$',
|
||||||
|
});
|
||||||
|
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||||
|
scope: 'admin',
|
||||||
|
rootDir: path.join('/etc', 'codex', 'skills'),
|
||||||
|
commandPrefix: '$',
|
||||||
|
});
|
||||||
|
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||||
|
scope: 'system',
|
||||||
|
rootDir: path.join(os.homedir(), '.codex', 'skills', '.system'),
|
||||||
|
commandPrefix: '$',
|
||||||
|
});
|
||||||
|
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||||
import { CodexProviderAuth } from '@/modules/providers/list/codex/codex-auth.provider.js';
|
import { CodexProviderAuth } from '@/modules/providers/list/codex/codex-auth.provider.js';
|
||||||
|
import { CodexProviderModels } from '@/modules/providers/list/codex/codex-models.provider.js';
|
||||||
import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js';
|
import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js';
|
||||||
|
import { CodexSessionSynchronizer } from '@/modules/providers/list/codex/codex-session-synchronizer.provider.js';
|
||||||
import { CodexSessionsProvider } from '@/modules/providers/list/codex/codex-sessions.provider.js';
|
import { CodexSessionsProvider } from '@/modules/providers/list/codex/codex-sessions.provider.js';
|
||||||
import type { IProviderAuth, IProviderSessions } from '@/shared/interfaces.js';
|
import { CodexSkillsProvider } from '@/modules/providers/list/codex/codex-skills.provider.js';
|
||||||
|
import type {
|
||||||
|
IProviderAuth,
|
||||||
|
IProviderModels,
|
||||||
|
IProviderSessionSynchronizer,
|
||||||
|
IProviderSkills,
|
||||||
|
IProviderSessions,
|
||||||
|
} from '@/shared/interfaces.js';
|
||||||
|
|
||||||
export class CodexProvider extends AbstractProvider {
|
export class CodexProvider extends AbstractProvider {
|
||||||
|
readonly models: IProviderModels = new CodexProviderModels();
|
||||||
readonly mcp = new CodexMcpProvider();
|
readonly mcp = new CodexMcpProvider();
|
||||||
readonly auth: IProviderAuth = new CodexProviderAuth();
|
readonly auth: IProviderAuth = new CodexProviderAuth();
|
||||||
|
readonly skills: IProviderSkills = new CodexSkillsProvider();
|
||||||
readonly sessions: IProviderSessions = new CodexSessionsProvider();
|
readonly sessions: IProviderSessions = new CodexSessionsProvider();
|
||||||
|
readonly sessionSynchronizer: IProviderSessionSynchronizer = new CodexSessionSynchronizer();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('codex');
|
super('codex');
|
||||||
|
|||||||
820
server/modules/providers/list/cursor/cursor-models.provider.ts
Normal file
820
server/modules/providers/list/cursor/cursor-models.provider.ts
Normal file
@@ -0,0 +1,820 @@
|
|||||||
|
import { access, readdir } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
import crossSpawn from 'cross-spawn';
|
||||||
|
|
||||||
|
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||||
|
import type {
|
||||||
|
ProviderChangeActiveModelInput,
|
||||||
|
ProviderCurrentActiveModel,
|
||||||
|
ProviderModelOption,
|
||||||
|
ProviderModelsDefinition,
|
||||||
|
ProviderSessionActiveModelChange,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
import {
|
||||||
|
buildDefaultProviderCurrentActiveModel,
|
||||||
|
sanitizeLeafDirectoryName,
|
||||||
|
writeProviderSessionActiveModelChange,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
|
export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||||
|
OPTIONS: [
|
||||||
|
{
|
||||||
|
value: "auto",
|
||||||
|
label: "auto",
|
||||||
|
description: "Auto",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "composer-2-fast",
|
||||||
|
label: "composer-2-fast",
|
||||||
|
description: "Composer 2 Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "composer-2",
|
||||||
|
label: "composer-2",
|
||||||
|
description: "Composer 2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.3-codex-low",
|
||||||
|
label: "gpt-5.3-codex-low",
|
||||||
|
description: "Codex 5.3 Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.3-codex-low-fast",
|
||||||
|
label: "gpt-5.3-codex-low-fast",
|
||||||
|
description: "Codex 5.3 Low Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.3-codex",
|
||||||
|
label: "gpt-5.3-codex",
|
||||||
|
description: "Codex 5.3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.3-codex-fast",
|
||||||
|
label: "gpt-5.3-codex-fast",
|
||||||
|
description: "Codex 5.3 Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.3-codex-high",
|
||||||
|
label: "gpt-5.3-codex-high",
|
||||||
|
description: "Codex 5.3 High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.3-codex-high-fast",
|
||||||
|
label: "gpt-5.3-codex-high-fast",
|
||||||
|
description: "Codex 5.3 High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.3-codex-xhigh",
|
||||||
|
label: "gpt-5.3-codex-xhigh",
|
||||||
|
description: "Codex 5.3 Extra High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.3-codex-xhigh-fast",
|
||||||
|
label: "gpt-5.3-codex-xhigh-fast",
|
||||||
|
description: "Codex 5.3 Extra High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2",
|
||||||
|
label: "gpt-5.2",
|
||||||
|
description: "GPT-5.2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-codex-low",
|
||||||
|
label: "gpt-5.2-codex-low",
|
||||||
|
description: "Codex 5.2 Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-codex-low-fast",
|
||||||
|
label: "gpt-5.2-codex-low-fast",
|
||||||
|
description: "Codex 5.2 Low Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-codex",
|
||||||
|
label: "gpt-5.2-codex",
|
||||||
|
description: "Codex 5.2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-codex-fast",
|
||||||
|
label: "gpt-5.2-codex-fast",
|
||||||
|
description: "Codex 5.2 Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-codex-high",
|
||||||
|
label: "gpt-5.2-codex-high",
|
||||||
|
description: "Codex 5.2 High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-codex-high-fast",
|
||||||
|
label: "gpt-5.2-codex-high-fast",
|
||||||
|
description: "Codex 5.2 High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-codex-xhigh",
|
||||||
|
label: "gpt-5.2-codex-xhigh",
|
||||||
|
description: "Codex 5.2 Extra High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-codex-xhigh-fast",
|
||||||
|
label: "gpt-5.2-codex-xhigh-fast",
|
||||||
|
description: "Codex 5.2 Extra High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-max-low",
|
||||||
|
label: "gpt-5.1-codex-max-low",
|
||||||
|
description: "Codex 5.1 Max Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-max-low-fast",
|
||||||
|
label: "gpt-5.1-codex-max-low-fast",
|
||||||
|
description: "Codex 5.1 Max Low Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-max-medium",
|
||||||
|
label: "gpt-5.1-codex-max-medium",
|
||||||
|
description: "Codex 5.1 Max",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-max-medium-fast",
|
||||||
|
label: "gpt-5.1-codex-max-medium-fast",
|
||||||
|
description: "Codex 5.1 Max Medium Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-max-high",
|
||||||
|
label: "gpt-5.1-codex-max-high",
|
||||||
|
description: "Codex 5.1 Max High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-max-high-fast",
|
||||||
|
label: "gpt-5.1-codex-max-high-fast",
|
||||||
|
description: "Codex 5.1 Max High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-max-xhigh",
|
||||||
|
label: "gpt-5.1-codex-max-xhigh",
|
||||||
|
description: "Codex 5.1 Max Extra High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-max-xhigh-fast",
|
||||||
|
label: "gpt-5.1-codex-max-xhigh-fast",
|
||||||
|
description: "Codex 5.1 Max Extra High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "composer-2.5",
|
||||||
|
label: "composer-2.5",
|
||||||
|
description: "Composer 2.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-high",
|
||||||
|
label: "gpt-5.5-high",
|
||||||
|
description: "GPT-5.5 1M High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-high-fast",
|
||||||
|
label: "gpt-5.5-high-fast",
|
||||||
|
description: "GPT-5.5 High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-high",
|
||||||
|
label: "claude-opus-4-7-thinking-high",
|
||||||
|
description: "Opus 4.7 1M High Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-high",
|
||||||
|
label: "gpt-5.4-high",
|
||||||
|
description: "GPT-5.4 1M High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-high-fast",
|
||||||
|
label: "gpt-5.4-high-fast",
|
||||||
|
description: "GPT-5.4 High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.6-opus-high-thinking",
|
||||||
|
label: "claude-4.6-opus-high-thinking",
|
||||||
|
description: "Opus 4.6 1M Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.6-opus-high-thinking-fast",
|
||||||
|
label: "claude-4.6-opus-high-thinking-fast",
|
||||||
|
description: "Opus 4.6 1M Thinking Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "composer-2.5-fast",
|
||||||
|
label: "composer-2.5-fast",
|
||||||
|
description: "Composer 2.5 Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-none",
|
||||||
|
label: "gpt-5.5-none",
|
||||||
|
description: "GPT-5.5 1M None",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-none-fast",
|
||||||
|
label: "gpt-5.5-none-fast",
|
||||||
|
description: "GPT-5.5 None Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-low",
|
||||||
|
label: "gpt-5.5-low",
|
||||||
|
description: "GPT-5.5 1M Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-low-fast",
|
||||||
|
label: "gpt-5.5-low-fast",
|
||||||
|
description: "GPT-5.5 Low Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-medium",
|
||||||
|
label: "gpt-5.5-medium",
|
||||||
|
description: "GPT-5.5 1M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-medium-fast",
|
||||||
|
label: "gpt-5.5-medium-fast",
|
||||||
|
description: "GPT-5.5 Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-extra-high",
|
||||||
|
label: "gpt-5.5-extra-high",
|
||||||
|
description: "GPT-5.5 1M Extra High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.5-extra-high-fast",
|
||||||
|
label: "gpt-5.5-extra-high-fast",
|
||||||
|
description: "GPT-5.5 Extra High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.6-sonnet-medium",
|
||||||
|
label: "claude-4.6-sonnet-medium",
|
||||||
|
description: "Sonnet 4.6 1M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.6-sonnet-medium-thinking",
|
||||||
|
label: "claude-4.6-sonnet-medium-thinking",
|
||||||
|
description: "Sonnet 4.6 1M Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-low",
|
||||||
|
label: "claude-opus-4-7-low",
|
||||||
|
description: "Opus 4.7 1M Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-low-fast",
|
||||||
|
label: "claude-opus-4-7-low-fast",
|
||||||
|
description: "Opus 4.7 1M Low Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-medium",
|
||||||
|
label: "claude-opus-4-7-medium",
|
||||||
|
description: "Opus 4.7 1M Medium",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-medium-fast",
|
||||||
|
label: "claude-opus-4-7-medium-fast",
|
||||||
|
description: "Opus 4.7 1M Medium Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-high",
|
||||||
|
label: "claude-opus-4-7-high",
|
||||||
|
description: "Opus 4.7 1M High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-high-fast",
|
||||||
|
label: "claude-opus-4-7-high-fast",
|
||||||
|
description: "Opus 4.7 1M High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-xhigh",
|
||||||
|
label: "claude-opus-4-7-xhigh",
|
||||||
|
description: "Opus 4.7 1M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-xhigh-fast",
|
||||||
|
label: "claude-opus-4-7-xhigh-fast",
|
||||||
|
description: "Opus 4.7 1M Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-max",
|
||||||
|
label: "claude-opus-4-7-max",
|
||||||
|
description: "Opus 4.7 1M Max",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-max-fast",
|
||||||
|
label: "claude-opus-4-7-max-fast",
|
||||||
|
description: "Opus 4.7 1M Max Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-low",
|
||||||
|
label: "claude-opus-4-7-thinking-low",
|
||||||
|
description: "Opus 4.7 1M Low Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-low-fast",
|
||||||
|
label: "claude-opus-4-7-thinking-low-fast",
|
||||||
|
description: "Opus 4.7 1M Low Thinking Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-medium",
|
||||||
|
label: "claude-opus-4-7-thinking-medium",
|
||||||
|
description: "Opus 4.7 1M Medium Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-medium-fast",
|
||||||
|
label: "claude-opus-4-7-thinking-medium-fast",
|
||||||
|
description: "Opus 4.7 1M Medium Thinking Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-high-fast",
|
||||||
|
label: "claude-opus-4-7-thinking-high-fast",
|
||||||
|
description: "Opus 4.7 1M High Thinking Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-xhigh",
|
||||||
|
label: "claude-opus-4-7-thinking-xhigh",
|
||||||
|
description: "Opus 4.7 1M Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-xhigh-fast",
|
||||||
|
label: "claude-opus-4-7-thinking-xhigh-fast",
|
||||||
|
description: "Opus 4.7 1M Thinking Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-max",
|
||||||
|
label: "claude-opus-4-7-thinking-max",
|
||||||
|
description: "Opus 4.7 1M Max Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-opus-4-7-thinking-max-fast",
|
||||||
|
label: "claude-opus-4-7-thinking-max-fast",
|
||||||
|
description: "Opus 4.7 1M Max Thinking Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "grok-build-0.1",
|
||||||
|
label: "grok-build-0.1",
|
||||||
|
description: "Grok Build 0.1 1M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-low",
|
||||||
|
label: "gpt-5.4-low",
|
||||||
|
description: "GPT-5.4 1M Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-medium",
|
||||||
|
label: "gpt-5.4-medium",
|
||||||
|
description: "GPT-5.4 1M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-medium-fast",
|
||||||
|
label: "gpt-5.4-medium-fast",
|
||||||
|
description: "GPT-5.4 Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-xhigh",
|
||||||
|
label: "gpt-5.4-xhigh",
|
||||||
|
description: "GPT-5.4 1M Extra High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-xhigh-fast",
|
||||||
|
label: "gpt-5.4-xhigh-fast",
|
||||||
|
description: "GPT-5.4 Extra High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.6-opus-high",
|
||||||
|
label: "claude-4.6-opus-high",
|
||||||
|
description: "Opus 4.6 1M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.6-opus-max",
|
||||||
|
label: "claude-4.6-opus-max",
|
||||||
|
description: "Opus 4.6 1M Max",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.6-opus-max-thinking",
|
||||||
|
label: "claude-4.6-opus-max-thinking",
|
||||||
|
description: "Opus 4.6 1M Max Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.6-opus-max-thinking-fast",
|
||||||
|
label: "claude-4.6-opus-max-thinking-fast",
|
||||||
|
description: "Opus 4.6 1M Max Thinking Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.5-opus-high",
|
||||||
|
label: "claude-4.5-opus-high",
|
||||||
|
description: "Opus 4.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.5-opus-high-thinking",
|
||||||
|
label: "claude-4.5-opus-high-thinking",
|
||||||
|
description: "Opus 4.5 Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-low",
|
||||||
|
label: "gpt-5.2-low",
|
||||||
|
description: "GPT-5.2 Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-low-fast",
|
||||||
|
label: "gpt-5.2-low-fast",
|
||||||
|
description: "GPT-5.2 Low Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-fast",
|
||||||
|
label: "gpt-5.2-fast",
|
||||||
|
description: "GPT-5.2 Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-high",
|
||||||
|
label: "gpt-5.2-high",
|
||||||
|
description: "GPT-5.2 High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-high-fast",
|
||||||
|
label: "gpt-5.2-high-fast",
|
||||||
|
description: "GPT-5.2 High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-xhigh",
|
||||||
|
label: "gpt-5.2-xhigh",
|
||||||
|
description: "GPT-5.2 Extra High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.2-xhigh-fast",
|
||||||
|
label: "gpt-5.2-xhigh-fast",
|
||||||
|
description: "GPT-5.2 Extra High Fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gemini-3.1-pro",
|
||||||
|
label: "gemini-3.1-pro",
|
||||||
|
description: "Gemini 3.1 Pro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-mini-none",
|
||||||
|
label: "gpt-5.4-mini-none",
|
||||||
|
description: "GPT-5.4 Mini None",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-mini-low",
|
||||||
|
label: "gpt-5.4-mini-low",
|
||||||
|
description: "GPT-5.4 Mini Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-mini-medium",
|
||||||
|
label: "gpt-5.4-mini-medium",
|
||||||
|
description: "GPT-5.4 Mini",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-mini-high",
|
||||||
|
label: "gpt-5.4-mini-high",
|
||||||
|
description: "GPT-5.4 Mini High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-mini-xhigh",
|
||||||
|
label: "gpt-5.4-mini-xhigh",
|
||||||
|
description: "GPT-5.4 Mini Extra High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-nano-none",
|
||||||
|
label: "gpt-5.4-nano-none",
|
||||||
|
description: "GPT-5.4 Nano None",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-nano-low",
|
||||||
|
label: "gpt-5.4-nano-low",
|
||||||
|
description: "GPT-5.4 Nano Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-nano-medium",
|
||||||
|
label: "gpt-5.4-nano-medium",
|
||||||
|
description: "GPT-5.4 Nano",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-nano-high",
|
||||||
|
label: "gpt-5.4-nano-high",
|
||||||
|
description: "GPT-5.4 Nano High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.4-nano-xhigh",
|
||||||
|
label: "gpt-5.4-nano-xhigh",
|
||||||
|
description: "GPT-5.4 Nano Extra High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "grok-4.3",
|
||||||
|
label: "grok-4.3",
|
||||||
|
description: "Grok 4.3 1M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.5-sonnet",
|
||||||
|
label: "claude-4.5-sonnet",
|
||||||
|
description: "Sonnet 4.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4.5-sonnet-thinking",
|
||||||
|
label: "claude-4.5-sonnet-thinking",
|
||||||
|
description: "Sonnet 4.5 Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-low",
|
||||||
|
label: "gpt-5.1-low",
|
||||||
|
description: "GPT-5.1 Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1",
|
||||||
|
label: "gpt-5.1",
|
||||||
|
description: "GPT-5.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-high",
|
||||||
|
label: "gpt-5.1-high",
|
||||||
|
description: "GPT-5.1 High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gemini-3-flash",
|
||||||
|
label: "gemini-3-flash",
|
||||||
|
description: "Gemini 3 Flash",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gemini-3.5-flash",
|
||||||
|
label: "gemini-3.5-flash",
|
||||||
|
description: "Gemini 3.5 Flash",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-mini-low",
|
||||||
|
label: "gpt-5.1-codex-mini-low",
|
||||||
|
description: "Codex 5.1 Mini Low",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-mini",
|
||||||
|
label: "gpt-5.1-codex-mini",
|
||||||
|
description: "Codex 5.1 Mini",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5.1-codex-mini-high",
|
||||||
|
label: "gpt-5.1-codex-mini-high",
|
||||||
|
description: "Codex 5.1 Mini High",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4-sonnet",
|
||||||
|
label: "claude-4-sonnet",
|
||||||
|
description: "Sonnet 4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "claude-4-sonnet-thinking",
|
||||||
|
label: "claude-4-sonnet-thinking",
|
||||||
|
description: "Sonnet 4 Thinking",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gpt-5-mini",
|
||||||
|
label: "gpt-5-mini",
|
||||||
|
description: "GPT-5 Mini",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "kimi-k2.5",
|
||||||
|
label: "kimi-k2.5",
|
||||||
|
description: "Kimi K2.5",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
DEFAULT: "composer-2.5-fast",
|
||||||
|
};
|
||||||
|
|
||||||
|
type CursorModelRow = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
current: boolean;
|
||||||
|
default: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CURSOR_MODELS_TIMEOUT_MS = 10_000;
|
||||||
|
const CURSOR_CHATS_ROOT = path.join(os.homedir(), '.cursor', 'chats');
|
||||||
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
|
const ANSI_PATTERN = new RegExp(
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
'[\\u001B\\u009B][[\\]()#;?]*(?:'
|
||||||
|
+ '(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]'
|
||||||
|
+ '|(?:[\\dA-PR-TZcf-ntqry=><~]))',
|
||||||
|
'g',
|
||||||
|
);
|
||||||
|
|
||||||
|
const stripAnsi = (value: string): string => value.replace(ANSI_PATTERN, '');
|
||||||
|
|
||||||
|
const parseModelLine = (line: string): CursorModelRow | null => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!trimmed
|
||||||
|
|| trimmed === 'Available models'
|
||||||
|
|| trimmed.startsWith('Loading models')
|
||||||
|
|| trimmed.startsWith('Tip:')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = trimmed.match(/^(.+?)\s+-\s+(.+)$/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = match[1].trim();
|
||||||
|
let description = match[2].trim();
|
||||||
|
const current = /\(current\)/i.test(description);
|
||||||
|
const defaultModel = /\(default\)/i.test(description);
|
||||||
|
|
||||||
|
description = description.replace(/\s*\((current|default)\)/gi, '').replace(/\s{2,}/g, ' ').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
current,
|
||||||
|
default: defaultModel,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseModelsOutput = (text: string): CursorModelRow[] => {
|
||||||
|
const models: CursorModelRow[] = [];
|
||||||
|
|
||||||
|
for (const line of stripAnsi(text).split(/\r?\n/)) {
|
||||||
|
const parsed = parseModelLine(line);
|
||||||
|
if (parsed) {
|
||||||
|
models.push(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runCursorListModels = (): Promise<string> => new Promise((resolve, reject) => {
|
||||||
|
const cursorProcess = spawnFunction('cursor-agent', ['--list-models'], {
|
||||||
|
env: { ...process.env },
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
cursorProcess.kill('SIGTERM');
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(new Error('cursor-agent --list-models timed out'));
|
||||||
|
}
|
||||||
|
}, CURSOR_MODELS_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const finish = (error: Error | null, output: string) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(output);
|
||||||
|
};
|
||||||
|
|
||||||
|
cursorProcess.stdout?.on('data', (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
cursorProcess.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
cursorProcess.on('error', (error) => {
|
||||||
|
finish(error instanceof Error ? error : new Error(String(error)), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
cursorProcess.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
finish(new Error(stderr.trim() || `cursor-agent --list-models exited with code ${code}`), '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(null, stdout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildCursorModelsDefinition = (models: CursorModelRow[]): ProviderModelsDefinition => {
|
||||||
|
const options: ProviderModelOption[] = [];
|
||||||
|
const seenValues = new Set<string>();
|
||||||
|
|
||||||
|
for (const model of models) {
|
||||||
|
if (seenValues.has(model.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenValues.add(model.name);
|
||||||
|
options.push({
|
||||||
|
value: model.name,
|
||||||
|
label: model.name,
|
||||||
|
description: model.description || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.length === 0) {
|
||||||
|
return CURSOR_FALLBACK_MODELS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValue = models.find((model) => model.default)?.name
|
||||||
|
?? models.find((model) => model.current)?.name
|
||||||
|
?? options[0]?.value
|
||||||
|
?? CURSOR_FALLBACK_MODELS.DEFAULT;
|
||||||
|
|
||||||
|
return {
|
||||||
|
OPTIONS: options,
|
||||||
|
DEFAULT: defaultValue,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveCursorSessionStorePath = async (sessionId: string): Promise<string | null> => {
|
||||||
|
const safeSessionId = sanitizeLeafDirectoryName(sessionId, 'cursor session id');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workspaceEntries = await readdir(CURSOR_CHATS_ROOT, { withFileTypes: true });
|
||||||
|
for (const workspaceEntry of workspaceEntries) {
|
||||||
|
if (!workspaceEntry.isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeDbPath = path.join(CURSOR_CHATS_ROOT, workspaceEntry.name, safeSessionId, 'store.db');
|
||||||
|
try {
|
||||||
|
await access(storeDbPath);
|
||||||
|
return storeDbPath;
|
||||||
|
} catch {
|
||||||
|
// Keep scanning sibling workspaces until the matching session directory is found.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CursorProviderModels implements IProviderModels {
|
||||||
|
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||||
|
try {
|
||||||
|
const stdout = await runCursorListModels();
|
||||||
|
const models = parseModelsOutput(stdout);
|
||||||
|
return buildCursorModelsDefinition(models);
|
||||||
|
} catch {
|
||||||
|
return CURSOR_FALLBACK_MODELS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
|
||||||
|
if (!sessionId?.trim()) {
|
||||||
|
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storeDbPath = await resolveCursorSessionStorePath(sessionId);
|
||||||
|
if (!storeDbPath) {
|
||||||
|
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||||
|
}
|
||||||
|
|
||||||
|
const { default: Database } = await import('better-sqlite3');
|
||||||
|
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = db.prepare(`SELECT value FROM meta WHERE key='0' LIMIT 1;`).get() as {
|
||||||
|
value?: Buffer | string;
|
||||||
|
} | undefined;
|
||||||
|
const metadataText = Buffer.isBuffer(row?.value)
|
||||||
|
? row.value.toString('utf8')
|
||||||
|
: typeof row?.value === 'string' && row.value.trim()
|
||||||
|
? Buffer.from(row.value.trim(), 'hex').toString('utf8')
|
||||||
|
: '';
|
||||||
|
if (!metadataText) {
|
||||||
|
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = JSON.parse(metadataText) as { lastUsedModel?: string };
|
||||||
|
if (typeof metadata.lastUsedModel === 'string' && metadata.lastUsedModel.trim()) {
|
||||||
|
return {
|
||||||
|
model: metadata.lastUsedModel.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to the provider default when Cursor metadata cannot be read.
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeActiveModel(
|
||||||
|
input: ProviderChangeActiveModelInput,
|
||||||
|
): Promise<ProviderSessionActiveModelChange> {
|
||||||
|
return writeProviderSessionActiveModelChange('cursor', input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import readline from 'node:readline';
|
||||||
|
|
||||||
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import {
|
||||||
|
extractFirstValidJsonlData,
|
||||||
|
findFilesRecursivelyCreatedAfter,
|
||||||
|
normalizeSessionName,
|
||||||
|
readFileTimestamps,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||||
|
|
||||||
|
type ParsedSession = {
|
||||||
|
sessionId: string;
|
||||||
|
projectPath: string;
|
||||||
|
sessionName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns directory entries or an empty list when the folder is missing.
|
||||||
|
*/
|
||||||
|
async function listDirectoryEntriesSafe(
|
||||||
|
directoryPath: string
|
||||||
|
): Promise<import('node:fs').Dirent[]> {
|
||||||
|
try {
|
||||||
|
return await fsp.readdir(directoryPath, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session indexer for Cursor transcript artifacts.
|
||||||
|
*/
|
||||||
|
export class CursorSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||||
|
private readonly provider = 'cursor' as const;
|
||||||
|
private readonly cursorHome = path.join(os.homedir(), '.cursor');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans Cursor chats and upserts discovered sessions into DB.
|
||||||
|
*/
|
||||||
|
async synchronize(since?: Date): Promise<number> {
|
||||||
|
const projectsDir = path.join(this.cursorHome, 'projects');
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
|
||||||
|
const files = await findFilesRecursivelyCreatedAfter(projectsDir, '.jsonl', since ?? null);
|
||||||
|
|
||||||
|
for (const filePath of files) {
|
||||||
|
const parsed = await this.processSessionFile(filePath);
|
||||||
|
if (!parsed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamps = await readFileTimestamps(filePath);
|
||||||
|
sessionsDb.createSession(
|
||||||
|
parsed.sessionId,
|
||||||
|
this.provider,
|
||||||
|
parsed.projectPath,
|
||||||
|
parsed.sessionName,
|
||||||
|
timestamps.createdAt,
|
||||||
|
timestamps.updatedAt,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
processed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and upserts one Cursor session JSONL file.
|
||||||
|
*/
|
||||||
|
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||||
|
if (!filePath.endsWith('.jsonl')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = await this.processSessionFile(filePath);
|
||||||
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamps = await readFileTimestamps(filePath);
|
||||||
|
return sessionsDb.createSession(
|
||||||
|
parsed.sessionId,
|
||||||
|
this.provider,
|
||||||
|
parsed.projectPath,
|
||||||
|
parsed.sessionName,
|
||||||
|
timestamps.createdAt,
|
||||||
|
timestamps.updatedAt,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts project path from Cursor worker.log.
|
||||||
|
*/
|
||||||
|
private async extractProjectPathFromWorkerLog(filePath: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||||
|
const lineReader = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
||||||
|
|
||||||
|
for await (const line of lineReader) {
|
||||||
|
const match = line.match(/workspacePath=(.*)$/);
|
||||||
|
const projectPath = match?.[1]?.trim();
|
||||||
|
if (projectPath) {
|
||||||
|
lineReader.close();
|
||||||
|
fileStream.close();
|
||||||
|
return projectPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Missing worker logs are valid for partial or incomplete session data.
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts session metadata from one Cursor JSONL session file.
|
||||||
|
*/
|
||||||
|
private async processSessionFile(filePath: string): Promise<ParsedSession | null> {
|
||||||
|
const sessionId = path.basename(filePath, '.jsonl');
|
||||||
|
const grandparentDir = path.dirname(path.dirname(path.dirname(filePath)));
|
||||||
|
const workerLogPath = path.join(grandparentDir, 'worker.log');
|
||||||
|
const projectPath = await this.extractProjectPathFromWorkerLog(workerLogPath);
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractFirstValidJsonlData(filePath, (rawData) => {
|
||||||
|
const data = rawData as Record<string, any>;
|
||||||
|
if (data.role !== 'user') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = typeof data.message?.content?.[0]?.text === 'string' ? data.message.content[0].text : '';
|
||||||
|
const firstLine = text.replace(/<\/?user_query>/g, '').trim().split('\n')[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
projectPath,
|
||||||
|
sessionName: normalizeSessionName(firstLine, 'Untitled Cursor Session'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,12 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
import {
|
||||||
|
createNormalizedMessage,
|
||||||
|
generateMessageId,
|
||||||
|
readObjectRecord,
|
||||||
|
sanitizeLeafDirectoryName,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
const PROVIDER = 'cursor';
|
const PROVIDER = 'cursor';
|
||||||
|
|
||||||
@@ -25,19 +30,162 @@ type CursorMessageBlob = {
|
|||||||
content: AnyRecord;
|
content: AnyRecord;
|
||||||
};
|
};
|
||||||
|
|
||||||
function sanitizeCursorSessionId(sessionId: string): string {
|
function isInternalCursorText(value: unknown): boolean {
|
||||||
const normalized = sessionId.trim();
|
if (typeof value !== 'string') {
|
||||||
if (!normalized) {
|
return false;
|
||||||
throw new Error('Cursor session id is required.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const normalized = value.trim();
|
||||||
normalized.includes('..')
|
return normalized.startsWith('<user_info>') || normalized.startsWith('<system_reminder>');
|
||||||
|| normalized.includes(path.posix.sep)
|
}
|
||||||
|| normalized.includes(path.win32.sep)
|
|
||||||
|| normalized !== path.basename(normalized)
|
function isInternalCursorPart(part: unknown): boolean {
|
||||||
) {
|
if (!part || typeof part !== 'object') {
|
||||||
throw new Error(`Invalid cursor session id "${sessionId}".`);
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = part as AnyRecord;
|
||||||
|
const type = typeof record.type === 'string' ? record.type : '';
|
||||||
|
if (type === 'user_info' || type === 'system_reminder') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isInternalCursorText(record.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapUserQueryText(value: string, role: 'user' | 'assistant'): string {
|
||||||
|
if (role !== 'user') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trimStart();
|
||||||
|
const openTag = '<user_query>';
|
||||||
|
const closeTag = '</user_query>';
|
||||||
|
if (!normalized.startsWith(openTag)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterOpen = normalized.slice(openTag.length);
|
||||||
|
const closeIndex = afterOpen.lastIndexOf(closeTag);
|
||||||
|
const inner = closeIndex >= 0 ? afterOpen.slice(0, closeIndex) : afterOpen;
|
||||||
|
return inner.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolId(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCursorToolResultContent(item: AnyRecord): string {
|
||||||
|
if (typeof item.result === 'string' && item.result.trim()) {
|
||||||
|
return item.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item.output === 'string' && item.output.trim()) {
|
||||||
|
return item.output;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(item.experimental_content)) {
|
||||||
|
const experimentalText = item.experimental_content
|
||||||
|
.map((part: unknown) => {
|
||||||
|
if (typeof part === 'string') {
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
if (part && typeof part === 'object') {
|
||||||
|
const record = part as AnyRecord;
|
||||||
|
if (typeof record.text === 'string') {
|
||||||
|
return record.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
if (experimentalText.trim()) {
|
||||||
|
return experimentalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof item.result === 'string' ? item.result : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCursorToolInput(rawInput: unknown): unknown {
|
||||||
|
if (typeof rawInput !== 'string') {
|
||||||
|
return rawInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = rawInput.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return rawInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return rawInput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCursorToolInput(toolName: string, rawInput: unknown): unknown {
|
||||||
|
const parsed = parseCursorToolInput(rawInput);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = parsed as AnyRecord;
|
||||||
|
const normalized: AnyRecord = { ...input };
|
||||||
|
|
||||||
|
const filePath = input.file_path
|
||||||
|
?? input.filePath
|
||||||
|
?? input.path
|
||||||
|
?? input.file
|
||||||
|
?? input.filename;
|
||||||
|
if (typeof filePath === 'string' && filePath.trim()) {
|
||||||
|
normalized.file_path = filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === 'Write') {
|
||||||
|
const content = input.content
|
||||||
|
?? input.text
|
||||||
|
?? input.value
|
||||||
|
?? input.contents
|
||||||
|
?? input.fileContent
|
||||||
|
?? input.new_string
|
||||||
|
?? input.newString;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
normalized.content = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === 'Edit') {
|
||||||
|
const oldString = input.old_string
|
||||||
|
?? input.oldString
|
||||||
|
?? input.old
|
||||||
|
?? '';
|
||||||
|
const newString = input.new_string
|
||||||
|
?? input.newString
|
||||||
|
?? input.new
|
||||||
|
?? input.content
|
||||||
|
?? '';
|
||||||
|
|
||||||
|
if (typeof oldString === 'string') {
|
||||||
|
normalized.old_string = oldString;
|
||||||
|
}
|
||||||
|
if (typeof newString === 'string') {
|
||||||
|
normalized.new_string = newString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === 'ApplyPatch') {
|
||||||
|
const patch = input.patch ?? input.diff ?? input.content;
|
||||||
|
if (typeof patch === 'string' && !normalized.patch) {
|
||||||
|
normalized.patch = patch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -53,7 +201,7 @@ export class CursorSessionsProvider implements IProviderSessions {
|
|||||||
const { default: Database } = await import('better-sqlite3');
|
const { default: Database } = await import('better-sqlite3');
|
||||||
|
|
||||||
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
||||||
const safeSessionId = sanitizeCursorSessionId(sessionId);
|
const safeSessionId = sanitizeLeafDirectoryName(sessionId, 'cursor session id');
|
||||||
const baseChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
|
const baseChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
|
||||||
const storeDbPath = path.join(baseChatsPath, safeSessionId, 'store.db');
|
const storeDbPath = path.join(baseChatsPath, safeSessionId, 'store.db');
|
||||||
const resolvedBaseChatsPath = path.resolve(baseChatsPath);
|
const resolvedBaseChatsPath = path.resolve(baseChatsPath);
|
||||||
@@ -225,13 +373,14 @@ export class CursorSessionsProvider implements IProviderSessions {
|
|||||||
try {
|
try {
|
||||||
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
|
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
|
||||||
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
|
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
|
||||||
const total = allNormalized.length;
|
const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result');
|
||||||
|
const total = renderableMessages.length;
|
||||||
|
|
||||||
if (limit !== null) {
|
if (limit !== null) {
|
||||||
const start = offset;
|
const start = offset;
|
||||||
const page = limit === 0
|
const page = limit === 0
|
||||||
? []
|
? []
|
||||||
: allNormalized.slice(start, start + limit);
|
: renderableMessages.slice(start, start + limit);
|
||||||
const hasMore = limit === 0
|
const hasMore = limit === 0
|
||||||
? start < total
|
? start < total
|
||||||
: start + limit < total;
|
: start + limit < total;
|
||||||
@@ -245,7 +394,7 @@ export class CursorSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: allNormalized,
|
messages: renderableMessages,
|
||||||
total,
|
total,
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -283,11 +432,24 @@ export class CursorSessionsProvider implements IProviderSessions {
|
|||||||
let text = '';
|
let text = '';
|
||||||
if (Array.isArray(content.message.content)) {
|
if (Array.isArray(content.message.content)) {
|
||||||
text = content.message.content
|
text = content.message.content
|
||||||
.map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '')
|
.map((part: string | AnyRecord) => {
|
||||||
|
if (typeof part === 'string') {
|
||||||
|
if (isInternalCursorText(part)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return unwrapUserQueryText(part, role);
|
||||||
|
}
|
||||||
|
if (isInternalCursorPart(part)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return unwrapUserQueryText(part?.text || '', role);
|
||||||
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
} else if (typeof content.message.content === 'string') {
|
} else if (typeof content.message.content === 'string') {
|
||||||
text = content.message.content;
|
if (!isInternalCursorText(content.message.content)) {
|
||||||
|
text = unwrapUserQueryText(content.message.content, role);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (text?.trim()) {
|
if (text?.trim()) {
|
||||||
messages.push(createNormalizedMessage({
|
messages.push(createNormalizedMessage({
|
||||||
@@ -316,7 +478,14 @@ export class CursorSessionsProvider implements IProviderSessions {
|
|||||||
if (item?.type !== 'tool-result') {
|
if (item?.type !== 'tool-result') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const toolCallId = item.toolCallId || content.id;
|
const cursorOptions = content.providerOptions?.cursor as AnyRecord | undefined;
|
||||||
|
const highLevelToolCallResult = cursorOptions?.highLevelToolCallResult;
|
||||||
|
const toolCallId = normalizeToolId(item.toolCallId)
|
||||||
|
|| normalizeToolId(item.tool_call_id)
|
||||||
|
|| normalizeToolId(highLevelToolCallResult?.toolCallId)
|
||||||
|
|| normalizeToolId(highLevelToolCallResult?.tool_call_id)
|
||||||
|
|| normalizeToolId(content.id)
|
||||||
|
|| '';
|
||||||
messages.push(createNormalizedMessage({
|
messages.push(createNormalizedMessage({
|
||||||
id: `${baseId}_tr`,
|
id: `${baseId}_tr`,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -324,8 +493,9 @@ export class CursorSessionsProvider implements IProviderSessions {
|
|||||||
provider: PROVIDER,
|
provider: PROVIDER,
|
||||||
kind: 'tool_result',
|
kind: 'tool_result',
|
||||||
toolId: toolCallId,
|
toolId: toolCallId,
|
||||||
content: item.result || '',
|
content: extractCursorToolResultContent(item),
|
||||||
isError: false,
|
isError: Boolean(item.isError || item.is_error),
|
||||||
|
toolUseResult: highLevelToolCallResult,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -336,8 +506,15 @@ export class CursorSessionsProvider implements IProviderSessions {
|
|||||||
if (Array.isArray(content.content)) {
|
if (Array.isArray(content.content)) {
|
||||||
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
|
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
|
||||||
const part = content.content[partIdx];
|
const part = content.content[partIdx];
|
||||||
|
if (isInternalCursorPart(part)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (part?.type === 'text' && part?.text) {
|
if (part?.type === 'text' && part?.text) {
|
||||||
|
const normalizedPartText = unwrapUserQueryText(part.text, role);
|
||||||
|
if (!normalizedPartText) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
messages.push(createNormalizedMessage({
|
messages.push(createNormalizedMessage({
|
||||||
id: `${baseId}_${partIdx}`,
|
id: `${baseId}_${partIdx}`,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -345,7 +522,7 @@ export class CursorSessionsProvider implements IProviderSessions {
|
|||||||
provider: PROVIDER,
|
provider: PROVIDER,
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
role,
|
role,
|
||||||
content: part.text,
|
content: normalizedPartText,
|
||||||
sequence: blob.sequence,
|
sequence: blob.sequence,
|
||||||
rowid: blob.rowid,
|
rowid: blob.rowid,
|
||||||
}));
|
}));
|
||||||
@@ -361,7 +538,11 @@ export class CursorSessionsProvider implements IProviderSessions {
|
|||||||
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
|
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
|
||||||
const rawToolName = part.toolName || part.name || 'Unknown Tool';
|
const rawToolName = part.toolName || part.name || 'Unknown Tool';
|
||||||
const toolName = rawToolName === 'ApplyPatch' ? 'Edit' : rawToolName;
|
const toolName = rawToolName === 'ApplyPatch' ? 'Edit' : rawToolName;
|
||||||
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
|
const toolId = normalizeToolId(part.toolCallId)
|
||||||
|
|| normalizeToolId(part.tool_call_id)
|
||||||
|
|| normalizeToolId(part.id)
|
||||||
|
|| `tool_${i}_${partIdx}`;
|
||||||
|
const normalizedToolInput = normalizeCursorToolInput(rawToolName, part.args ?? part.input);
|
||||||
const message = createNormalizedMessage({
|
const message = createNormalizedMessage({
|
||||||
id: `${baseId}_${partIdx}`,
|
id: `${baseId}_${partIdx}`,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -369,14 +550,22 @@ export class CursorSessionsProvider implements IProviderSessions {
|
|||||||
provider: PROVIDER,
|
provider: PROVIDER,
|
||||||
kind: 'tool_use',
|
kind: 'tool_use',
|
||||||
toolName,
|
toolName,
|
||||||
toolInput: part.args || part.input,
|
toolInput: normalizedToolInput,
|
||||||
toolId,
|
toolId,
|
||||||
});
|
});
|
||||||
messages.push(message);
|
messages.push(message);
|
||||||
toolUseMap.set(toolId, message);
|
toolUseMap.set(toolId, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (typeof content.content === 'string' && content.content.trim()) {
|
} else if (
|
||||||
|
typeof content.content === 'string'
|
||||||
|
&& content.content.trim()
|
||||||
|
&& !isInternalCursorText(content.content)
|
||||||
|
) {
|
||||||
|
const normalizedText = unwrapUserQueryText(content.content, role);
|
||||||
|
if (!normalizedText) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
messages.push(createNormalizedMessage({
|
messages.push(createNormalizedMessage({
|
||||||
id: baseId,
|
id: baseId,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -384,7 +573,7 @@ export class CursorSessionsProvider implements IProviderSessions {
|
|||||||
provider: PROVIDER,
|
provider: PROVIDER,
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
role,
|
role,
|
||||||
content: content.content,
|
content: normalizedText,
|
||||||
sequence: blob.sequence,
|
sequence: blob.sequence,
|
||||||
rowid: blob.rowid,
|
rowid: blob.rowid,
|
||||||
}));
|
}));
|
||||||
@@ -401,6 +590,7 @@ export class CursorSessionsProvider implements IProviderSessions {
|
|||||||
toolUse.toolResult = {
|
toolUse.toolResult = {
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
isError: msg.isError,
|
isError: msg.isError,
|
||||||
|
toolUseResult: msg.toolUseResult,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||||
|
import type { ProviderSkillSource } from '@/shared/types.js';
|
||||||
|
|
||||||
|
export class CursorSkillsProvider extends SkillsProvider {
|
||||||
|
constructor() {
|
||||||
|
super('cursor');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
scope: 'project',
|
||||||
|
rootDir: path.join(workspacePath, '.agents', 'skills'),
|
||||||
|
commandPrefix: '/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'project',
|
||||||
|
rootDir: path.join(workspacePath, '.cursor', 'skills'),
|
||||||
|
commandPrefix: '/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'user',
|
||||||
|
rootDir: path.join(os.homedir(), '.cursor', 'skills'),
|
||||||
|
commandPrefix: '/',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||||
import { CursorProviderAuth } from '@/modules/providers/list/cursor/cursor-auth.provider.js';
|
import { CursorProviderAuth } from '@/modules/providers/list/cursor/cursor-auth.provider.js';
|
||||||
|
import { CursorProviderModels } from '@/modules/providers/list/cursor/cursor-models.provider.js';
|
||||||
import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js';
|
import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js';
|
||||||
|
import { CursorSessionSynchronizer } from '@/modules/providers/list/cursor/cursor-session-synchronizer.provider.js';
|
||||||
import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js';
|
import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js';
|
||||||
import type { IProviderAuth, IProviderSessions } from '@/shared/interfaces.js';
|
import { CursorSkillsProvider } from '@/modules/providers/list/cursor/cursor-skills.provider.js';
|
||||||
|
import type {
|
||||||
|
IProviderAuth,
|
||||||
|
IProviderModels,
|
||||||
|
IProviderSessionSynchronizer,
|
||||||
|
IProviderSkills,
|
||||||
|
IProviderSessions,
|
||||||
|
} from '@/shared/interfaces.js';
|
||||||
|
|
||||||
export class CursorProvider extends AbstractProvider {
|
export class CursorProvider extends AbstractProvider {
|
||||||
|
readonly models: IProviderModels = new CursorProviderModels();
|
||||||
readonly mcp = new CursorMcpProvider();
|
readonly mcp = new CursorMcpProvider();
|
||||||
readonly auth: IProviderAuth = new CursorProviderAuth();
|
readonly auth: IProviderAuth = new CursorProviderAuth();
|
||||||
|
readonly skills: IProviderSkills = new CursorSkillsProvider();
|
||||||
readonly sessions: IProviderSessions = new CursorSessionsProvider();
|
readonly sessions: IProviderSessions = new CursorSessionsProvider();
|
||||||
|
readonly sessionSynchronizer: IProviderSessionSynchronizer = new CursorSessionSynchronizer();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('cursor');
|
super('cursor');
|
||||||
|
|||||||
@@ -15,7 +15,24 @@ type GeminiCredentialsStatus = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GeminiAuthType =
|
||||||
|
| 'oauth-personal'
|
||||||
|
| 'gemini-api-key'
|
||||||
|
| 'vertex-ai'
|
||||||
|
| 'compute-default-credentials'
|
||||||
|
| 'gateway'
|
||||||
|
| 'cloud-shell'
|
||||||
|
| null;
|
||||||
|
|
||||||
export class GeminiProviderAuth implements IProviderAuth {
|
export class GeminiProviderAuth implements IProviderAuth {
|
||||||
|
/**
|
||||||
|
* Gemini CLI can override its home root via GEMINI_CLI_HOME.
|
||||||
|
* Use the same resolution so status checks match runtime behavior.
|
||||||
|
*/
|
||||||
|
private getGeminiCliHome(): string {
|
||||||
|
return process.env.GEMINI_CLI_HOME?.trim() || os.homedir();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether the Gemini CLI is available on this host.
|
* Checks whether the Gemini CLI is available on this host.
|
||||||
*/
|
*/
|
||||||
@@ -58,6 +75,88 @@ export class GeminiProviderAuth implements IProviderAuth {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses dotenv-style key/value pairs.
|
||||||
|
*/
|
||||||
|
private parseEnvFile(content: string): Record<string, string> {
|
||||||
|
const parsed: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const rawLine of content.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLine = line.startsWith('export ')
|
||||||
|
? line.slice('export '.length).trim()
|
||||||
|
: line;
|
||||||
|
const separatorIndex = normalizedLine.indexOf('=');
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalizedLine.slice(0, separatorIndex).trim();
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = normalizedLine.slice(separatorIndex + 1).trim();
|
||||||
|
const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''));
|
||||||
|
if (quoted) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
} else {
|
||||||
|
value = value.replace(/\s+#.*$/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads user-level auth env in Gemini's "first file found" order.
|
||||||
|
*/
|
||||||
|
private async loadUserLevelAuthEnv(): Promise<Record<string, string>> {
|
||||||
|
const geminiCliHome = this.getGeminiCliHome();
|
||||||
|
const envCandidates = [
|
||||||
|
path.join(geminiCliHome, '.gemini', '.env'),
|
||||||
|
path.join(geminiCliHome, '.env'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const envPath of envCandidates) {
|
||||||
|
try {
|
||||||
|
const content = await readFile(envPath, 'utf8');
|
||||||
|
return this.parseEnvFile(content);
|
||||||
|
} catch {
|
||||||
|
// Continue to the next fallback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads Gemini's selected auth type from settings.json when available.
|
||||||
|
*/
|
||||||
|
private async readSelectedAuthType(): Promise<GeminiAuthType> {
|
||||||
|
try {
|
||||||
|
const settingsPath = path.join(this.getGeminiCliHome(), '.gemini', 'settings.json');
|
||||||
|
const content = await readFile(settingsPath, 'utf8');
|
||||||
|
const settings = readObjectRecord(JSON.parse(content));
|
||||||
|
const security = readObjectRecord(settings?.security);
|
||||||
|
const auth = readObjectRecord(security?.auth);
|
||||||
|
const selectedType = readOptionalString(auth?.selectedType);
|
||||||
|
if (!selectedType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedType as GeminiAuthType;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks Gemini credentials from API key env vars or local OAuth credential files.
|
* Checks Gemini credentials from API key env vars or local OAuth credential files.
|
||||||
*/
|
*/
|
||||||
@@ -66,8 +165,46 @@ export class GeminiProviderAuth implements IProviderAuth {
|
|||||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userEnv = await this.loadUserLevelAuthEnv();
|
||||||
|
if (readOptionalString(userEnv.GEMINI_API_KEY)) {
|
||||||
|
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedType = await this.readSelectedAuthType();
|
||||||
|
if (selectedType === 'vertex-ai') {
|
||||||
|
const hasGoogleApiKey = Boolean(
|
||||||
|
process.env.GOOGLE_API_KEY?.trim()
|
||||||
|
|| readOptionalString(userEnv.GOOGLE_API_KEY)
|
||||||
|
);
|
||||||
|
const hasProject = Boolean(
|
||||||
|
process.env.GOOGLE_CLOUD_PROJECT?.trim()
|
||||||
|
|| process.env.GOOGLE_CLOUD_PROJECT_ID?.trim()
|
||||||
|
|| readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT)
|
||||||
|
|| readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT_ID)
|
||||||
|
);
|
||||||
|
const hasLocation = Boolean(
|
||||||
|
process.env.GOOGLE_CLOUD_LOCATION?.trim()
|
||||||
|
|| readOptionalString(userEnv.GOOGLE_CLOUD_LOCATION)
|
||||||
|
);
|
||||||
|
const hasServiceAccount = Boolean(
|
||||||
|
process.env.GOOGLE_APPLICATION_CREDENTIALS?.trim()
|
||||||
|
|| readOptionalString(userEnv.GOOGLE_APPLICATION_CREDENTIALS)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasGoogleApiKey || hasServiceAccount || (hasProject && hasLocation)) {
|
||||||
|
return { authenticated: true, email: 'Vertex AI Auth', method: 'vertex_ai' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
email: null,
|
||||||
|
method: 'vertex_ai',
|
||||||
|
error: 'Gemini is set to Vertex AI, but required env vars are missing',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
const credsPath = path.join(this.getGeminiCliHome(), '.gemini', 'oauth_creds.json');
|
||||||
const content = await readFile(credsPath, 'utf8');
|
const content = await readFile(credsPath, 'utf8');
|
||||||
const creds = readObjectRecord(JSON.parse(content)) ?? {};
|
const creds = readObjectRecord(JSON.parse(content)) ?? {};
|
||||||
const accessToken = readOptionalString(creds.access_token);
|
const accessToken = readOptionalString(creds.access_token);
|
||||||
@@ -106,6 +243,25 @@ export class GeminiProviderAuth implements IProviderAuth {
|
|||||||
method: 'credentials_file',
|
method: 'credentials_file',
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
if (selectedType === 'gemini-api-key') {
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
email: null,
|
||||||
|
method: 'api_key',
|
||||||
|
error: 'Gemini is set to "Use Gemini API key", but GEMINI_API_KEY is unavailable',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedType === 'oauth-personal') {
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
email: null,
|
||||||
|
method: 'credentials_file',
|
||||||
|
error: 'Gemini is set to Google sign-in, but no cached OAuth credentials were found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no explicit auth type was selected, surface the generic "not configured" error.
|
||||||
return {
|
return {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
email: null,
|
email: null,
|
||||||
@@ -140,7 +296,7 @@ export class GeminiProviderAuth implements IProviderAuth {
|
|||||||
*/
|
*/
|
||||||
private async getActiveAccountEmail(): Promise<string | null> {
|
private async getActiveAccountEmail(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
const accPath = path.join(this.getGeminiCliHome(), '.gemini', 'google_accounts.json');
|
||||||
const accContent = await readFile(accPath, 'utf8');
|
const accContent = await readFile(accPath, 'utf8');
|
||||||
const accounts = readObjectRecord(JSON.parse(accContent));
|
const accounts = readObjectRecord(JSON.parse(accContent));
|
||||||
return readOptionalString(accounts?.active) ?? null;
|
return readOptionalString(accounts?.active) ?? null;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||||
|
import type {
|
||||||
|
ProviderChangeActiveModelInput,
|
||||||
|
ProviderCurrentActiveModel,
|
||||||
|
ProviderModelsDefinition,
|
||||||
|
ProviderSessionActiveModelChange,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
import {
|
||||||
|
buildDefaultProviderCurrentActiveModel,
|
||||||
|
writeProviderSessionActiveModelChange,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
|
export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||||
|
OPTIONS: [
|
||||||
|
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
|
||||||
|
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
|
||||||
|
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
||||||
|
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||||
|
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||||
|
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
|
||||||
|
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
||||||
|
{ value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' },
|
||||||
|
{ value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' },
|
||||||
|
],
|
||||||
|
DEFAULT: 'gemini-3.1-pro-preview',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class GeminiProviderModels implements IProviderModels {
|
||||||
|
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||||
|
return GEMINI_FALLBACK_MODELS;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
|
||||||
|
return buildDefaultProviderCurrentActiveModel(GEMINI_FALLBACK_MODELS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeActiveModel(
|
||||||
|
input: ProviderChangeActiveModelInput,
|
||||||
|
): Promise<ProviderSessionActiveModelChange> {
|
||||||
|
return writeProviderSessionActiveModelChange('gemini', input);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import {
|
||||||
|
findFilesRecursivelyCreatedAfter,
|
||||||
|
normalizeProjectPath,
|
||||||
|
normalizeSessionName,
|
||||||
|
readFileTimestamps,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||||
|
import type { AnyRecord } from '@/shared/types.js';
|
||||||
|
|
||||||
|
type ParsedSession = {
|
||||||
|
sessionId: string;
|
||||||
|
projectPath: string;
|
||||||
|
sessionName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GeminiJsonlMetadata = {
|
||||||
|
sessionId: string;
|
||||||
|
projectPath?: string;
|
||||||
|
projectHash?: string;
|
||||||
|
firstUserMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session indexer for Gemini transcript artifacts.
|
||||||
|
*/
|
||||||
|
export class GeminiSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||||
|
private readonly provider = 'gemini' as const;
|
||||||
|
private readonly geminiHome = path.join(os.homedir(), '.gemini');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans Gemini legacy JSON and new JSONL artifacts and upserts sessions into DB.
|
||||||
|
*/
|
||||||
|
async synchronize(since?: Date): Promise<number> {
|
||||||
|
const projectHashLookup = this.buildProjectHashLookup();
|
||||||
|
|
||||||
|
// const legacySessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||||
|
// path.join(this.geminiHome, 'sessions'),
|
||||||
|
// '.json',
|
||||||
|
// since ?? null
|
||||||
|
// );
|
||||||
|
// Gemini creates overlapping artifacts across `sessions/` and `tmp/`.
|
||||||
|
// We currently index only `tmp/*/chats/*.jsonl` because those files are the
|
||||||
|
// live transcript source and avoid duplicate session rows from mirrored files.
|
||||||
|
// const legacyTempFiles = await findFilesRecursivelyCreatedAfter(
|
||||||
|
// path.join(this.geminiHome, 'tmp'),
|
||||||
|
// '.json',
|
||||||
|
// since ?? null
|
||||||
|
// );
|
||||||
|
// const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||||
|
// path.join(this.geminiHome, 'sessions'),
|
||||||
|
// '.jsonl',
|
||||||
|
// since ?? null
|
||||||
|
// );
|
||||||
|
const jsonlTempFiles = await findFilesRecursivelyCreatedAfter(
|
||||||
|
path.join(this.geminiHome, 'tmp'),
|
||||||
|
'.jsonl',
|
||||||
|
since ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Current strategy: index only temp chat JSONL artifacts.
|
||||||
|
const files = [
|
||||||
|
// ...legacySessionFiles,
|
||||||
|
// Intentionally disabled to avoid duplicate indexing from mirrored
|
||||||
|
// `sessions/*.json` and `sessions/*.jsonl` artifacts.
|
||||||
|
// ...legacyTempFiles,
|
||||||
|
// ...jsonlSessionFiles,
|
||||||
|
...jsonlTempFiles,
|
||||||
|
];
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
for (const filePath of files) {
|
||||||
|
if (this.shouldSkipTempArtifact(filePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = filePath.endsWith('.jsonl')
|
||||||
|
? await this.processJsonlSessionFile(filePath, projectHashLookup)
|
||||||
|
: await this.processLegacySessionFile(filePath);
|
||||||
|
if (!parsed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamps = await readFileTimestamps(filePath);
|
||||||
|
sessionsDb.createSession(
|
||||||
|
parsed.sessionId,
|
||||||
|
this.provider,
|
||||||
|
parsed.projectPath,
|
||||||
|
parsed.sessionName,
|
||||||
|
timestamps.createdAt,
|
||||||
|
timestamps.updatedAt,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
processed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and upserts one Gemini legacy JSON or JSONL artifact.
|
||||||
|
*/
|
||||||
|
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||||
|
if (!filePath.endsWith('.json') && !filePath.endsWith('.jsonl')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.shouldSkipTempArtifact(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = filePath.endsWith('.jsonl')
|
||||||
|
? await this.processJsonlSessionFile(filePath, this.buildProjectHashLookup())
|
||||||
|
: await this.processLegacySessionFile(filePath);
|
||||||
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamps = await readFileTimestamps(filePath);
|
||||||
|
return sessionsDb.createSession(
|
||||||
|
parsed.sessionId,
|
||||||
|
this.provider,
|
||||||
|
parsed.projectPath,
|
||||||
|
parsed.sessionName,
|
||||||
|
timestamps.createdAt,
|
||||||
|
timestamps.updatedAt,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts session metadata from one Gemini legacy JSON artifact.
|
||||||
|
*/
|
||||||
|
private async processLegacySessionFile(filePath: string): Promise<ParsedSession | null> {
|
||||||
|
try {
|
||||||
|
const content = await readFile(filePath, 'utf8');
|
||||||
|
const data = JSON.parse(content) as AnyRecord;
|
||||||
|
|
||||||
|
const sessionId =
|
||||||
|
typeof data.sessionId === 'string'
|
||||||
|
? data.sessionId
|
||||||
|
: typeof data.id === 'string'
|
||||||
|
? data.id
|
||||||
|
: undefined;
|
||||||
|
if (!sessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceProjectPath = await this.resolveProjectPathFromChatWorkspace(filePath);
|
||||||
|
const projectPath = typeof data.projectPath === 'string' && data.projectPath.trim().length > 0
|
||||||
|
? data.projectPath
|
||||||
|
: workspaceProjectPath;
|
||||||
|
if (!projectPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = Array.isArray(data.messages) ? data.messages : [];
|
||||||
|
const firstMessage = messages[0] as AnyRecord | undefined;
|
||||||
|
let rawName: string | undefined;
|
||||||
|
|
||||||
|
if (Array.isArray(firstMessage?.content) && typeof firstMessage.content[0]?.text === 'string') {
|
||||||
|
rawName = firstMessage.content[0].text;
|
||||||
|
} else if (typeof firstMessage?.content === 'string') {
|
||||||
|
rawName = firstMessage.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
projectPath,
|
||||||
|
sessionName: normalizeSessionName(rawName, 'New Gemini Chat'),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts session metadata from one Gemini JSONL artifact.
|
||||||
|
*/
|
||||||
|
private async processJsonlSessionFile(
|
||||||
|
filePath: string,
|
||||||
|
projectHashLookup: Map<string, string>
|
||||||
|
): Promise<ParsedSession | null> {
|
||||||
|
const metadata = await this.extractJsonlMetadata(filePath);
|
||||||
|
if (!metadata) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let projectPath = typeof metadata.projectPath === 'string' ? metadata.projectPath.trim() : '';
|
||||||
|
if (!projectPath) {
|
||||||
|
const workspaceProjectPath = await this.resolveProjectPathFromChatWorkspace(filePath);
|
||||||
|
if (workspaceProjectPath) {
|
||||||
|
projectPath = workspaceProjectPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!projectPath && typeof metadata.projectHash === 'string') {
|
||||||
|
projectPath = projectHashLookup.get(metadata.projectHash.trim().toLowerCase()) ?? '';
|
||||||
|
}
|
||||||
|
if (!projectPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once we resolve a project hash/path pair, keep it in-memory for this sync run.
|
||||||
|
if (typeof metadata.projectHash === 'string' && metadata.projectHash.trim()) {
|
||||||
|
projectHashLookup.set(metadata.projectHash.trim().toLowerCase(), projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: metadata.sessionId,
|
||||||
|
projectPath,
|
||||||
|
sessionName: normalizeSessionName(metadata.firstUserMessage, 'New Gemini Chat'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads first useful metadata from Gemini JSONL files.
|
||||||
|
*/
|
||||||
|
private async extractJsonlMetadata(filePath: string): Promise<GeminiJsonlMetadata | null> {
|
||||||
|
try {
|
||||||
|
const content = await readFile(filePath, 'utf8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
let sessionId: string | undefined;
|
||||||
|
let projectPath: string | undefined;
|
||||||
|
let projectHash: string | undefined;
|
||||||
|
let firstUserMessage: string | undefined;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: AnyRecord;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed) as AnyRecord;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId && typeof parsed.sessionId === 'string') {
|
||||||
|
sessionId = parsed.sessionId;
|
||||||
|
}
|
||||||
|
if (!projectPath && typeof parsed.projectPath === 'string') {
|
||||||
|
projectPath = parsed.projectPath;
|
||||||
|
}
|
||||||
|
if (!projectHash && typeof parsed.projectHash === 'string') {
|
||||||
|
projectHash = parsed.projectHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firstUserMessage && parsed.type === 'user') {
|
||||||
|
firstUserMessage = this.extractGeminiTextContent(parsed.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionId && (projectPath || projectHash) && firstUserMessage) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
projectPath,
|
||||||
|
projectHash,
|
||||||
|
firstUserMessage,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to resolve project root from Gemini tmp chat workspaces.
|
||||||
|
*/
|
||||||
|
private async resolveProjectPathFromChatWorkspace(filePath: string): Promise<string> {
|
||||||
|
if (!filePath.includes(`${path.sep}chats${path.sep}`)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatsDir = path.dirname(filePath);
|
||||||
|
const workspaceDir = path.dirname(chatsDir);
|
||||||
|
const projectRootPath = path.join(workspaceDir, '.project_root');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rootContent = await readFile(projectRootPath, 'utf8');
|
||||||
|
return rootContent.trim();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a hash->path lookup for Gemini JSONL metadata that stores projectHash.
|
||||||
|
*/
|
||||||
|
private buildProjectHashLookup(): Map<string, string> {
|
||||||
|
const lookup = new Map<string, string>();
|
||||||
|
const knownPaths = new Set<string>();
|
||||||
|
|
||||||
|
for (const project of projectsDb.getProjectPaths()) {
|
||||||
|
if (typeof project.project_path === 'string' && project.project_path.trim()) {
|
||||||
|
knownPaths.add(project.project_path.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const session of sessionsDb.getAllSessions()) {
|
||||||
|
if (session.provider === this.provider && typeof session.project_path === 'string' && session.project_path.trim()) {
|
||||||
|
knownPaths.add(session.project_path.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const knownPath of knownPaths) {
|
||||||
|
this.addProjectHashCandidates(lookup, knownPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds likely Gemini hash variants for one project path.
|
||||||
|
*/
|
||||||
|
private addProjectHashCandidates(lookup: Map<string, string>, projectPath: string): void {
|
||||||
|
const trimmed = projectPath.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeProjectPath(trimmed);
|
||||||
|
const resolved = path.resolve(trimmed);
|
||||||
|
const resolvedNormalized = normalizeProjectPath(resolved);
|
||||||
|
|
||||||
|
const candidates = new Set<string>([
|
||||||
|
trimmed,
|
||||||
|
normalized,
|
||||||
|
resolved,
|
||||||
|
resolvedNormalized,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
for (const candidate of [...candidates]) {
|
||||||
|
candidates.add(candidate.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = this.sha256(candidate);
|
||||||
|
if (!lookup.has(hash)) {
|
||||||
|
lookup.set(hash, trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns first user text from Gemini content payload shapes.
|
||||||
|
*/
|
||||||
|
private extractGeminiTextContent(content: unknown): string | undefined {
|
||||||
|
if (typeof content === 'string' && content.trim().length > 0) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const part of content) {
|
||||||
|
if (typeof part === 'string' && part.trim().length > 0) {
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part && typeof part === 'object' && typeof (part as AnyRecord).text === 'string') {
|
||||||
|
const text = (part as AnyRecord).text;
|
||||||
|
if (text.trim().length > 0) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps tmp scanning scoped to chat artifacts only.
|
||||||
|
*/
|
||||||
|
private shouldSkipTempArtifact(filePath: string): boolean {
|
||||||
|
return (
|
||||||
|
filePath.startsWith(path.join(this.geminiHome, 'tmp'))
|
||||||
|
&& !filePath.includes(`${path.sep}chats${path.sep}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sha256(value: string): string {
|
||||||
|
return crypto.createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,242 @@
|
|||||||
import sessionManager from '@/sessionManager.js';
|
import fsSync from 'node:fs';
|
||||||
import { getGeminiCliSessionMessages } from '@/projects.js';
|
import fs from 'node:fs/promises';
|
||||||
|
import readline from 'node:readline';
|
||||||
|
|
||||||
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||||
|
|
||||||
const PROVIDER = 'gemini';
|
const PROVIDER = 'gemini';
|
||||||
|
|
||||||
|
type GeminiHistoryResult = {
|
||||||
|
messages: AnyRecord[];
|
||||||
|
tokenUsage?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapGeminiRole(value: unknown): 'user' | 'assistant' | null {
|
||||||
|
if (value === 'user') {
|
||||||
|
return 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'gemini' || value === 'assistant') {
|
||||||
|
return 'assistant';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGeminiTextContent(content: unknown): string {
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
.map((part) => {
|
||||||
|
if (typeof part === 'string') {
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
if (!part || typeof part !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = part as AnyRecord;
|
||||||
|
if (typeof record.text === 'string') {
|
||||||
|
return record.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGeminiThoughts(thoughts: unknown): string {
|
||||||
|
if (!Array.isArray(thoughts)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return thoughts
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = item as AnyRecord;
|
||||||
|
const subject = typeof record.subject === 'string' ? record.subject.trim() : '';
|
||||||
|
const description = typeof record.description === 'string' ? record.description.trim() : '';
|
||||||
|
|
||||||
|
if (subject && description) {
|
||||||
|
return `${subject}: ${description}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return description || subject;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGeminiTokenUsage(tokens: unknown): AnyRecord | undefined {
|
||||||
|
if (!tokens || typeof tokens !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = tokens as AnyRecord;
|
||||||
|
const input = Number(record.input || 0);
|
||||||
|
const output = Number(record.output || 0);
|
||||||
|
const total = Number(record.total || input + output || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
used: total,
|
||||||
|
inputTokens: input,
|
||||||
|
outputTokens: output,
|
||||||
|
breakdown: {
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGeminiLegacySessionMessages(sessionFilePath: string): Promise<GeminiHistoryResult> {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(sessionFilePath, 'utf8');
|
||||||
|
const session = JSON.parse(data) as AnyRecord;
|
||||||
|
const sourceMessages = Array.isArray(session.messages) ? session.messages : [];
|
||||||
|
|
||||||
|
const messages: AnyRecord[] = [];
|
||||||
|
for (const msg of sourceMessages) {
|
||||||
|
const role = mapGeminiRole(msg.type ?? msg.role);
|
||||||
|
if (!role) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
type: 'message',
|
||||||
|
uuid: typeof msg.id === 'string' ? msg.id : undefined,
|
||||||
|
message: { role, content: msg.content },
|
||||||
|
timestamp: msg.timestamp || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messages };
|
||||||
|
} catch {
|
||||||
|
return { messages: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGeminiJsonlSessionMessages(sessionFilePath: string): Promise<GeminiHistoryResult> {
|
||||||
|
const messages: AnyRecord[] = [];
|
||||||
|
let tokenUsage: AnyRecord | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileStream = fsSync.createReadStream(sessionFilePath);
|
||||||
|
const lineReader = readline.createInterface({
|
||||||
|
input: fileStream,
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const line of lineReader) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry: AnyRecord;
|
||||||
|
try {
|
||||||
|
entry = JSON.parse(trimmed) as AnyRecord;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata/update lines (e.g. {$set:{lastUpdated:...}}) do not represent chat messages.
|
||||||
|
if (entry.$set) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = mapGeminiRole(entry.type);
|
||||||
|
if (role) {
|
||||||
|
const textContent = extractGeminiTextContent(entry.content);
|
||||||
|
if (textContent.trim()) {
|
||||||
|
messages.push({
|
||||||
|
type: 'message',
|
||||||
|
uuid: typeof entry.id === 'string' ? entry.id : undefined,
|
||||||
|
message: { role, content: textContent },
|
||||||
|
timestamp: entry.timestamp || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const thinkingContent = extractGeminiThoughts(entry.thoughts);
|
||||||
|
if (thinkingContent.trim()) {
|
||||||
|
messages.push({
|
||||||
|
type: 'thinking',
|
||||||
|
uuid: typeof entry.id === 'string' ? `${entry.id}_thinking` : undefined,
|
||||||
|
message: { role: 'assistant', content: thinkingContent },
|
||||||
|
timestamp: entry.timestamp || null,
|
||||||
|
isReasoning: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'assistant') {
|
||||||
|
const usage = buildGeminiTokenUsage(entry.tokens);
|
||||||
|
if (usage) {
|
||||||
|
tokenUsage = usage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === 'tool_use') {
|
||||||
|
messages.push({
|
||||||
|
type: 'tool_use',
|
||||||
|
uuid: typeof entry.id === 'string' ? entry.id : undefined,
|
||||||
|
timestamp: entry.timestamp || null,
|
||||||
|
toolName: entry.tool_name || entry.name || 'Tool',
|
||||||
|
toolInput: entry.parameters ?? entry.input ?? entry.arguments ?? '',
|
||||||
|
toolCallId: entry.tool_id || entry.toolCallId || entry.id,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === 'tool_result') {
|
||||||
|
messages.push({
|
||||||
|
type: 'tool_result',
|
||||||
|
uuid: typeof entry.id === 'string' ? entry.id : undefined,
|
||||||
|
timestamp: entry.timestamp || null,
|
||||||
|
toolCallId: entry.tool_id || entry.toolCallId || entry.id || '',
|
||||||
|
output: entry.output ?? entry.result ?? '',
|
||||||
|
isError: Boolean(entry.error) || entry.status === 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { messages: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.sort(
|
||||||
|
(a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { messages, tokenUsage };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGeminiCliSessionMessages(sessionId: string): Promise<GeminiHistoryResult> {
|
||||||
|
const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||||
|
if (!sessionFilePath) {
|
||||||
|
return { messages: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionFilePath.endsWith('.jsonl')) {
|
||||||
|
return getGeminiJsonlSessionMessages(sessionFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getGeminiLegacySessionMessages(sessionFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
export class GeminiSessionsProvider implements IProviderSessions {
|
export class GeminiSessionsProvider implements IProviderSessions {
|
||||||
/**
|
/**
|
||||||
* Normalizes live Gemini stream-json events into the shared message shape.
|
* Normalizes live Gemini stream-json events into the shared message shape.
|
||||||
@@ -108,8 +339,7 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads Gemini history from the in-memory session manager first, then falls
|
* Loads Gemini history from Gemini CLI session files on disk.
|
||||||
* back to Gemini CLI session files on disk.
|
|
||||||
*/
|
*/
|
||||||
async fetchHistory(
|
async fetchHistory(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -117,28 +347,73 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
|||||||
): Promise<FetchHistoryResult> {
|
): Promise<FetchHistoryResult> {
|
||||||
const { limit = null, offset = 0 } = options;
|
const { limit = null, offset = 0 } = options;
|
||||||
|
|
||||||
let rawMessages: AnyRecord[];
|
let result: GeminiHistoryResult;
|
||||||
try {
|
try {
|
||||||
rawMessages = sessionManager.getSessionMessages(sessionId) as AnyRecord[];
|
result = await getGeminiCliSessionMessages(sessionId);
|
||||||
|
|
||||||
if (rawMessages.length === 0) {
|
|
||||||
rawMessages = await getGeminiCliSessionMessages(sessionId) as AnyRecord[];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message);
|
console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message);
|
||||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawMessages = result.messages;
|
||||||
const normalized: NormalizedMessage[] = [];
|
const normalized: NormalizedMessage[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < rawMessages.length; i++) {
|
for (let i = 0; i < rawMessages.length; i++) {
|
||||||
const raw = rawMessages[i];
|
const raw = rawMessages[i];
|
||||||
const ts = raw.timestamp || new Date().toISOString();
|
const ts = raw.timestamp || new Date().toISOString();
|
||||||
const baseId = raw.uuid || generateMessageId('gemini');
|
const baseId = raw.uuid || generateMessageId('gemini');
|
||||||
|
|
||||||
|
if (raw.type === 'thinking' || raw.isReasoning) {
|
||||||
|
const thinkingContent = typeof raw.message?.content === 'string'
|
||||||
|
? raw.message.content
|
||||||
|
: typeof raw.content === 'string'
|
||||||
|
? raw.content
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (thinkingContent.trim()) {
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'thinking',
|
||||||
|
content: thinkingContent,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.type === 'tool_use' || raw.toolName) {
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_use',
|
||||||
|
toolName: raw.toolName || 'Tool',
|
||||||
|
toolInput: raw.toolInput,
|
||||||
|
toolId: raw.toolCallId || baseId,
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.type === 'tool_result') {
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_result',
|
||||||
|
toolId: raw.toolCallId || '',
|
||||||
|
content: raw.output === undefined ? '' : String(raw.output),
|
||||||
|
isError: Boolean(raw.isError),
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const role = raw.message?.role || raw.role;
|
const role = raw.message?.role || raw.role;
|
||||||
const content = raw.message?.content || raw.content;
|
const content = raw.message?.content || raw.content;
|
||||||
|
|
||||||
if (!role || !content) {
|
if (!role || !content) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -147,8 +422,26 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
|||||||
|
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
for (let partIdx = 0; partIdx < content.length; partIdx++) {
|
for (let partIdx = 0; partIdx < content.length; partIdx++) {
|
||||||
const part = content[partIdx];
|
const part = content[partIdx] as AnyRecord | string;
|
||||||
if (part.type === 'text' && part.text) {
|
|
||||||
|
if (typeof part === 'string' && part.trim()) {
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}_${partIdx}`,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role: normalizedRole,
|
||||||
|
content: part,
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!part || typeof part !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((part.type === 'text' || !part.type) && typeof part.text === 'string' && part.text.trim()) {
|
||||||
normalized.push(createNormalizedMessage({
|
normalized.push(createNormalizedMessage({
|
||||||
id: `${baseId}_${partIdx}`,
|
id: `${baseId}_${partIdx}`,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -192,6 +485,19 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
|||||||
role: normalizedRole,
|
role: normalizedRole,
|
||||||
content,
|
content,
|
||||||
}));
|
}));
|
||||||
|
} else {
|
||||||
|
const textContent = extractGeminiTextContent(content);
|
||||||
|
if (textContent.trim()) {
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: ts,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role: normalizedRole,
|
||||||
|
content: textContent,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,13 +521,20 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
|||||||
const messages = pageLimit === null
|
const messages = pageLimit === null
|
||||||
? normalized.slice(start)
|
? normalized.slice(start)
|
||||||
: normalized.slice(start, start + pageLimit);
|
: normalized.slice(start, start + pageLimit);
|
||||||
|
let total = 0;
|
||||||
|
for (const msg of normalized) {
|
||||||
|
if (msg.kind !== 'tool_result') {
|
||||||
|
total += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
total: normalized.length,
|
total,
|
||||||
hasMore: pageLimit === null ? false : start + pageLimit < normalized.length,
|
hasMore: pageLimit === null ? false : start + pageLimit < normalized.length,
|
||||||
offset: start,
|
offset: start,
|
||||||
limit: pageLimit,
|
limit: pageLimit,
|
||||||
|
tokenUsage: result.tokenUsage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||||
|
import type { ProviderSkillSource } from '@/shared/types.js';
|
||||||
|
|
||||||
|
export class GeminiSkillsProvider extends SkillsProvider {
|
||||||
|
constructor() {
|
||||||
|
super('gemini');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
scope: 'user',
|
||||||
|
rootDir: path.join(os.homedir(), '.gemini', 'skills'),
|
||||||
|
commandPrefix: '/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'user',
|
||||||
|
rootDir: path.join(os.homedir(), '.agents', 'skills'),
|
||||||
|
commandPrefix: '/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'project',
|
||||||
|
rootDir: path.join(workspacePath, '.gemini', 'skills'),
|
||||||
|
commandPrefix: '/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'project',
|
||||||
|
rootDir: path.join(workspacePath, '.agents', 'skills'),
|
||||||
|
commandPrefix: '/',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||||
import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js';
|
import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js';
|
||||||
|
import { GeminiProviderModels } from '@/modules/providers/list/gemini/gemini-models.provider.js';
|
||||||
import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js';
|
import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js';
|
||||||
|
import { GeminiSessionSynchronizer } from '@/modules/providers/list/gemini/gemini-session-synchronizer.provider.js';
|
||||||
import { GeminiSessionsProvider } from '@/modules/providers/list/gemini/gemini-sessions.provider.js';
|
import { GeminiSessionsProvider } from '@/modules/providers/list/gemini/gemini-sessions.provider.js';
|
||||||
import type { IProviderAuth, IProviderSessions } from '@/shared/interfaces.js';
|
import { GeminiSkillsProvider } from '@/modules/providers/list/gemini/gemini-skills.provider.js';
|
||||||
|
import type {
|
||||||
|
IProviderAuth,
|
||||||
|
IProviderModels,
|
||||||
|
IProviderSessionSynchronizer,
|
||||||
|
IProviderSkills,
|
||||||
|
IProviderSessions,
|
||||||
|
} from '@/shared/interfaces.js';
|
||||||
|
|
||||||
export class GeminiProvider extends AbstractProvider {
|
export class GeminiProvider extends AbstractProvider {
|
||||||
|
readonly models: IProviderModels = new GeminiProviderModels();
|
||||||
readonly mcp = new GeminiMcpProvider();
|
readonly mcp = new GeminiMcpProvider();
|
||||||
readonly auth: IProviderAuth = new GeminiProviderAuth();
|
readonly auth: IProviderAuth = new GeminiProviderAuth();
|
||||||
|
readonly skills: IProviderSkills = new GeminiSkillsProvider();
|
||||||
readonly sessions: IProviderSessions = new GeminiSessionsProvider();
|
readonly sessions: IProviderSessions = new GeminiSessionsProvider();
|
||||||
|
readonly sessionSynchronizer: IProviderSessionSynchronizer = new GeminiSessionSynchronizer();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('gemini');
|
super('gemini');
|
||||||
|
|||||||
111
server/modules/providers/list/opencode/opencode-auth.provider.ts
Normal file
111
server/modules/providers/list/opencode/opencode-auth.provider.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import spawn from 'cross-spawn';
|
||||||
|
|
||||||
|
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||||
|
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||||
|
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type OpenCodeCredentialsStatus = {
|
||||||
|
authenticated: boolean;
|
||||||
|
email: string | null;
|
||||||
|
method: string | null;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPENCODE_ENV_CREDENTIAL_KEYS = [
|
||||||
|
'ANTHROPIC_API_KEY',
|
||||||
|
'OPENAI_API_KEY',
|
||||||
|
'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||||
|
'GEMINI_API_KEY',
|
||||||
|
'GROQ_API_KEY',
|
||||||
|
'OPENROUTER_API_KEY',
|
||||||
|
];
|
||||||
|
|
||||||
|
export class OpenCodeProviderAuth implements IProviderAuth {
|
||||||
|
/**
|
||||||
|
* Checks whether the OpenCode CLI is available to the server process.
|
||||||
|
*/
|
||||||
|
private checkInstalled(): boolean {
|
||||||
|
try {
|
||||||
|
const result = spawn.sync('opencode', ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||||
|
return !result.error && result.status === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns OpenCode CLI installation and credential status.
|
||||||
|
*/
|
||||||
|
async getStatus(): Promise<ProviderAuthStatus> {
|
||||||
|
const installed = this.checkInstalled();
|
||||||
|
const credentials = await this.checkCredentials();
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed,
|
||||||
|
provider: 'opencode',
|
||||||
|
authenticated: credentials.authenticated,
|
||||||
|
email: credentials.email,
|
||||||
|
method: credentials.method,
|
||||||
|
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads OpenCode's auth store or falls back to provider API key environment variables.
|
||||||
|
*/
|
||||||
|
private async checkCredentials(): Promise<OpenCodeCredentialsStatus> {
|
||||||
|
try {
|
||||||
|
const authPath = path.join(os.homedir(), '.local', 'share', 'opencode', 'auth.json');
|
||||||
|
const content = await readFile(authPath, 'utf8');
|
||||||
|
const auth = readObjectRecord(JSON.parse(content)) ?? {};
|
||||||
|
|
||||||
|
for (const [providerId, providerAuth] of Object.entries(auth)) {
|
||||||
|
const providerRecord = readObjectRecord(providerAuth);
|
||||||
|
if (!providerRecord) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCredential = Object.values(providerRecord).some(
|
||||||
|
(value) => readOptionalString(value) !== undefined || Boolean(readObjectRecord(value)),
|
||||||
|
);
|
||||||
|
if (hasCredential) {
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
email: `${providerId} credentials`,
|
||||||
|
method: 'credentials_file',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
if (code !== 'ENOENT') {
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
email: null,
|
||||||
|
method: null,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to read OpenCode auth',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const envCredential = OPENCODE_ENV_CREDENTIAL_KEYS.find((key) => process.env[key]?.trim());
|
||||||
|
if (envCredential) {
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
email: envCredential,
|
||||||
|
method: 'environment',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
email: null,
|
||||||
|
method: null,
|
||||||
|
error: 'OpenCode not configured',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
228
server/modules/providers/list/opencode/opencode-mcp.provider.ts
Normal file
228
server/modules/providers/list/opencode/opencode-mcp.provider.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
|
||||||
|
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||||
|
import {
|
||||||
|
AppError,
|
||||||
|
readObjectRecord,
|
||||||
|
readOptionalString,
|
||||||
|
readStringArray,
|
||||||
|
readStringRecord,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type OpenCodeConfigPath = {
|
||||||
|
filePath: string;
|
||||||
|
exists: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileExists = async (filePath: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await access(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes JSONC comments without touching comment-like text inside strings.
|
||||||
|
*/
|
||||||
|
const stripJsonComments = (content: string): string => {
|
||||||
|
let output = '';
|
||||||
|
let inString = false;
|
||||||
|
let quote = '';
|
||||||
|
let escaped = false;
|
||||||
|
|
||||||
|
for (let index = 0; index < content.length; index += 1) {
|
||||||
|
const char = content[index];
|
||||||
|
const next = content[index + 1];
|
||||||
|
|
||||||
|
if (inString) {
|
||||||
|
output += char;
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (char === '\\') {
|
||||||
|
escaped = true;
|
||||||
|
} else if (char === quote) {
|
||||||
|
inString = false;
|
||||||
|
quote = '';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"' || char === '\'') {
|
||||||
|
inString = true;
|
||||||
|
quote = char;
|
||||||
|
output += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '/' && next === '/') {
|
||||||
|
while (index < content.length && content[index] !== '\n') {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
output += '\n';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '/' && next === '*') {
|
||||||
|
index += 2;
|
||||||
|
while (index < content.length && !(content[index] === '*' && content[index + 1] === '/')) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
output += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripTrailingCommas = (content: string): string =>
|
||||||
|
content.replace(/,\s*([}\]])/g, '$1');
|
||||||
|
|
||||||
|
const readOpenCodeConfig = async (filePath: string): Promise<Record<string, unknown>> => {
|
||||||
|
try {
|
||||||
|
const content = await readFile(filePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(stripTrailingCommas(stripJsonComments(content))) as unknown;
|
||||||
|
return readObjectRecord(parsed) ?? {};
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
if (code === 'ENOENT') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeOpenCodeConfig = async (filePath: string, data: Record<string, unknown>): Promise<void> => {
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveOpenCodeConfigPath = async (scope: McpScope, workspacePath: string): Promise<OpenCodeConfigPath> => {
|
||||||
|
const root = scope === 'user'
|
||||||
|
? path.join(os.homedir(), '.config', 'opencode')
|
||||||
|
: workspacePath;
|
||||||
|
const jsonPath = path.join(root, 'opencode.json');
|
||||||
|
const jsoncPath = path.join(root, 'opencode.jsonc');
|
||||||
|
|
||||||
|
if (await fileExists(jsonPath)) {
|
||||||
|
return { filePath: jsonPath, exists: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await fileExists(jsoncPath)) {
|
||||||
|
return { filePath: jsoncPath, exists: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { filePath: jsonPath, exists: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OpenCodeMcpProvider extends McpProvider {
|
||||||
|
constructor() {
|
||||||
|
super('opencode', ['user', 'project'], ['stdio', 'http']);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||||
|
const { filePath } = await resolveOpenCodeConfigPath(scope, workspacePath);
|
||||||
|
const config = await readOpenCodeConfig(filePath);
|
||||||
|
return readObjectRecord(config.mcp) ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async writeScopedServers(
|
||||||
|
scope: McpScope,
|
||||||
|
workspacePath: string,
|
||||||
|
servers: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const { filePath } = await resolveOpenCodeConfigPath(scope, workspacePath);
|
||||||
|
const config = await readOpenCodeConfig(filePath);
|
||||||
|
config.mcp = servers;
|
||||||
|
await writeOpenCodeConfig(filePath, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||||
|
if (input.transport === 'stdio') {
|
||||||
|
if (!input.command?.trim()) {
|
||||||
|
throw new AppError('command is required for stdio MCP servers.', {
|
||||||
|
code: 'MCP_COMMAND_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'local',
|
||||||
|
command: [input.command, ...(input.args ?? [])],
|
||||||
|
enabled: true,
|
||||||
|
environment: input.env ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.url?.trim()) {
|
||||||
|
throw new AppError('url is required for http MCP servers.', {
|
||||||
|
code: 'MCP_URL_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'remote',
|
||||||
|
url: input.url,
|
||||||
|
enabled: true,
|
||||||
|
headers: input.headers ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected normalizeServerConfig(
|
||||||
|
scope: McpScope,
|
||||||
|
name: string,
|
||||||
|
rawConfig: unknown,
|
||||||
|
): ProviderMcpServer | null {
|
||||||
|
const config = readObjectRecord(rawConfig);
|
||||||
|
if (!config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.type === 'local' || config.command !== undefined) {
|
||||||
|
const commandParts = typeof config.command === 'string'
|
||||||
|
? [config.command, ...(readStringArray(config.args) ?? [])]
|
||||||
|
: readStringArray(config.command);
|
||||||
|
const command = commandParts?.[0];
|
||||||
|
if (!command) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: 'opencode',
|
||||||
|
name,
|
||||||
|
scope,
|
||||||
|
transport: 'stdio',
|
||||||
|
command,
|
||||||
|
args: commandParts.slice(1),
|
||||||
|
env: readStringRecord(config.environment) ?? readStringRecord(config.env),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.type === 'remote' || typeof config.url === 'string') {
|
||||||
|
const url = readOptionalString(config.url);
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: 'opencode',
|
||||||
|
name,
|
||||||
|
scope,
|
||||||
|
transport: 'http',
|
||||||
|
url,
|
||||||
|
headers: readStringRecord(config.headers),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import crossSpawn from 'cross-spawn';
|
||||||
|
|
||||||
|
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||||
|
import type {
|
||||||
|
ProviderChangeActiveModelInput,
|
||||||
|
ProviderCurrentActiveModel,
|
||||||
|
ProviderModelOption,
|
||||||
|
ProviderModelsDefinition,
|
||||||
|
ProviderSessionActiveModelChange,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
import {
|
||||||
|
buildDefaultProviderCurrentActiveModel,
|
||||||
|
getOpenCodeDatabasePath,
|
||||||
|
readObjectRecord,
|
||||||
|
readOptionalString,
|
||||||
|
writeProviderSessionActiveModelChange,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
|
export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||||
|
OPTIONS: [
|
||||||
|
{
|
||||||
|
value: 'anthropic/claude-sonnet-4-5',
|
||||||
|
label: 'Claude Sonnet 4.5',
|
||||||
|
description: 'anthropic - anthropic/claude-sonnet-4-5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'anthropic/claude-opus-4-1',
|
||||||
|
label: 'Claude Opus 4.1',
|
||||||
|
description: 'anthropic - anthropic/claude-opus-4-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'anthropic/claude-haiku-4-5',
|
||||||
|
label: 'Claude Haiku 4.5',
|
||||||
|
description: 'anthropic - anthropic/claude-haiku-4-5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'openai/gpt-5.1',
|
||||||
|
label: 'GPT-5.1',
|
||||||
|
description: 'openai - openai/gpt-5.1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'openai/gpt-5.1-codex',
|
||||||
|
label: 'GPT-5.1 Codex',
|
||||||
|
description: 'openai - openai/gpt-5.1-codex',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'openai/gpt-5.4-mini',
|
||||||
|
label: 'GPT-5.4 Mini',
|
||||||
|
description: 'openai - openai/gpt-5.4-mini',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'google/gemini-2.5-pro',
|
||||||
|
label: 'Gemini 2.5 Pro',
|
||||||
|
description: 'google - google/gemini-2.5-pro',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'google/gemini-2.5-flash',
|
||||||
|
label: 'Gemini 2.5 Flash',
|
||||||
|
description: 'google - google/gemini-2.5-flash',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
DEFAULT: 'anthropic/claude-sonnet-4-5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPEN_CODE_MODELS_TIMEOUT_MS = 20_000;
|
||||||
|
const MODEL_ID_LINE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
|
||||||
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
|
const DATE_TOKEN = /^\d{8}$/;
|
||||||
|
const SIMPLE_NUMBER_TOKEN = /^\d$/;
|
||||||
|
const VERSION_TOKEN = /^[a-z]\d+$/i;
|
||||||
|
const NUMERIC_TOKEN = /^\d+(?:\.\d+)*$/;
|
||||||
|
const SHORT_ACRONYM_TOKEN = /^[a-z]{2,3}$/;
|
||||||
|
|
||||||
|
export const parseOpenCodeModelsStdout = (stdout: string): string[] => {
|
||||||
|
const ids: string[] = [];
|
||||||
|
|
||||||
|
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('{') || line.startsWith('[')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MODEL_ID_LINE.test(line)) {
|
||||||
|
ids.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(ids)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateToken = (token: string): string => (
|
||||||
|
`${token.slice(0, 4)}-${token.slice(4, 6)}-${token.slice(6, 8)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatModelToken = (token: string, nextToken?: string): string => {
|
||||||
|
const lower = token.toLowerCase();
|
||||||
|
|
||||||
|
if (VERSION_TOKEN.test(token)) {
|
||||||
|
return token.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SHORT_ACRONYM_TOKEN.test(lower) && nextToken && NUMERIC_TOKEN.test(nextToken)) {
|
||||||
|
return token.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatOpenCodeModelSlug = (slug: string): string => {
|
||||||
|
const labelParts: string[] = [];
|
||||||
|
const dateParts: string[] = [];
|
||||||
|
const tokens = slug.split('-').filter(Boolean);
|
||||||
|
|
||||||
|
for (let index = 0; index < tokens.length; index += 1) {
|
||||||
|
const token = tokens[index];
|
||||||
|
const nextToken = tokens[index + 1];
|
||||||
|
|
||||||
|
if (DATE_TOKEN.test(token)) {
|
||||||
|
dateParts.push(formatDateToken(token));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SIMPLE_NUMBER_TOKEN.test(token) && nextToken && SIMPLE_NUMBER_TOKEN.test(nextToken)) {
|
||||||
|
labelParts.push(`${token}.${nextToken}`);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
labelParts.push(formatModelToken(token, nextToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = (labelParts.join(' ').trim() || slug).replace(/^GPT\s+/, 'GPT-');
|
||||||
|
if (dateParts.length === 0) {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${label} (${dateParts.join(', ')})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readOpenCodeModelParts = (id: string): { upstreamProvider: string; slug: string } => {
|
||||||
|
const separatorIndex = id.indexOf('/');
|
||||||
|
if (separatorIndex < 0) {
|
||||||
|
return {
|
||||||
|
upstreamProvider: '',
|
||||||
|
slug: id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
upstreamProvider: id.slice(0, separatorIndex),
|
||||||
|
slug: id.slice(separatorIndex + 1),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelForOpenCodeModelId = (id: string): string => {
|
||||||
|
const fallbackLabel = OPENCODE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === id)?.label;
|
||||||
|
if (fallbackLabel) {
|
||||||
|
return fallbackLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug } = readOpenCodeModelParts(id);
|
||||||
|
return formatOpenCodeModelSlug(slug);
|
||||||
|
};
|
||||||
|
|
||||||
|
const descriptionForOpenCodeModelId = (id: string): string => {
|
||||||
|
const { upstreamProvider } = readOpenCodeModelParts(id);
|
||||||
|
return upstreamProvider ? `${upstreamProvider} - ${id}` : id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => {
|
||||||
|
const options: ProviderModelOption[] = ids.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: labelForOpenCodeModelId(value),
|
||||||
|
description: descriptionForOpenCodeModelId(value),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value
|
||||||
|
?? options[0]?.value
|
||||||
|
?? OPENCODE_FALLBACK_MODELS.DEFAULT;
|
||||||
|
|
||||||
|
return {
|
||||||
|
OPTIONS: options,
|
||||||
|
DEFAULT: defaultValue,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseOpenCodeSessionModelValue = (rawModel: unknown): string | null => {
|
||||||
|
if (typeof rawModel === 'string') {
|
||||||
|
const trimmed = rawModel.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return parseOpenCodeSessionModelValue(JSON.parse(trimmed));
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = readObjectRecord(rawModel);
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return readOptionalString(record.id)
|
||||||
|
?? readOptionalString(record.model)
|
||||||
|
?? readOptionalString(record.name)
|
||||||
|
?? readOptionalString(record.value)
|
||||||
|
?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runOpenCodeModelsCommand = (): Promise<string> => new Promise((resolve, reject) => {
|
||||||
|
const openCodeProcess = spawnFunction('opencode', ['models'], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: { ...process.env },
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
openCodeProcess.kill('SIGTERM');
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(new Error('opencode models timed out'));
|
||||||
|
}
|
||||||
|
}, OPEN_CODE_MODELS_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const finish = (error: Error | null, output: string) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(output);
|
||||||
|
};
|
||||||
|
|
||||||
|
openCodeProcess.stdout?.on('data', (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
openCodeProcess.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
openCodeProcess.on('error', (error) => {
|
||||||
|
finish(error instanceof Error ? error : new Error(String(error)), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
openCodeProcess.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
finish(new Error(stderr.trim() || `opencode models exited with code ${code}`), '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(null, stdout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export class OpenCodeProviderModels implements IProviderModels {
|
||||||
|
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||||
|
try {
|
||||||
|
const stdout = await runOpenCodeModelsCommand();
|
||||||
|
const ids = parseOpenCodeModelsStdout(stdout);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return OPENCODE_FALLBACK_MODELS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildOpenCodeDefinitionFromIds(ids);
|
||||||
|
} catch {
|
||||||
|
return OPENCODE_FALLBACK_MODELS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
|
||||||
|
if (!sessionId?.trim()) {
|
||||||
|
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbPath = getOpenCodeDatabasePath();
|
||||||
|
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
s.id AS sessionId,
|
||||||
|
s.model AS model,
|
||||||
|
s.agent AS agent,
|
||||||
|
s.directory AS directory,
|
||||||
|
s.time_updated AS timeUpdated,
|
||||||
|
s.time_created AS timeCreated
|
||||||
|
FROM session s
|
||||||
|
WHERE s.id = ?
|
||||||
|
ORDER BY COALESCE(s.time_updated, s.time_created, 0) DESC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(sessionId) as {
|
||||||
|
sessionId?: string;
|
||||||
|
model?: unknown;
|
||||||
|
agent?: string | null;
|
||||||
|
directory?: string | null;
|
||||||
|
timeUpdated?: number | null;
|
||||||
|
timeCreated?: number | null;
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
const model = parseOpenCodeSessionModelValue(row?.model);
|
||||||
|
if (model) {
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to the provider default when OpenCode session lookup fails.
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeActiveModel(
|
||||||
|
input: ProviderChangeActiveModelInput,
|
||||||
|
): Promise<ProviderSessionActiveModelChange> {
|
||||||
|
return writeProviderSessionActiveModelChange('opencode', input);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import fsSync from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||||
|
import {
|
||||||
|
getOpenCodeDatabasePath,
|
||||||
|
normalizeProviderTimestamp,
|
||||||
|
normalizeSessionName,
|
||||||
|
readJsonRecord,
|
||||||
|
readOptionalString,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type OpenCodeSessionRow = {
|
||||||
|
id: string;
|
||||||
|
directory: string | null;
|
||||||
|
title: string | null;
|
||||||
|
time_created: number | null;
|
||||||
|
time_updated: number | null;
|
||||||
|
worktree: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SynchronizeRowsResult = {
|
||||||
|
processed: number;
|
||||||
|
firstSessionId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session indexer for OpenCode's SQLite-backed session store.
|
||||||
|
*/
|
||||||
|
export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||||
|
private readonly provider = 'opencode' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans OpenCode's shared opencode.db and upserts active sessions into DB.
|
||||||
|
*/
|
||||||
|
async synchronize(since?: Date): Promise<number> {
|
||||||
|
const result = this.synchronizeRows(since);
|
||||||
|
return result.processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles watcher changes for opencode.db.
|
||||||
|
*/
|
||||||
|
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||||
|
if (path.basename(filePath) !== 'opencode.db') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.synchronizeRows(undefined, 1);
|
||||||
|
return result.firstSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronizeRows(since?: Date, limit?: number): SynchronizeRowsResult {
|
||||||
|
const dbPath = getOpenCodeDatabasePath();
|
||||||
|
if (!fsSync.existsSync(dbPath)) {
|
||||||
|
return { processed: 0, firstSessionId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||||
|
try {
|
||||||
|
const sinceMillis = since?.getTime() ?? null;
|
||||||
|
const limitClause = limit ? 'LIMIT ?' : '';
|
||||||
|
const params = limit ? [sinceMillis, sinceMillis, limit] : [sinceMillis, sinceMillis];
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
s.id AS id,
|
||||||
|
s.directory AS directory,
|
||||||
|
s.title AS title,
|
||||||
|
s.time_created AS time_created,
|
||||||
|
s.time_updated AS time_updated,
|
||||||
|
p.worktree AS worktree
|
||||||
|
FROM session s
|
||||||
|
LEFT JOIN project p ON p.id = s.project_id
|
||||||
|
WHERE s.time_archived IS NULL
|
||||||
|
AND (? IS NULL OR COALESCE(s.time_updated, s.time_created, 0) >= ?)
|
||||||
|
ORDER BY COALESCE(s.time_updated, s.time_created, 0) DESC, s.id DESC
|
||||||
|
${limitClause}
|
||||||
|
`).all(...params) as OpenCodeSessionRow[];
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
let firstSessionId: string | null = null;
|
||||||
|
for (const row of rows) {
|
||||||
|
const indexedSessionId = this.upsertSession(db, row);
|
||||||
|
if (!indexedSessionId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firstSessionId) {
|
||||||
|
firstSessionId = indexedSessionId;
|
||||||
|
}
|
||||||
|
processed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { processed, firstSessionId };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.warn('[OpenCodeProvider] Failed to synchronize sessions:', message);
|
||||||
|
return { processed: 0, firstSessionId: null };
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private upsertSession(db: Database.Database, row: OpenCodeSessionRow): string | null {
|
||||||
|
const sessionId = readOptionalString(row.id);
|
||||||
|
const projectPath = readOptionalString(row.directory) ?? readOptionalString(row.worktree);
|
||||||
|
if (!sessionId || !projectPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackTitle = 'Untitled OpenCode Session';
|
||||||
|
const existingSession = sessionsDb.getSessionById(sessionId);
|
||||||
|
const existingName = existingSession?.custom_name;
|
||||||
|
const nextName = existingName && existingName !== fallbackTitle
|
||||||
|
? existingName
|
||||||
|
: readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId);
|
||||||
|
|
||||||
|
// OpenCode stores every session in one shared sqlite database, so jsonl_path
|
||||||
|
// must stay null to avoid deleting opencode.db when one app session is removed.
|
||||||
|
sessionsDb.createSession(
|
||||||
|
sessionId,
|
||||||
|
this.provider,
|
||||||
|
projectPath,
|
||||||
|
normalizeSessionName(nextName, fallbackTitle),
|
||||||
|
normalizeProviderTimestamp(row.time_created),
|
||||||
|
normalizeProviderTimestamp(row.time_updated ?? row.time_created),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readFirstUserText(db: Database.Database, sessionId: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT p.data AS data
|
||||||
|
FROM message m
|
||||||
|
INNER JOIN part p
|
||||||
|
ON p.session_id = m.session_id
|
||||||
|
AND p.message_id = m.id
|
||||||
|
WHERE m.session_id = ?
|
||||||
|
AND json_extract(m.data, '$.role') = 'user'
|
||||||
|
AND json_extract(p.data, '$.type') = 'text'
|
||||||
|
ORDER BY COALESCE(m.time_created, 0), COALESCE(p.time_created, 0)
|
||||||
|
LIMIT 1
|
||||||
|
`).get(sessionId) as { data: string | null } | undefined;
|
||||||
|
|
||||||
|
const data = readJsonRecord(row?.data);
|
||||||
|
return readOptionalString(data?.text);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
import fsSync from 'node:fs';
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||||
|
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||||
|
import {
|
||||||
|
createNormalizedMessage,
|
||||||
|
generateMessageId,
|
||||||
|
getOpenCodeDatabasePath,
|
||||||
|
normalizeProviderTimestamp,
|
||||||
|
readObjectRecord,
|
||||||
|
readJsonRecord,
|
||||||
|
readOptionalString,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
|
const PROVIDER = 'opencode';
|
||||||
|
|
||||||
|
type OpenCodeHistoryRow = {
|
||||||
|
message_id: string;
|
||||||
|
message_time_created: number | null;
|
||||||
|
message_data: string | null;
|
||||||
|
part_id: string | null;
|
||||||
|
part_time_created: number | null;
|
||||||
|
part_data: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenCodeTokenTotals = {
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
reasoningTokens: number;
|
||||||
|
cacheReadTokens: number;
|
||||||
|
cacheWriteTokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openOpenCodeDatabase = (): Database.Database | null => {
|
||||||
|
const dbPath = getOpenCodeDatabasePath();
|
||||||
|
if (!fsSync.existsSync(dbPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatToolContent = (value: unknown): string => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenCode can persist the first prompt as a JSON string literal inside a text
|
||||||
|
* part, for example `"hello"` instead of `hello`. Decode only complete JSON
|
||||||
|
* string literals so normal assistant/user prose remains untouched.
|
||||||
|
*/
|
||||||
|
const unwrapJsonStringLiteral = (value: string): string => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
return typeof parsed === 'string' ? parsed : value;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractText = (value: unknown): string => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return unwrapJsonStringLiteral(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = readObjectRecord(value);
|
||||||
|
const text = readOptionalString(record?.text)
|
||||||
|
?? readOptionalString(record?.content)
|
||||||
|
?? '';
|
||||||
|
return unwrapJsonStringLiteral(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasUserRole = (value: unknown): boolean => {
|
||||||
|
const record = readObjectRecord(value);
|
||||||
|
return readOptionalString(record?.role) === 'user';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUserTextEcho = (raw: AnyRecord): boolean => {
|
||||||
|
return readOptionalString(raw.role) === 'user'
|
||||||
|
|| hasUserRole(raw.message)
|
||||||
|
|| hasUserRole(raw.part);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | undefined => {
|
||||||
|
if (!totals) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputTokens = totals.inputTokens;
|
||||||
|
const displayInputTokens = inputTokens + totals.cacheReadTokens;
|
||||||
|
const outputTokens = totals.outputTokens;
|
||||||
|
const used = inputTokens
|
||||||
|
+ outputTokens
|
||||||
|
+ totals.reasoningTokens
|
||||||
|
+ totals.cacheReadTokens
|
||||||
|
+ totals.cacheWriteTokens;
|
||||||
|
|
||||||
|
if (used <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
used,
|
||||||
|
inputTokens: displayInputTokens,
|
||||||
|
outputTokens,
|
||||||
|
breakdown: {
|
||||||
|
input: displayInputTokens,
|
||||||
|
output: outputTokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readOpenCodeSessionColumnTokenUsage = (
|
||||||
|
db: Database.Database,
|
||||||
|
sessionId: string,
|
||||||
|
): AnyRecord | undefined => {
|
||||||
|
const columns = db.prepare('PRAGMA table_info(session)').all() as { name: string }[];
|
||||||
|
const columnNames = new Set(columns.map((column) => column.name));
|
||||||
|
const requiredColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write'];
|
||||||
|
if (!requiredColumns.every((column) => columnNames.has(column))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
tokens_input AS inputTokens,
|
||||||
|
tokens_output AS outputTokens,
|
||||||
|
tokens_reasoning AS reasoningTokens,
|
||||||
|
tokens_cache_read AS cacheReadTokens,
|
||||||
|
tokens_cache_write AS cacheWriteTokens
|
||||||
|
FROM session
|
||||||
|
WHERE id = ?
|
||||||
|
`).get(sessionId) as OpenCodeTokenTotals | undefined;
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildTokenUsage({
|
||||||
|
inputTokens: Number(row.inputTokens ?? 0),
|
||||||
|
outputTokens: Number(row.outputTokens ?? 0),
|
||||||
|
reasoningTokens: Number(row.reasoningTokens ?? 0),
|
||||||
|
cacheReadTokens: Number(row.cacheReadTokens ?? 0),
|
||||||
|
cacheWriteTokens: Number(row.cacheWriteTokens ?? 0),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenCode stores per-message token counts on assistant `message.data` objects
|
||||||
|
* (see MessageV2.Assistant). Older DBs also had session-level counters; this
|
||||||
|
* matches current `opencode.db` layouts that only persist message JSON.
|
||||||
|
*/
|
||||||
|
const aggregateOpenCodeSessionTokenUsage = (
|
||||||
|
db: Database.Database,
|
||||||
|
sessionId: string,
|
||||||
|
): AnyRecord | undefined => {
|
||||||
|
const sessionColumnUsage = readOpenCodeSessionColumnTokenUsage(db, sessionId);
|
||||||
|
if (sessionColumnUsage) {
|
||||||
|
return sessionColumnUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = db.prepare('SELECT data FROM message WHERE session_id = ?').all(sessionId) as { data: string }[];
|
||||||
|
|
||||||
|
let inputTokens = 0;
|
||||||
|
let outputTokens = 0;
|
||||||
|
let reasoningTokens = 0;
|
||||||
|
let cacheReadTokens = 0;
|
||||||
|
let cacheWriteTokens = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const info = readJsonRecord(row.data);
|
||||||
|
if (readOptionalString(info?.role) !== 'assistant') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = readObjectRecord(info?.tokens);
|
||||||
|
if (!tokens) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputTokens += Number(tokens.input ?? 0);
|
||||||
|
outputTokens += Number(tokens.output ?? 0);
|
||||||
|
reasoningTokens += Number(tokens.reasoning ?? 0);
|
||||||
|
const cache = readObjectRecord(tokens.cache);
|
||||||
|
cacheReadTokens += Number(cache?.read ?? 0);
|
||||||
|
cacheWriteTokens += Number(cache?.write ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildTokenUsage({
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
reasoningTokens,
|
||||||
|
cacheReadTokens,
|
||||||
|
cacheWriteTokens,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OpenCodeSessionsProvider implements IProviderSessions {
|
||||||
|
/**
|
||||||
|
* Normalizes live `opencode run --format json` events into frontend messages.
|
||||||
|
*/
|
||||||
|
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
||||||
|
const raw = readObjectRecord(rawMessage);
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = readOptionalString(raw.type) ?? readOptionalString(raw.event);
|
||||||
|
const eventSessionId = readOptionalString(raw.sessionID) ?? readOptionalString(raw.sessionId) ?? sessionId;
|
||||||
|
const timestamp = normalizeProviderTimestamp(raw.time ?? raw.timestamp);
|
||||||
|
const baseId = readOptionalString(raw.id)
|
||||||
|
?? readOptionalString(raw.messageID)
|
||||||
|
?? generateMessageId('opencode');
|
||||||
|
|
||||||
|
if (type === 'text') {
|
||||||
|
// The client already renders an optimistic user bubble, so provider user
|
||||||
|
// echoes must not be streamed back as assistant text.
|
||||||
|
if (isUserTextEcho(raw)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = extractText(raw.text ?? raw.delta ?? raw.message);
|
||||||
|
if (!content.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId: eventSessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'stream_delta',
|
||||||
|
content,
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'reasoning') {
|
||||||
|
const content = extractText(raw.text ?? raw.delta ?? raw.message);
|
||||||
|
if (!content.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId: eventSessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'thinking',
|
||||||
|
content,
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'tool_use') {
|
||||||
|
const toolName = readOptionalString(raw.tool) ?? readOptionalString(raw.name) ?? 'Tool';
|
||||||
|
const toolId = readOptionalString(raw.callID) ?? readOptionalString(raw.toolCallId) ?? baseId;
|
||||||
|
const toolMessage = createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId: eventSessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_use',
|
||||||
|
toolName,
|
||||||
|
toolInput: raw.input ?? raw.arguments ?? {},
|
||||||
|
toolId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (raw.output !== undefined || raw.error !== undefined) {
|
||||||
|
toolMessage.toolResult = {
|
||||||
|
content: formatToolContent(raw.output ?? raw.error),
|
||||||
|
isError: raw.error !== undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return [toolMessage];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'error') {
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId: eventSessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'error',
|
||||||
|
content: readOptionalString(raw.error) ?? readOptionalString(raw.message) ?? 'Unknown OpenCode error',
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'step_finish') {
|
||||||
|
return [createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId: eventSessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'stream_end',
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads OpenCode history from the shared SQLite session database.
|
||||||
|
*/
|
||||||
|
async fetchHistory(
|
||||||
|
sessionId: string,
|
||||||
|
options: FetchHistoryOptions = {},
|
||||||
|
): Promise<FetchHistoryResult> {
|
||||||
|
const { limit = null, offset = 0 } = options;
|
||||||
|
const db = openOpenCodeDatabase();
|
||||||
|
if (!db) {
|
||||||
|
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
m.id AS message_id,
|
||||||
|
m.time_created AS message_time_created,
|
||||||
|
m.data AS message_data,
|
||||||
|
p.id AS part_id,
|
||||||
|
p.time_created AS part_time_created,
|
||||||
|
p.data AS part_data
|
||||||
|
FROM message m
|
||||||
|
LEFT JOIN part p
|
||||||
|
ON p.session_id = m.session_id
|
||||||
|
AND p.message_id = m.id
|
||||||
|
WHERE m.session_id = ?
|
||||||
|
ORDER BY
|
||||||
|
COALESCE(m.time_created, 0),
|
||||||
|
m.id,
|
||||||
|
COALESCE(p.time_created, 0),
|
||||||
|
p.id
|
||||||
|
`).all(sessionId) as OpenCodeHistoryRow[];
|
||||||
|
|
||||||
|
const normalized = this.normalizeHistoryRows(rows, sessionId);
|
||||||
|
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, sessionId);
|
||||||
|
|
||||||
|
const normalizedOffset = Math.max(0, offset);
|
||||||
|
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||||
|
const total = normalized.length;
|
||||||
|
const messages = normalizedLimit === null
|
||||||
|
? normalized
|
||||||
|
: normalized.slice(
|
||||||
|
Math.max(0, total - normalizedOffset - normalizedLimit),
|
||||||
|
Math.max(0, total - normalizedOffset),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
total,
|
||||||
|
hasMore: normalizedLimit === null
|
||||||
|
? false
|
||||||
|
: Math.max(0, total - normalizedOffset - normalizedLimit) > 0,
|
||||||
|
offset: normalizedOffset,
|
||||||
|
limit: normalizedLimit,
|
||||||
|
tokenUsage,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.warn(`[OpenCodeProvider] Failed to load session ${sessionId}:`, message);
|
||||||
|
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeHistoryRows(rows: OpenCodeHistoryRow[], sessionId: string): NormalizedMessage[] {
|
||||||
|
const normalized: NormalizedMessage[] = [];
|
||||||
|
const emittedMessageErrors = new Set<string>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const timestamp = normalizeProviderTimestamp(row.part_time_created ?? row.message_time_created);
|
||||||
|
const baseId = `${row.message_id}_${row.part_id ?? normalized.length}`;
|
||||||
|
const messageInfo = readJsonRecord(row.message_data);
|
||||||
|
const messageRole = readOptionalString(messageInfo?.role);
|
||||||
|
|
||||||
|
if (
|
||||||
|
messageInfo
|
||||||
|
&& messageRole === 'assistant'
|
||||||
|
&& messageInfo.error != null
|
||||||
|
&& !emittedMessageErrors.has(row.message_id)
|
||||||
|
) {
|
||||||
|
emittedMessageErrors.add(row.message_id);
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: `${baseId}_error`,
|
||||||
|
sessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'error',
|
||||||
|
content: formatToolContent(messageInfo.error),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.part_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partData = readJsonRecord(row.part_data) ?? {};
|
||||||
|
const partType = readOptionalString(partData.type);
|
||||||
|
if (!partType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partType === 'text') {
|
||||||
|
const content = extractText(partData);
|
||||||
|
if (content.trim()) {
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'text',
|
||||||
|
role: messageRole === 'user' ? 'user' : 'assistant',
|
||||||
|
content,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partType === 'reasoning') {
|
||||||
|
const content = extractText(partData);
|
||||||
|
if (content.trim()) {
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'thinking',
|
||||||
|
content,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partType === 'tool') {
|
||||||
|
const state = readObjectRecord(partData.state) ?? {};
|
||||||
|
const status = readOptionalString(state.status);
|
||||||
|
const toolMessage = createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_use',
|
||||||
|
toolName: readOptionalString(partData.tool) ?? 'Tool',
|
||||||
|
toolInput: state.input ?? partData.input ?? {},
|
||||||
|
toolId: readOptionalString(partData.callID) ?? row.part_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status === 'completed' || status === 'error') {
|
||||||
|
toolMessage.toolResult = {
|
||||||
|
content: formatToolContent(state.output ?? state.error),
|
||||||
|
isError: status === 'error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized.push(toolMessage);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partType === 'step-finish') {
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'stream_end',
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partType === 'patch' || partType === 'agent') {
|
||||||
|
normalized.push(createNormalizedMessage({
|
||||||
|
id: baseId,
|
||||||
|
sessionId,
|
||||||
|
timestamp,
|
||||||
|
provider: PROVIDER,
|
||||||
|
kind: 'tool_use',
|
||||||
|
toolName: partType === 'patch' ? 'Patch' : 'Agent',
|
||||||
|
toolInput: partData,
|
||||||
|
toolId: row.part_id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||||
|
import type { ProviderSkillSource } from '@/shared/types.js';
|
||||||
|
import {
|
||||||
|
addUniqueProviderSkillSource,
|
||||||
|
findTopmostGitRoot,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
|
const OPENCODE_PROJECT_SKILL_DIRS = [
|
||||||
|
['.opencode', 'skills'],
|
||||||
|
['.claude', 'skills'],
|
||||||
|
['.agents', 'skills'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPENCODE_USER_SKILL_DIRS = [
|
||||||
|
['.config', 'opencode', 'skills'],
|
||||||
|
['.claude', 'skills'],
|
||||||
|
['.agents', 'skills'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export class OpenCodeSkillsProvider extends SkillsProvider {
|
||||||
|
constructor() {
|
||||||
|
super('opencode');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||||
|
const sources: ProviderSkillSource[] = [];
|
||||||
|
const seenRootDirs = new Set<string>();
|
||||||
|
const repoRoot = await findTopmostGitRoot(workspacePath);
|
||||||
|
|
||||||
|
for (const projectRoot of this.getProjectSearchRoots(workspacePath, repoRoot)) {
|
||||||
|
for (const skillDir of OPENCODE_PROJECT_SKILL_DIRS) {
|
||||||
|
// OpenCode intentionally reads Claude and Agents skill folders so users
|
||||||
|
// can reuse the same skill libraries across compatible coding agents.
|
||||||
|
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||||
|
scope: 'project',
|
||||||
|
rootDir: path.join(projectRoot, ...skillDir),
|
||||||
|
commandPrefix: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const skillDir of OPENCODE_USER_SKILL_DIRS) {
|
||||||
|
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||||
|
scope: 'user',
|
||||||
|
rootDir: path.join(os.homedir(), ...skillDir),
|
||||||
|
commandPrefix: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getProjectSearchRoots(workspacePath: string, repoRoot: string | null): string[] {
|
||||||
|
const roots: string[] = [];
|
||||||
|
const normalizedWorkspacePath = path.resolve(workspacePath);
|
||||||
|
const normalizedRepoRoot = repoRoot ? path.resolve(repoRoot) : null;
|
||||||
|
let currentPath = normalizedWorkspacePath;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
roots.push(currentPath);
|
||||||
|
if (!normalizedRepoRoot || currentPath === normalizedRepoRoot) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = path.dirname(currentPath);
|
||||||
|
if (parentPath === currentPath) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPath = parentPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
server/modules/providers/list/opencode/opencode.provider.ts
Normal file
27
server/modules/providers/list/opencode/opencode.provider.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { OpenCodeProviderAuth } from '@/modules/providers/list/opencode/opencode-auth.provider.js';
|
||||||
|
import { OpenCodeProviderModels } from '@/modules/providers/list/opencode/opencode-models.provider.js';
|
||||||
|
import { OpenCodeMcpProvider } from '@/modules/providers/list/opencode/opencode-mcp.provider.js';
|
||||||
|
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
|
||||||
|
import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
|
||||||
|
import { OpenCodeSkillsProvider } from '@/modules/providers/list/opencode/opencode-skills.provider.js';
|
||||||
|
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||||
|
import type {
|
||||||
|
IProviderAuth,
|
||||||
|
IProviderModels,
|
||||||
|
IProviderSessionSynchronizer,
|
||||||
|
IProviderSkills,
|
||||||
|
IProviderSessions,
|
||||||
|
} from '@/shared/interfaces.js';
|
||||||
|
|
||||||
|
export class OpenCodeProvider extends AbstractProvider {
|
||||||
|
readonly models: IProviderModels = new OpenCodeProviderModels();
|
||||||
|
readonly mcp = new OpenCodeMcpProvider();
|
||||||
|
readonly auth: IProviderAuth = new OpenCodeProviderAuth();
|
||||||
|
readonly skills: IProviderSkills = new OpenCodeSkillsProvider();
|
||||||
|
readonly sessions: IProviderSessions = new OpenCodeSessionsProvider();
|
||||||
|
readonly sessionSynchronizer: IProviderSessionSynchronizer = new OpenCodeSessionSynchronizer();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('opencode');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.
|
|||||||
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
|
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
|
||||||
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
|
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
|
||||||
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
|
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
|
||||||
|
import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js';
|
||||||
import type { IProvider } from '@/shared/interfaces.js';
|
import type { IProvider } from '@/shared/interfaces.js';
|
||||||
import type { LLMProvider } from '@/shared/types.js';
|
import type { LLMProvider } from '@/shared/types.js';
|
||||||
import { AppError } from '@/shared/utils.js';
|
import { AppError } from '@/shared/utils.js';
|
||||||
@@ -11,6 +12,7 @@ const providers: Record<LLMProvider, IProvider> = {
|
|||||||
codex: new CodexProvider(),
|
codex: new CodexProvider(),
|
||||||
cursor: new CursorProvider(),
|
cursor: new CursorProvider(),
|
||||||
gemini: new GeminiProvider(),
|
gemini: new GeminiProvider(),
|
||||||
|
opencode: new OpenCodeProvider(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,17 @@ import express, { type Request, type Response } from 'express';
|
|||||||
|
|
||||||
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
||||||
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
||||||
import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
|
||||||
|
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
|
||||||
|
import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js';
|
||||||
|
import { sessionsService } from '@/modules/providers/services/sessions.service.js';
|
||||||
|
import type {
|
||||||
|
LLMProvider,
|
||||||
|
McpScope,
|
||||||
|
McpTransport,
|
||||||
|
ProviderChangeActiveModelInput,
|
||||||
|
UpsertProviderMcpServerInput,
|
||||||
|
} from '@/shared/types.js';
|
||||||
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
|
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -25,6 +35,20 @@ const readPathParam = (value: unknown, name: string): string => {
|
|||||||
const normalizeProviderParam = (value: unknown): string =>
|
const normalizeProviderParam = (value: unknown): string =>
|
||||||
readPathParam(value, 'provider').trim().toLowerCase();
|
readPathParam(value, 'provider').trim().toLowerCase();
|
||||||
|
|
||||||
|
const SESSION_ID_PATTERN = /^[a-zA-Z0-9._-]{1,120}$/;
|
||||||
|
|
||||||
|
const parseSessionId = (value: unknown): string => {
|
||||||
|
const sessionId = readPathParam(value, 'sessionId').trim();
|
||||||
|
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
||||||
|
throw new AppError('Invalid sessionId.', {
|
||||||
|
code: 'INVALID_SESSION_ID',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
};
|
||||||
|
|
||||||
const readOptionalQueryString = (value: unknown): string | undefined => {
|
const readOptionalQueryString = (value: unknown): string | undefined => {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -34,6 +58,29 @@ const readOptionalQueryString = (value: unknown): string | undefined => {
|
|||||||
return normalized.length > 0 ? normalized : undefined;
|
return normalized.length > 0 ? normalized : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseOptionalBooleanQuery = (value: unknown, name: string): boolean | undefined => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = readOptionalQueryString(value);
|
||||||
|
if (!normalized) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (normalized === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AppError(`${name} must be "true" or "false".`, {
|
||||||
|
code: 'INVALID_QUERY_PARAMETER',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const parseMcpScope = (value: unknown): McpScope | undefined => {
|
const parseMcpScope = (value: unknown): McpScope | undefined => {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -103,19 +150,19 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput =
|
|||||||
args: Array.isArray(body.args) ? body.args.filter((entry): entry is string => typeof entry === 'string') : undefined,
|
args: Array.isArray(body.args) ? body.args.filter((entry): entry is string => typeof entry === 'string') : undefined,
|
||||||
env: typeof body.env === 'object' && body.env !== null
|
env: typeof body.env === 'object' && body.env !== null
|
||||||
? Object.fromEntries(
|
? Object.fromEntries(
|
||||||
Object.entries(body.env as Record<string, unknown>).filter(
|
Object.entries(body.env as Record<string, unknown>).filter(
|
||||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
cwd: readOptionalQueryString(body.cwd),
|
cwd: readOptionalQueryString(body.cwd),
|
||||||
url: readOptionalQueryString(body.url),
|
url: readOptionalQueryString(body.url),
|
||||||
headers: typeof body.headers === 'object' && body.headers !== null
|
headers: typeof body.headers === 'object' && body.headers !== null
|
||||||
? Object.fromEntries(
|
? Object.fromEntries(
|
||||||
Object.entries(body.headers as Record<string, unknown>).filter(
|
Object.entries(body.headers as Record<string, unknown>).filter(
|
||||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
envVars: Array.isArray(body.envVars)
|
envVars: Array.isArray(body.envVars)
|
||||||
? body.envVars.filter((entry): entry is string => typeof entry === 'string')
|
? body.envVars.filter((entry): entry is string => typeof entry === 'string')
|
||||||
@@ -123,17 +170,23 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput =
|
|||||||
bearerTokenEnvVar: readOptionalQueryString(body.bearerTokenEnvVar),
|
bearerTokenEnvVar: readOptionalQueryString(body.bearerTokenEnvVar),
|
||||||
envHttpHeaders: typeof body.envHttpHeaders === 'object' && body.envHttpHeaders !== null
|
envHttpHeaders: typeof body.envHttpHeaders === 'object' && body.envHttpHeaders !== null
|
||||||
? Object.fromEntries(
|
? Object.fromEntries(
|
||||||
Object.entries(body.envHttpHeaders as Record<string, unknown>).filter(
|
Object.entries(body.envHttpHeaders as Record<string, unknown>).filter(
|
||||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseProvider = (value: unknown): LLMProvider => {
|
const parseProvider = (value: unknown): LLMProvider => {
|
||||||
const normalized = normalizeProviderParam(value);
|
const normalized = normalizeProviderParam(value);
|
||||||
if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') {
|
if (
|
||||||
|
normalized === 'claude'
|
||||||
|
|| normalized === 'codex'
|
||||||
|
|| normalized === 'cursor'
|
||||||
|
|| normalized === 'gemini'
|
||||||
|
|| normalized === 'opencode'
|
||||||
|
) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +196,85 @@ const parseProvider = (value: unknown): LLMProvider => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseSessionRenameSummary = (payload: unknown): string => {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
throw new AppError('Request body must be an object.', {
|
||||||
|
code: 'INVALID_REQUEST_BODY',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = payload as Record<string, unknown>;
|
||||||
|
const summary = typeof body.summary === 'string' ? body.summary.trim() : '';
|
||||||
|
if (!summary) {
|
||||||
|
throw new AppError('Summary is required.', {
|
||||||
|
code: 'INVALID_SESSION_SUMMARY',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.length > 500) {
|
||||||
|
throw new AppError('Summary must not exceed 500 characters.', {
|
||||||
|
code: 'INVALID_SESSION_SUMMARY',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSessionSearchQuery = (value: unknown): string => {
|
||||||
|
const query = readOptionalQueryString(value) ?? '';
|
||||||
|
if (query.length < 2) {
|
||||||
|
throw new AppError('Query must be at least 2 characters', {
|
||||||
|
code: 'INVALID_SEARCH_QUERY',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSessionSearchLimit = (value: unknown): number => {
|
||||||
|
const raw = readOptionalQueryString(value);
|
||||||
|
if (!raw) {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
throw new AppError('limit must be a valid integer.', {
|
||||||
|
code: 'INVALID_QUERY_PARAMETER',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, Math.min(parsed, 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseChangeActiveModelPayload = (payload: unknown): ProviderChangeActiveModelInput => {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
throw new AppError('Request body must be an object.', {
|
||||||
|
code: 'INVALID_REQUEST_BODY',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = payload as Record<string, unknown>;
|
||||||
|
const model = readOptionalQueryString(body.model);
|
||||||
|
if (!model) {
|
||||||
|
throw new AppError('model is required.', {
|
||||||
|
code: 'MODEL_REQUIRED',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: '',
|
||||||
|
model,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:provider/auth/status',
|
'/:provider/auth/status',
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
@@ -152,6 +284,42 @@ router.get(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:provider/models',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const provider = parseProvider(req.params.provider);
|
||||||
|
const bypassCache = parseOptionalBooleanQuery(req.query.bypassCache, 'bypassCache') ?? false;
|
||||||
|
const result = await providerModelsService.getProviderModels(provider, { bypassCache });
|
||||||
|
res.json(createApiSuccessResponse({ provider, models: result.models, cache: result.cache }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:provider/sessions/:sessionId/active-model',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const provider = parseProvider(req.params.provider);
|
||||||
|
const sessionId = parseSessionId(req.params.sessionId);
|
||||||
|
const payload = parseChangeActiveModelPayload(req.body);
|
||||||
|
const result = await providerModelsService.changeActiveModel(provider, {
|
||||||
|
...payload,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
res.json(createApiSuccessResponse(result));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ----------------- Skills routes -----------------
|
||||||
|
router.get(
|
||||||
|
'/:provider/skills',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const provider = parseProvider(req.params.provider);
|
||||||
|
const workspacePath = readOptionalQueryString(req.query.workspacePath);
|
||||||
|
const skills = await providerSkillsService.listProviderSkills(provider, { workspacePath });
|
||||||
|
res.json(createApiSuccessResponse({ provider, skills }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ----------------- MCP routes -----------------
|
||||||
router.get(
|
router.get(
|
||||||
'/:provider/mcp/servers',
|
'/:provider/mcp/servers',
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
@@ -214,4 +382,137 @@ router.post(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ----------------- Session routes -----------------
|
||||||
|
router.get(
|
||||||
|
'/sessions/archived',
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
const sessions = sessionsService.listArchivedSessions();
|
||||||
|
res.json(createApiSuccessResponse({ sessions }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/sessions/:sessionId',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const sessionId = parseSessionId(req.params.sessionId);
|
||||||
|
const force = parseOptionalBooleanQuery(req.query.force, 'force') ?? false;
|
||||||
|
const deletedFromDisk = parseOptionalBooleanQuery(req.query.deletedFromDisk, 'deletedFromDisk') ?? force;
|
||||||
|
const result = await sessionsService.deleteOrArchiveSessionById(sessionId, {
|
||||||
|
force,
|
||||||
|
deletedFromDisk,
|
||||||
|
});
|
||||||
|
res.json(createApiSuccessResponse(result));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/sessions/:sessionId/restore',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const sessionId = parseSessionId(req.params.sessionId);
|
||||||
|
const result = sessionsService.restoreSessionById(sessionId);
|
||||||
|
res.json(createApiSuccessResponse(result));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/sessions/:sessionId',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const sessionId = parseSessionId(req.params.sessionId);
|
||||||
|
const summary = parseSessionRenameSummary(req.body);
|
||||||
|
const result = sessionsService.renameSessionById(sessionId, summary);
|
||||||
|
res.json(createApiSuccessResponse(result));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/sessions/:sessionId/messages',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const sessionId = parseSessionId(req.params.sessionId);
|
||||||
|
const limitRaw = readOptionalQueryString(req.query.limit);
|
||||||
|
const offsetRaw = readOptionalQueryString(req.query.offset);
|
||||||
|
|
||||||
|
let limit: number | null = null;
|
||||||
|
if (limitRaw !== undefined) {
|
||||||
|
const parsedLimit = Number.parseInt(limitRaw, 10);
|
||||||
|
if (Number.isNaN(parsedLimit) || parsedLimit < 0) {
|
||||||
|
throw new AppError('limit must be a non-negative integer.', {
|
||||||
|
code: 'INVALID_QUERY_PARAMETER',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
limit = parsedLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
if (offsetRaw !== undefined) {
|
||||||
|
const parsedOffset = Number.parseInt(offsetRaw, 10);
|
||||||
|
if (Number.isNaN(parsedOffset) || parsedOffset < 0) {
|
||||||
|
throw new AppError('offset must be a non-negative integer.', {
|
||||||
|
code: 'INVALID_QUERY_PARAMETER',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
offset = parsedOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sessionsService.fetchHistory(sessionId, {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get('/search/sessions', asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const query = parseSessionSearchQuery(req.query.q);
|
||||||
|
const limit = parseSessionSearchLimit(req.query.limit);
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
});
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
req.on('close', () => {
|
||||||
|
closed = true;
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sessionConversationsSearchService.search({
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
signal: abortController.signal,
|
||||||
|
onProgress: ({ projectResult, totalMatches, scannedProjects, totalProjects }) => {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectResult) {
|
||||||
|
res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!closed) {
|
||||||
|
res.write('event: done\ndata: {}\n\n');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching conversations:', error);
|
||||||
|
if (!closed) {
|
||||||
|
res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!closed) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,18 +1,7 @@
|
|||||||
import os from 'node:os';
|
|
||||||
|
|
||||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||||
import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||||
import { AppError } from '@/shared/utils.js';
|
import { AppError } from '@/shared/utils.js';
|
||||||
|
|
||||||
/** Cursor MCP is not supported on Windows hosts (no Cursor CLI integration). */
|
|
||||||
function includeProviderInGlobalMcp(providerId: LLMProvider): boolean {
|
|
||||||
if (providerId === 'cursor' && os.platform() === 'win32') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const providerMcpService = {
|
export const providerMcpService = {
|
||||||
/**
|
/**
|
||||||
@@ -75,7 +64,7 @@ export const providerMcpService = {
|
|||||||
|
|
||||||
const scope = input.scope ?? 'project';
|
const scope = input.scope ?? 'project';
|
||||||
const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = [];
|
const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = [];
|
||||||
const providers = providerRegistry.listProviders().filter((p) => includeProviderInGlobalMcp(p.id));
|
const providers = providerRegistry.listProviders();
|
||||||
for (const provider of providers) {
|
for (const provider of providers) {
|
||||||
try {
|
try {
|
||||||
await provider.mcp.upsertServer({ ...input, scope });
|
await provider.mcp.upsertServer({ ...input, scope });
|
||||||
|
|||||||
358
server/modules/providers/services/provider-models.service.ts
Normal file
358
server/modules/providers/services/provider-models.service.ts
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||||
|
import type { IProvider } from '@/shared/interfaces.js';
|
||||||
|
import type {
|
||||||
|
LLMProvider,
|
||||||
|
ProviderChangeActiveModelInput,
|
||||||
|
ProviderCurrentActiveModel,
|
||||||
|
ProviderModelsCacheInfo,
|
||||||
|
ProviderModelsDefinition,
|
||||||
|
ProviderModelsResult,
|
||||||
|
ProviderSessionActiveModelChange,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
||||||
|
const PROVIDER_MODELS_CACHE_VERSION = 1;
|
||||||
|
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude']);
|
||||||
|
|
||||||
|
type ProviderModelsServiceDependencies = {
|
||||||
|
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
|
||||||
|
cachePath?: string;
|
||||||
|
activeModelChangesPath?: string;
|
||||||
|
now?: () => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderModelsOptions = {
|
||||||
|
bypassCache?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderModelsCacheEntry = {
|
||||||
|
updatedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
models: ProviderModelsDefinition;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderModelsCacheFile = {
|
||||||
|
version: number;
|
||||||
|
entries: Record<string, ProviderModelsCacheEntry>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProviderModelsCachePath = (): string => path.join(
|
||||||
|
os.homedir(),
|
||||||
|
'.cloudcli',
|
||||||
|
'provider-models-cache.json',
|
||||||
|
);
|
||||||
|
|
||||||
|
const toProviderModelsCacheInfo = (
|
||||||
|
entry: ProviderModelsCacheEntry,
|
||||||
|
source: ProviderModelsCacheInfo['source'],
|
||||||
|
): ProviderModelsCacheInfo => ({
|
||||||
|
updatedAt: new Date(entry.updatedAt).toISOString(),
|
||||||
|
expiresAt: new Date(entry.expiresAt).toISOString(),
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isProviderModelOption = (
|
||||||
|
value: unknown,
|
||||||
|
): value is ProviderModelsDefinition['OPTIONS'][number] => (
|
||||||
|
Boolean(value)
|
||||||
|
&& typeof value === 'object'
|
||||||
|
&& typeof (value as ProviderModelsDefinition['OPTIONS'][number]).value === 'string'
|
||||||
|
&& typeof (value as ProviderModelsDefinition['OPTIONS'][number]).label === 'string'
|
||||||
|
&& (
|
||||||
|
typeof (value as ProviderModelsDefinition['OPTIONS'][number]).description === 'undefined'
|
||||||
|
|| typeof (value as ProviderModelsDefinition['OPTIONS'][number]).description === 'string'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isProviderModelsDefinition = (value: unknown): value is ProviderModelsDefinition => (
|
||||||
|
Boolean(value)
|
||||||
|
&& typeof value === 'object'
|
||||||
|
&& Array.isArray((value as ProviderModelsDefinition).OPTIONS)
|
||||||
|
&& (value as ProviderModelsDefinition).OPTIONS.every(isProviderModelOption)
|
||||||
|
&& typeof (value as ProviderModelsDefinition).DEFAULT === 'string'
|
||||||
|
);
|
||||||
|
|
||||||
|
const isProviderModelsCacheEntry = (value: unknown): value is ProviderModelsCacheEntry => (
|
||||||
|
Boolean(value)
|
||||||
|
&& typeof value === 'object'
|
||||||
|
&& typeof (value as ProviderModelsCacheEntry).updatedAt === 'number'
|
||||||
|
&& typeof (value as ProviderModelsCacheEntry).expiresAt === 'number'
|
||||||
|
&& isProviderModelsDefinition((value as ProviderModelsCacheEntry).models)
|
||||||
|
);
|
||||||
|
|
||||||
|
const readProviderModelsCacheFile = async (
|
||||||
|
cachePath: string,
|
||||||
|
): Promise<ProviderModelsCacheFile | null> => {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(cachePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as Partial<ProviderModelsCacheFile>;
|
||||||
|
if (parsed.version !== PROVIDER_MODELS_CACHE_VERSION || !parsed.entries || typeof parsed.entries !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.fromEntries(
|
||||||
|
Object.entries(parsed.entries).filter((entry): entry is [string, ProviderModelsCacheEntry] =>
|
||||||
|
isProviderModelsCacheEntry(entry[1]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: PROVIDER_MODELS_CACHE_VERSION,
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeProviderModelsCacheFile = async (
|
||||||
|
cachePath: string,
|
||||||
|
entries: Map<LLMProvider, ProviderModelsCacheEntry>,
|
||||||
|
now: number,
|
||||||
|
): Promise<void> => {
|
||||||
|
const serializableEntries = Object.fromEntries(
|
||||||
|
[...entries.entries()].filter(([, entry]) => entry.expiresAt > now),
|
||||||
|
);
|
||||||
|
const payload: ProviderModelsCacheFile = {
|
||||||
|
version: PROVIDER_MODELS_CACHE_VERSION,
|
||||||
|
entries: serializableEntries,
|
||||||
|
};
|
||||||
|
|
||||||
|
await mkdir(path.dirname(cachePath), { recursive: true });
|
||||||
|
await writeFile(cachePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider model lookup service.
|
||||||
|
*
|
||||||
|
* Routes and other service callers use this layer instead of resolving provider
|
||||||
|
* classes directly so the provider-registry dependency stays centralized in one
|
||||||
|
* place.
|
||||||
|
*/
|
||||||
|
export const createProviderModelsService = (dependencies: ProviderModelsServiceDependencies = {}) => {
|
||||||
|
const resolveProvider = dependencies.resolveProvider ?? providerRegistry.resolveProvider;
|
||||||
|
const cachePath = dependencies.cachePath ?? getProviderModelsCachePath();
|
||||||
|
const activeModelChangesPath = dependencies.activeModelChangesPath;
|
||||||
|
const now = dependencies.now ?? (() => Date.now());
|
||||||
|
const memoryCache = new Map<LLMProvider, ProviderModelsCacheEntry>();
|
||||||
|
const pendingRequests = new Map<LLMProvider, Promise<ProviderModelsResult>>();
|
||||||
|
let persistedCacheLoaded = false;
|
||||||
|
let persistedCacheLoadPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const pruneExpiredMemoryEntry = (
|
||||||
|
provider: LLMProvider,
|
||||||
|
currentTime: number,
|
||||||
|
source: ProviderModelsCacheInfo['source'],
|
||||||
|
): ProviderModelsResult | null => {
|
||||||
|
const cachedEntry = memoryCache.get(provider);
|
||||||
|
if (!cachedEntry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedEntry.expiresAt > currentTime) {
|
||||||
|
return {
|
||||||
|
models: cachedEntry.models,
|
||||||
|
cache: toProviderModelsCacheInfo(cachedEntry, source),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryCache.delete(provider);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPersistedCache = async (): Promise<void> => {
|
||||||
|
if (persistedCacheLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!persistedCacheLoadPromise) {
|
||||||
|
persistedCacheLoadPromise = (async () => {
|
||||||
|
const cacheFile = await readProviderModelsCacheFile(cachePath);
|
||||||
|
const currentTime = now();
|
||||||
|
|
||||||
|
for (const [provider, entry] of Object.entries(cacheFile?.entries ?? {})) {
|
||||||
|
if (entry.expiresAt > currentTime) {
|
||||||
|
memoryCache.set(provider as LLMProvider, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
persistedCacheLoaded = true;
|
||||||
|
})().finally(() => {
|
||||||
|
persistedCacheLoadPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await persistedCacheLoadPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistCache = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await writeProviderModelsCacheFile(cachePath, memoryCache, now());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to persist provider models cache:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCacheEntry = async (
|
||||||
|
provider: LLMProvider,
|
||||||
|
models: ProviderModelsDefinition,
|
||||||
|
): Promise<ProviderModelsCacheEntry> => {
|
||||||
|
const currentTime = now();
|
||||||
|
const entry: ProviderModelsCacheEntry = {
|
||||||
|
updatedAt: currentTime,
|
||||||
|
expiresAt: currentTime + PROVIDER_MODELS_CACHE_TTL_MS,
|
||||||
|
models,
|
||||||
|
};
|
||||||
|
|
||||||
|
memoryCache.set(provider, entry);
|
||||||
|
await persistCache();
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAndCacheModels = (
|
||||||
|
provider: LLMProvider,
|
||||||
|
): Promise<ProviderModelsResult> => {
|
||||||
|
const request = resolveProvider(provider).models.getSupportedModels()
|
||||||
|
.then(async (models) => {
|
||||||
|
const entry = await setCacheEntry(provider, models);
|
||||||
|
return {
|
||||||
|
models,
|
||||||
|
cache: toProviderModelsCacheInfo(entry, 'fresh'),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pendingRequests.delete(provider);
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingRequests.set(provider, request);
|
||||||
|
return request;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDirectModels = (
|
||||||
|
provider: LLMProvider,
|
||||||
|
): Promise<ProviderModelsResult> => {
|
||||||
|
const request = resolveProvider(provider).models.getSupportedModels()
|
||||||
|
.then((models) => {
|
||||||
|
const currentTime = now();
|
||||||
|
return {
|
||||||
|
models,
|
||||||
|
cache: {
|
||||||
|
updatedAt: new Date(currentTime).toISOString(),
|
||||||
|
expiresAt: new Date(currentTime).toISOString(),
|
||||||
|
source: 'fresh' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pendingRequests.delete(provider);
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingRequests.set(provider, request);
|
||||||
|
return request;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProviderModels = async (
|
||||||
|
provider: LLMProvider,
|
||||||
|
options: ProviderModelsOptions = {},
|
||||||
|
): Promise<ProviderModelsResult> => {
|
||||||
|
if (UNCACHED_PROVIDERS.has(provider)) {
|
||||||
|
const pendingRequest = pendingRequests.get(provider);
|
||||||
|
if (pendingRequest) {
|
||||||
|
return pendingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadDirectModels(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.bypassCache) {
|
||||||
|
const pendingRequest = pendingRequests.get(provider);
|
||||||
|
if (pendingRequest) {
|
||||||
|
return pendingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadAndCacheModels(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedModels = pruneExpiredMemoryEntry(provider, now(), 'memory');
|
||||||
|
if (cachedModels) {
|
||||||
|
return cachedModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingRequest = pendingRequests.get(provider);
|
||||||
|
if (pendingRequest) {
|
||||||
|
return pendingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadPersistedCache();
|
||||||
|
|
||||||
|
const persistedModels = pruneExpiredMemoryEntry(provider, now(), 'disk');
|
||||||
|
if (persistedModels) {
|
||||||
|
return persistedModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const postLoadPendingRequest = pendingRequests.get(provider);
|
||||||
|
if (postLoadPendingRequest) {
|
||||||
|
return postLoadPendingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadAndCacheModels(provider);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentActiveModel = async (
|
||||||
|
provider: LLMProvider,
|
||||||
|
sessionId?: string,
|
||||||
|
): Promise<ProviderCurrentActiveModel> => resolveProvider(provider).models.getCurrentActiveModel(sessionId);
|
||||||
|
|
||||||
|
const changeActiveModel = async (
|
||||||
|
provider: LLMProvider,
|
||||||
|
input: ProviderChangeActiveModelInput,
|
||||||
|
): Promise<ProviderSessionActiveModelChange> => resolveProvider(provider).models.changeActiveModel(input);
|
||||||
|
|
||||||
|
const getChangedActiveModel = async (
|
||||||
|
provider: LLMProvider,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<ProviderSessionActiveModelChange> => readProviderSessionActiveModelChange(provider, sessionId, {
|
||||||
|
filePath: activeModelChangesPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveResumeModel = async (
|
||||||
|
provider: LLMProvider,
|
||||||
|
sessionId: string | undefined,
|
||||||
|
requestedModel?: string | null,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
const normalizedRequestedModel = typeof requestedModel === 'string' ? requestedModel.trim() : '';
|
||||||
|
if (!sessionId?.trim()) {
|
||||||
|
return normalizedRequestedModel || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedModel = await getChangedActiveModel(provider, sessionId);
|
||||||
|
if (changedModel.supported && changedModel.changed && changedModel.model?.trim()) {
|
||||||
|
return changedModel.model.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedRequestedModel || undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCache = (): void => {
|
||||||
|
memoryCache.clear();
|
||||||
|
pendingRequests.clear();
|
||||||
|
persistedCacheLoaded = false;
|
||||||
|
persistedCacheLoadPromise = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getProviderModels,
|
||||||
|
getCurrentActiveModel,
|
||||||
|
getChangedActiveModel,
|
||||||
|
changeActiveModel,
|
||||||
|
resolveResumeModel,
|
||||||
|
clearCache,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const providerModelsService = createProviderModelsService();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
|||||||
|
import { scanStateDb } from '@/modules/database/index.js';
|
||||||
|
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||||
|
import type { LLMProvider } from '@/shared/types.js';
|
||||||
|
|
||||||
|
type SessionSynchronizeResult = {
|
||||||
|
processedByProvider: Record<LLMProvider, number>;
|
||||||
|
failures: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrates provider-specific session indexers and indexed-session lifecycle operations.
|
||||||
|
*/
|
||||||
|
export const sessionSynchronizerService = {
|
||||||
|
/**
|
||||||
|
* Runs all provider synchronizers and updates scan_state.last_scanned_at.
|
||||||
|
*/
|
||||||
|
async synchronizeSessions(): Promise<SessionSynchronizeResult> {
|
||||||
|
const lastScanAt = scanStateDb.getLastScannedAt();
|
||||||
|
const scanBoundary = new Date();
|
||||||
|
const processedByProvider: Record<LLMProvider, number> = {
|
||||||
|
claude: 0,
|
||||||
|
codex: 0,
|
||||||
|
cursor: 0,
|
||||||
|
gemini: 0,
|
||||||
|
opencode: 0,
|
||||||
|
};
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
providerRegistry.listProviders().map(async (provider) => ({
|
||||||
|
provider: provider.id,
|
||||||
|
processed: await provider.sessionSynchronizer.synchronize(lastScanAt ?? undefined),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
processedByProvider[result.value.provider] = result.value.processed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
||||||
|
failures.push(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length === 0) {
|
||||||
|
scanStateDb.updateLastScannedAt(scanBoundary);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`[Sessions] Skipping scan_state cursor advance because ${failures.length} provider sync(s) failed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
processedByProvider,
|
||||||
|
failures,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indexes one provider artifact file without running a full provider rescan.
|
||||||
|
*/
|
||||||
|
async synchronizeProviderFile(
|
||||||
|
provider: LLMProvider,
|
||||||
|
filePath: string
|
||||||
|
): Promise<{ provider: LLMProvider; indexed: boolean; sessionId: string | null }> {
|
||||||
|
const resolvedProvider = providerRegistry.resolveProvider(provider);
|
||||||
|
const sessionId = await resolvedProvider.sessionSynchronizer.synchronizeFile(filePath);
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
indexed: Boolean(sessionId),
|
||||||
|
sessionId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
293
server/modules/providers/services/sessions-watcher.service.ts
Normal file
293
server/modules/providers/services/sessions-watcher.service.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { promises as fsPromises } from 'node:fs';
|
||||||
|
|
||||||
|
import chokidar, { type FSWatcher } from 'chokidar';
|
||||||
|
|
||||||
|
import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js';
|
||||||
|
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
|
||||||
|
import type { LLMProvider } from '@/shared/types.js';
|
||||||
|
import { getProjectsWithSessions } from '@/modules/projects/index.js';
|
||||||
|
|
||||||
|
type WatcherEventType = 'add' | 'change';
|
||||||
|
|
||||||
|
const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = [
|
||||||
|
{
|
||||||
|
provider: 'claude',
|
||||||
|
rootPath: path.join(os.homedir(), '.claude', 'projects'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'cursor',
|
||||||
|
rootPath: path.join(os.homedir(), '.cursor', 'projects'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'codex',
|
||||||
|
rootPath: path.join(os.homedir(), '.codex', 'sessions'),
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// provider: 'gemini',
|
||||||
|
// rootPath: path.join(os.homedir(), '.gemini', 'sessions'),
|
||||||
|
// },
|
||||||
|
// Keep `sessions/` watcher disabled: Gemini also mirrors artifacts there,
|
||||||
|
// which causes duplicate synchronization events.
|
||||||
|
{
|
||||||
|
provider: 'gemini',
|
||||||
|
rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'opencode',
|
||||||
|
rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const WATCHER_IGNORED_PATTERNS = [
|
||||||
|
'**/node_modules/**',
|
||||||
|
'**/.git/**',
|
||||||
|
'**/dist/**',
|
||||||
|
'**/build/**',
|
||||||
|
'**/*.tmp',
|
||||||
|
'**/*.swp',
|
||||||
|
'**/.DS_Store',
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROJECTS_UPDATE_DEBOUNCE_MS = 500;
|
||||||
|
const PROJECTS_UPDATE_MAX_WAIT_MS = 2_000;
|
||||||
|
|
||||||
|
const watchers: FSWatcher[] = [];
|
||||||
|
|
||||||
|
type PendingWatcherUpdate = {
|
||||||
|
providers: Set<LLMProvider>;
|
||||||
|
changeTypes: Set<WatcherEventType>;
|
||||||
|
updatedSessionIds: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let pendingWatcherUpdate: PendingWatcherUpdate | null = null;
|
||||||
|
let pendingWatcherUpdateStartedAt: number | null = null;
|
||||||
|
let pendingWatcherFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let watcherRefreshInFlight = false;
|
||||||
|
let watcherRescheduleAfterRefresh = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters watcher events to provider-specific session artifact file types.
|
||||||
|
*/
|
||||||
|
function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
|
||||||
|
if (provider === 'opencode') {
|
||||||
|
return path.basename(filePath) === 'opencode.db';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'gemini') {
|
||||||
|
return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath.endsWith('.jsonl');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingWatcherFlushTimer(): void {
|
||||||
|
if (pendingWatcherFlushTimer) {
|
||||||
|
clearTimeout(pendingWatcherFlushTimer);
|
||||||
|
pendingWatcherFlushTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePendingWatcherFlush(): void {
|
||||||
|
if (!pendingWatcherUpdate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (pendingWatcherUpdateStartedAt === null) {
|
||||||
|
pendingWatcherUpdateStartedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = now - pendingWatcherUpdateStartedAt;
|
||||||
|
const remainingMaxWait = Math.max(0, PROJECTS_UPDATE_MAX_WAIT_MS - elapsed);
|
||||||
|
const delay = Math.min(PROJECTS_UPDATE_DEBOUNCE_MS, remainingMaxWait);
|
||||||
|
|
||||||
|
clearPendingWatcherFlushTimer();
|
||||||
|
pendingWatcherFlushTimer = setTimeout(() => {
|
||||||
|
void flushPendingWatcherUpdate();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuePendingWatcherUpdate(
|
||||||
|
eventType: WatcherEventType,
|
||||||
|
provider: LLMProvider,
|
||||||
|
updatedSessionId: string | null
|
||||||
|
): void {
|
||||||
|
if (!pendingWatcherUpdate) {
|
||||||
|
pendingWatcherUpdate = {
|
||||||
|
providers: new Set<LLMProvider>(),
|
||||||
|
changeTypes: new Set<WatcherEventType>(),
|
||||||
|
updatedSessionIds: new Set<string>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingWatcherUpdate.providers.add(provider);
|
||||||
|
pendingWatcherUpdate.changeTypes.add(eventType);
|
||||||
|
if (updatedSessionId) {
|
||||||
|
pendingWatcherUpdate.updatedSessionIds.add(updatedSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
schedulePendingWatcherFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushPendingWatcherUpdate(): Promise<void> {
|
||||||
|
clearPendingWatcherFlushTimer();
|
||||||
|
|
||||||
|
if (!pendingWatcherUpdate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (watcherRefreshInFlight) {
|
||||||
|
watcherRescheduleAfterRefresh = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queuedUpdate = pendingWatcherUpdate;
|
||||||
|
pendingWatcherUpdate = null;
|
||||||
|
pendingWatcherUpdateStartedAt = null;
|
||||||
|
watcherRefreshInFlight = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedProjects = await getProjectsWithSessions({ skipSynchronization: true });
|
||||||
|
const changeTypes = Array.from(queuedUpdate.changeTypes);
|
||||||
|
const watchProviders = Array.from(queuedUpdate.providers);
|
||||||
|
const updatedSessionIds = Array.from(queuedUpdate.updatedSessionIds);
|
||||||
|
|
||||||
|
// Backward-compatible fields stay populated with the first queued values.
|
||||||
|
const updateMessage = JSON.stringify({
|
||||||
|
type: 'projects_updated',
|
||||||
|
projects: updatedProjects,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
changeType: changeTypes[0] ?? 'change',
|
||||||
|
updatedSessionId: updatedSessionIds[0] ?? undefined,
|
||||||
|
watchProvider: watchProviders[0] ?? undefined,
|
||||||
|
changeTypes,
|
||||||
|
updatedSessionIds,
|
||||||
|
watchProviders,
|
||||||
|
batched: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
connectedClients.forEach(client => {
|
||||||
|
if (client.readyState === WS_OPEN_STATE) {
|
||||||
|
client.send(updateMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('Session watcher refresh failed while broadcasting projects_updated', { error: message });
|
||||||
|
} finally {
|
||||||
|
watcherRefreshInFlight = false;
|
||||||
|
|
||||||
|
if (pendingWatcherUpdate || watcherRescheduleAfterRefresh) {
|
||||||
|
watcherRescheduleAfterRefresh = false;
|
||||||
|
schedulePendingWatcherFlush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles file watcher updates and triggers provider file-level synchronization.
|
||||||
|
*/
|
||||||
|
async function onUpdate(
|
||||||
|
eventType: WatcherEventType,
|
||||||
|
filePath: string,
|
||||||
|
provider: LLMProvider
|
||||||
|
): Promise<void> {
|
||||||
|
if (!isWatcherTargetFile(provider, filePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sessionSynchronizerService.synchronizeProviderFile(provider, filePath);
|
||||||
|
if (!result.indexed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Session synchronization triggered by ${eventType} event for provider "${provider}"`, {
|
||||||
|
filePath,
|
||||||
|
sessionId: result.sessionId,
|
||||||
|
});
|
||||||
|
queuePendingWatcherUpdate(eventType, provider, result.sessionId);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Session watcher sync failed for provider "${provider}"`, {
|
||||||
|
eventType,
|
||||||
|
filePath,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts provider filesystem watchers and performs initial DB synchronization.
|
||||||
|
*/
|
||||||
|
export async function initializeSessionsWatcher(): Promise<void> {
|
||||||
|
console.log('Setting up session watchers');
|
||||||
|
|
||||||
|
const initialSync = await sessionSynchronizerService.synchronizeSessions();
|
||||||
|
console.log('Initial session synchronization complete', {
|
||||||
|
processedByProvider: initialSync.processedByProvider,
|
||||||
|
failures: initialSync.failures,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
|
||||||
|
try {
|
||||||
|
await fsPromises.mkdir(rootPath, { recursive: true });
|
||||||
|
|
||||||
|
const watcher = chokidar.watch(rootPath, {
|
||||||
|
ignored: WATCHER_IGNORED_PATTERNS,
|
||||||
|
persistent: true,
|
||||||
|
ignoreInitial: true,
|
||||||
|
followSymlinks: false,
|
||||||
|
depth: 6,
|
||||||
|
usePolling: true,
|
||||||
|
interval: 6_000,
|
||||||
|
binaryInterval: 6_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher
|
||||||
|
.on('add', (filePath: string) => {
|
||||||
|
void onUpdate('add', filePath, provider);
|
||||||
|
})
|
||||||
|
.on('change', (filePath: string) => {
|
||||||
|
void onUpdate('change', filePath, provider);
|
||||||
|
})
|
||||||
|
.on('error', (error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Session watcher error for provider "${provider}"`, { error: message });
|
||||||
|
});
|
||||||
|
|
||||||
|
watchers.push(watcher);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Failed to initialize session watcher for provider "${provider}"`, {
|
||||||
|
rootPath,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops all active provider session watchers.
|
||||||
|
*/
|
||||||
|
export async function closeSessionsWatcher(): Promise<void> {
|
||||||
|
clearPendingWatcherFlushTimer();
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
watchers.map(async (watcher) => {
|
||||||
|
try {
|
||||||
|
await watcher.close();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('Failed to close session watcher', { error: message });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
watchers.length = 0;
|
||||||
|
pendingWatcherUpdate = null;
|
||||||
|
pendingWatcherUpdateStartedAt = null;
|
||||||
|
watcherRefreshInFlight = false;
|
||||||
|
watcherRescheduleAfterRefresh = false;
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||||
import type {
|
import type {
|
||||||
FetchHistoryOptions,
|
FetchHistoryOptions,
|
||||||
@@ -5,6 +9,58 @@ import type {
|
|||||||
LLMProvider,
|
LLMProvider,
|
||||||
NormalizedMessage,
|
NormalizedMessage,
|
||||||
} from '@/shared/types.js';
|
} from '@/shared/types.js';
|
||||||
|
import { AppError } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
type ArchivedSessionListItem = {
|
||||||
|
sessionId: string;
|
||||||
|
provider: LLMProvider;
|
||||||
|
projectId: string | null;
|
||||||
|
projectPath: string | null;
|
||||||
|
projectDisplayName: string;
|
||||||
|
sessionTitle: string;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
lastActivity: string | null;
|
||||||
|
isProjectArchived: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes one file if it exists.
|
||||||
|
*/
|
||||||
|
async function removeFileIfExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fsp.unlink(filePath);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
if (code === 'ENOENT') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive rows need a stable project label even when the owning project is not
|
||||||
|
* part of the active sidebar payload. This lightweight resolver keeps the
|
||||||
|
* archive API self-contained while still matching the project's stored display
|
||||||
|
* name when one exists.
|
||||||
|
*/
|
||||||
|
function resolveProjectDisplayName(
|
||||||
|
projectPath: string | null,
|
||||||
|
customProjectName: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
const trimmedCustomName = typeof customProjectName === 'string' ? customProjectName.trim() : '';
|
||||||
|
if (trimmedCustomName.length > 0) {
|
||||||
|
return trimmedCustomName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
return 'Unknown Project';
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.basename(projectPath) || projectPath;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application service for provider-backed session message operations.
|
* Application service for provider-backed session message operations.
|
||||||
@@ -33,13 +89,145 @@ export const sessionsService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches normalized persisted session history for one provider/session pair.
|
* Fetches persisted history by session id.
|
||||||
|
*
|
||||||
|
* Provider and provider-specific lookup hints are resolved from the indexed
|
||||||
|
* session metadata in the database.
|
||||||
*/
|
*/
|
||||||
fetchHistory(
|
fetchHistory(
|
||||||
providerName: string,
|
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
options?: FetchHistoryOptions,
|
options: Pick<FetchHistoryOptions, 'limit' | 'offset'> = {},
|
||||||
): Promise<FetchHistoryResult> {
|
): Promise<FetchHistoryResult> {
|
||||||
return providerRegistry.resolveProvider(providerName).sessions.fetchHistory(sessionId, options);
|
const session = sessionsDb.getSessionById(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
throw new AppError(`Session "${sessionId}" was not found.`, {
|
||||||
|
code: 'SESSION_NOT_FOUND',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = session.provider as LLMProvider;
|
||||||
|
return providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
|
||||||
|
limit: options.limit ?? null,
|
||||||
|
offset: options.offset ?? 0,
|
||||||
|
projectPath: session.project_path ?? '',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns archived sessions with enough project metadata for the sidebar to
|
||||||
|
* group, filter, open, and restore them without a per-row follow-up query.
|
||||||
|
*/
|
||||||
|
listArchivedSessions(): ArchivedSessionListItem[] {
|
||||||
|
const archivedSessions = sessionsDb.getArchivedSessions();
|
||||||
|
const projectCache = new Map<string, ReturnType<typeof projectsDb.getProjectPath>>();
|
||||||
|
|
||||||
|
return archivedSessions.map((session) => {
|
||||||
|
const projectPath = session.project_path?.trim() ? session.project_path : null;
|
||||||
|
let project = null;
|
||||||
|
|
||||||
|
if (projectPath) {
|
||||||
|
if (!projectCache.has(projectPath)) {
|
||||||
|
projectCache.set(projectPath, projectsDb.getProjectPath(projectPath));
|
||||||
|
}
|
||||||
|
project = projectCache.get(projectPath) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: session.session_id,
|
||||||
|
provider: session.provider as LLMProvider,
|
||||||
|
projectId: project?.project_id ?? null,
|
||||||
|
projectPath,
|
||||||
|
projectDisplayName: resolveProjectDisplayName(projectPath, project?.custom_project_name),
|
||||||
|
sessionTitle: session.custom_name?.trim() || session.session_id,
|
||||||
|
createdAt: session.created_at ?? null,
|
||||||
|
updatedAt: session.updated_at ?? null,
|
||||||
|
lastActivity: session.updated_at ?? session.created_at ?? null,
|
||||||
|
isProjectArchived: Boolean(project?.isArchived),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archives or permanently deletes one persisted session row by id.
|
||||||
|
*
|
||||||
|
* Soft-delete mirrors the project behavior by toggling `isArchived` so the
|
||||||
|
* row disappears from active lists but remains restorable. Force-delete
|
||||||
|
* optionally removes the transcript file before deleting the database row.
|
||||||
|
*/
|
||||||
|
async deleteOrArchiveSessionById(
|
||||||
|
sessionId: string,
|
||||||
|
options: {
|
||||||
|
force?: boolean;
|
||||||
|
deletedFromDisk?: boolean;
|
||||||
|
} = {},
|
||||||
|
): Promise<{ sessionId: string; action: 'archived' | 'deleted'; deletedFromDisk: boolean }> {
|
||||||
|
const session = sessionsDb.getSessionById(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
throw new AppError(`Session "${sessionId}" was not found.`, {
|
||||||
|
code: 'SESSION_NOT_FOUND',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.force) {
|
||||||
|
sessionsDb.updateSessionIsArchived(sessionId, true);
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
action: 'archived',
|
||||||
|
deletedFromDisk: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let removedFromDisk = false;
|
||||||
|
if (options.deletedFromDisk && session.jsonl_path) {
|
||||||
|
removedFromDisk = await removeFileIfExists(session.jsonl_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = sessionsDb.deleteSessionById(sessionId);
|
||||||
|
if (!deleted) {
|
||||||
|
throw new AppError(`Session "${sessionId}" was not found.`, {
|
||||||
|
code: 'SESSION_NOT_FOUND',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
action: 'deleted',
|
||||||
|
deletedFromDisk: removedFromDisk,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores one archived session back into the active sidebar lists.
|
||||||
|
*/
|
||||||
|
restoreSessionById(sessionId: string): { sessionId: string; isArchived: false } {
|
||||||
|
const session = sessionsDb.getSessionById(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
throw new AppError(`Session "${sessionId}" was not found.`, {
|
||||||
|
code: 'SESSION_NOT_FOUND',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionsDb.updateSessionIsArchived(sessionId, false);
|
||||||
|
return { sessionId, isArchived: false };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renames one session by id without requiring the caller to pass provider.
|
||||||
|
*/
|
||||||
|
renameSessionById(sessionId: string, summary: string): { sessionId: string; summary: string } {
|
||||||
|
const session = sessionsDb.getSessionById(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
throw new AppError(`Session "${sessionId}" was not found.`, {
|
||||||
|
code: 'SESSION_NOT_FOUND',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionsDb.updateSessionCustomName(sessionId, summary);
|
||||||
|
return { sessionId, summary };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
15
server/modules/providers/services/skills.service.ts
Normal file
15
server/modules/providers/services/skills.service.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||||
|
import type { ProviderSkill, ProviderSkillListOptions } from '@/shared/types.js';
|
||||||
|
|
||||||
|
export const providerSkillsService = {
|
||||||
|
/**
|
||||||
|
* Lists normalized skills visible to one provider.
|
||||||
|
*/
|
||||||
|
async listProviderSkills(
|
||||||
|
providerName: string,
|
||||||
|
options?: ProviderSkillListOptions,
|
||||||
|
): Promise<ProviderSkill[]> {
|
||||||
|
const provider = providerRegistry.resolveProvider(providerName);
|
||||||
|
return provider.skills.listSkills(options);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
import type { IProvider, IProviderAuth, IProviderMcp, IProviderSessions } from '@/shared/interfaces.js';
|
import type {
|
||||||
|
IProvider,
|
||||||
|
IProviderAuth,
|
||||||
|
IProviderMcp,
|
||||||
|
IProviderModels,
|
||||||
|
IProviderSessionSynchronizer,
|
||||||
|
IProviderSkills,
|
||||||
|
IProviderSessions,
|
||||||
|
} from '@/shared/interfaces.js';
|
||||||
import type { LLMProvider } from '@/shared/types.js';
|
import type { LLMProvider } from '@/shared/types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,9 +18,12 @@ import type { LLMProvider } from '@/shared/types.js';
|
|||||||
*/
|
*/
|
||||||
export abstract class AbstractProvider implements IProvider {
|
export abstract class AbstractProvider implements IProvider {
|
||||||
readonly id: LLMProvider;
|
readonly id: LLMProvider;
|
||||||
|
abstract readonly models: IProviderModels;
|
||||||
abstract readonly mcp: IProviderMcp;
|
abstract readonly mcp: IProviderMcp;
|
||||||
abstract readonly auth: IProviderAuth;
|
abstract readonly auth: IProviderAuth;
|
||||||
|
abstract readonly skills: IProviderSkills;
|
||||||
abstract readonly sessions: IProviderSessions;
|
abstract readonly sessions: IProviderSessions;
|
||||||
|
abstract readonly sessionSynchronizer: IProviderSessionSynchronizer;
|
||||||
|
|
||||||
protected constructor(id: LLMProvider) {
|
protected constructor(id: LLMProvider) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
|
|||||||
64
server/modules/providers/shared/skills/skills.provider.ts
Normal file
64
server/modules/providers/shared/skills/skills.provider.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type { IProviderSkills } from '@/shared/interfaces.js';
|
||||||
|
import type {
|
||||||
|
LLMProvider,
|
||||||
|
ProviderSkill,
|
||||||
|
ProviderSkillListOptions,
|
||||||
|
ProviderSkillSource,
|
||||||
|
} from '@/shared/types.js';
|
||||||
|
import {
|
||||||
|
findProviderSkillMarkdownFiles,
|
||||||
|
readProviderSkillMarkdownDefinition,
|
||||||
|
} from '@/shared/utils.js';
|
||||||
|
|
||||||
|
const resolveWorkspacePath = (workspacePath?: string): string =>
|
||||||
|
path.resolve(workspacePath ?? process.cwd());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared skills provider for provider-specific skill source discovery.
|
||||||
|
*/
|
||||||
|
export abstract class SkillsProvider implements IProviderSkills {
|
||||||
|
protected readonly provider: LLMProvider;
|
||||||
|
|
||||||
|
protected constructor(provider: LLMProvider) {
|
||||||
|
this.provider = provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSkills(options?: ProviderSkillListOptions): Promise<ProviderSkill[]> {
|
||||||
|
const workspacePath = resolveWorkspacePath(options?.workspacePath);
|
||||||
|
const sources = await this.getSkillSources(workspacePath);
|
||||||
|
const skills: ProviderSkill[] = [];
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
const skillFiles = await findProviderSkillMarkdownFiles(source.rootDir, {
|
||||||
|
recursive: source.recursive,
|
||||||
|
});
|
||||||
|
for (const skillPath of skillFiles) {
|
||||||
|
try {
|
||||||
|
const definition = await readProviderSkillMarkdownDefinition(skillPath);
|
||||||
|
const command = source.commandForSkill
|
||||||
|
? source.commandForSkill(definition.name)
|
||||||
|
: `${source.commandPrefix ?? '/'}${definition.name}`;
|
||||||
|
|
||||||
|
skills.push({
|
||||||
|
provider: this.provider,
|
||||||
|
name: definition.name,
|
||||||
|
description: definition.description,
|
||||||
|
command,
|
||||||
|
scope: source.scope,
|
||||||
|
sourcePath: skillPath,
|
||||||
|
pluginName: source.pluginName,
|
||||||
|
pluginId: source.pluginId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// A malformed or unreadable skill markdown file should not hide other valid skills.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]>;
|
||||||
|
}
|
||||||
@@ -169,6 +169,93 @@ test('providerMcpService handles codex MCP TOML config and capability validation
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This test covers OpenCode MCP support for user/project config files, JSONC-compatible
|
||||||
|
* reads, and validation for unsupported scope/transport combinations.
|
||||||
|
*/
|
||||||
|
test('providerMcpService handles opencode MCP config and capability validation', { concurrency: false }, async () => {
|
||||||
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-opencode-'));
|
||||||
|
const workspacePath = path.join(tempRoot, 'workspace');
|
||||||
|
await fs.mkdir(workspacePath, { recursive: true });
|
||||||
|
await fs.mkdir(path.join(tempRoot, '.config', 'opencode'), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tempRoot, '.config', 'opencode', 'opencode.jsonc'),
|
||||||
|
`{
|
||||||
|
// Existing comments should not block OpenCode MCP reads.
|
||||||
|
"mcp": {}
|
||||||
|
}\n`,
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||||
|
try {
|
||||||
|
await providerMcpService.upsertProviderMcpServer('opencode', {
|
||||||
|
name: 'opencode-user-stdio',
|
||||||
|
scope: 'user',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
env: { API_KEY: 'x' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await providerMcpService.upsertProviderMcpServer('opencode', {
|
||||||
|
name: 'opencode-project-http',
|
||||||
|
scope: 'project',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://opencode.example.com/mcp',
|
||||||
|
headers: { Authorization: 'Bearer token' },
|
||||||
|
workspacePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userConfig = await readJson(path.join(tempRoot, '.config', 'opencode', 'opencode.jsonc'));
|
||||||
|
const userServers = userConfig.mcp as Record<string, unknown>;
|
||||||
|
const userStdio = userServers['opencode-user-stdio'] as Record<string, unknown>;
|
||||||
|
assert.equal(userStdio.type, 'local');
|
||||||
|
assert.deepEqual(userStdio.command, ['node', 'server.js']);
|
||||||
|
assert.deepEqual(userStdio.environment, { API_KEY: 'x' });
|
||||||
|
|
||||||
|
const projectConfig = await readJson(path.join(workspacePath, 'opencode.json'));
|
||||||
|
const projectServers = projectConfig.mcp as Record<string, unknown>;
|
||||||
|
const projectHttp = projectServers['opencode-project-http'] as Record<string, unknown>;
|
||||||
|
assert.equal(projectHttp.type, 'remote');
|
||||||
|
assert.equal(projectHttp.url, 'https://opencode.example.com/mcp');
|
||||||
|
|
||||||
|
const grouped = await providerMcpService.listProviderMcpServers('opencode', { workspacePath });
|
||||||
|
assert.ok(grouped.user.some((server) => server.name === 'opencode-user-stdio' && server.transport === 'stdio'));
|
||||||
|
assert.ok(grouped.project.some((server) => server.name === 'opencode-project-http' && server.transport === 'http'));
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
providerMcpService.upsertProviderMcpServer('opencode', {
|
||||||
|
name: 'opencode-local',
|
||||||
|
scope: 'local',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
}),
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof AppError &&
|
||||||
|
error.code === 'MCP_SCOPE_NOT_SUPPORTED' &&
|
||||||
|
error.statusCode === 400,
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
providerMcpService.upsertProviderMcpServer('opencode', {
|
||||||
|
name: 'opencode-sse',
|
||||||
|
scope: 'project',
|
||||||
|
transport: 'sse',
|
||||||
|
url: 'https://example.com/sse',
|
||||||
|
workspacePath,
|
||||||
|
}),
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof AppError &&
|
||||||
|
error.code === 'MCP_TRANSPORT_NOT_SUPPORTED' &&
|
||||||
|
error.statusCode === 400,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
restoreHomeDir();
|
||||||
|
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence.
|
* This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence.
|
||||||
*/
|
*/
|
||||||
@@ -254,8 +341,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
|||||||
workspacePath,
|
workspacePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
const expectCursorGlobal = process.platform !== 'win32';
|
assert.equal(globalResult.length, 5);
|
||||||
assert.equal(globalResult.length, expectCursorGlobal ? 4 : 3);
|
|
||||||
assert.ok(globalResult.every((entry) => entry.created === true));
|
assert.ok(globalResult.every((entry) => entry.created === true));
|
||||||
|
|
||||||
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
|
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
|
||||||
@@ -267,10 +353,11 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
|||||||
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
|
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
|
||||||
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
|
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||||
|
|
||||||
if (expectCursorGlobal) {
|
const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json'));
|
||||||
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
|
assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']);
|
||||||
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
|
|
||||||
}
|
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
|
||||||
|
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
providerMcpService.addMcpServerToAllProviders({
|
providerMcpService.addMcpServerToAllProviders({
|
||||||
|
|||||||
73
server/modules/providers/tests/opencode-models.test.ts
Normal file
73
server/modules/providers/tests/opencode-models.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildOpenCodeDefinitionFromIds,
|
||||||
|
parseOpenCodeModelsStdout,
|
||||||
|
} from '@/modules/providers/list/opencode/opencode-models.provider.js';
|
||||||
|
|
||||||
|
test('OpenCode models provider parses plain CLI output and removes duplicates', () => {
|
||||||
|
const ids = parseOpenCodeModelsStdout(`
|
||||||
|
opencode/big-pickle
|
||||||
|
not a model
|
||||||
|
anthropic/claude-opus-4-7-fast
|
||||||
|
anthropic/claude-opus-4-7-fast
|
||||||
|
openai/gpt-5.5-pro
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.deepEqual(ids, [
|
||||||
|
'opencode/big-pickle',
|
||||||
|
'anthropic/claude-opus-4-7-fast',
|
||||||
|
'openai/gpt-5.5-pro',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OpenCode models provider formats frontend labels from provider-prefixed ids', () => {
|
||||||
|
const definition = buildOpenCodeDefinitionFromIds([
|
||||||
|
'opencode/deepseek-v4-flash-free',
|
||||||
|
'opencode/nemotron-3-super-free',
|
||||||
|
'anthropic/claude-3-5-sonnet-20241022',
|
||||||
|
'anthropic/claude-opus-4-7-fast',
|
||||||
|
'openai/gpt-5.4-mini-fast',
|
||||||
|
'openai/gpt-5.5-pro',
|
||||||
|
'newprovider/alpha-v12-special-20261231',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(definition.OPTIONS, [
|
||||||
|
{
|
||||||
|
value: 'opencode/deepseek-v4-flash-free',
|
||||||
|
label: 'Deepseek V4 Flash Free',
|
||||||
|
description: 'opencode - opencode/deepseek-v4-flash-free',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'opencode/nemotron-3-super-free',
|
||||||
|
label: 'Nemotron 3 Super Free',
|
||||||
|
description: 'opencode - opencode/nemotron-3-super-free',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'anthropic/claude-3-5-sonnet-20241022',
|
||||||
|
label: 'Claude 3.5 Sonnet (2024-10-22)',
|
||||||
|
description: 'anthropic - anthropic/claude-3-5-sonnet-20241022',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'anthropic/claude-opus-4-7-fast',
|
||||||
|
label: 'Claude Opus 4.7 Fast',
|
||||||
|
description: 'anthropic - anthropic/claude-opus-4-7-fast',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'openai/gpt-5.4-mini-fast',
|
||||||
|
label: 'GPT-5.4 Mini Fast',
|
||||||
|
description: 'openai - openai/gpt-5.4-mini-fast',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'openai/gpt-5.5-pro',
|
||||||
|
label: 'GPT-5.5 Pro',
|
||||||
|
description: 'openai - openai/gpt-5.5-pro',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'newprovider/alpha-v12-special-20261231',
|
||||||
|
label: 'Alpha V12 Special (2026-12-31)',
|
||||||
|
description: 'newprovider - newprovider/alpha-v12-special-20261231',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
334
server/modules/providers/tests/opencode-sessions.test.ts
Normal file
334
server/modules/providers/tests/opencode-sessions.test.ts
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
|
||||||
|
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
|
||||||
|
import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
|
||||||
|
|
||||||
|
const patchHomeDir = (nextHomeDir: string) => {
|
||||||
|
const original = os.homedir;
|
||||||
|
(os as any).homedir = () => nextHomeDir;
|
||||||
|
return () => {
|
||||||
|
(os as any).homedir = original;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
||||||
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||||
|
const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'opencode-provider-db-'));
|
||||||
|
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||||
|
|
||||||
|
closeConnection();
|
||||||
|
process.env.DATABASE_PATH = databasePath;
|
||||||
|
await initializeDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runTest();
|
||||||
|
} finally {
|
||||||
|
closeConnection();
|
||||||
|
if (previousDatabasePath === undefined) {
|
||||||
|
delete process.env.DATABASE_PATH;
|
||||||
|
} else {
|
||||||
|
process.env.DATABASE_PATH = previousDatabasePath;
|
||||||
|
}
|
||||||
|
await rm(tempDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): Promise<void> => {
|
||||||
|
const dataDir = path.join(homeDir, '.local', 'share', 'opencode');
|
||||||
|
await mkdir(dataDir, { recursive: true });
|
||||||
|
|
||||||
|
const db = new Database(path.join(dataDir, 'opencode.db'));
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE project (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
worktree TEXT NOT NULL,
|
||||||
|
vcs TEXT,
|
||||||
|
name TEXT,
|
||||||
|
icon_url TEXT,
|
||||||
|
icon_color TEXT,
|
||||||
|
time_created INTEGER NOT NULL,
|
||||||
|
time_updated INTEGER NOT NULL,
|
||||||
|
time_initialized INTEGER,
|
||||||
|
sandboxes TEXT NOT NULL,
|
||||||
|
commands TEXT,
|
||||||
|
icon_url_override TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE session (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
parent_id TEXT,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
directory TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
share_url TEXT,
|
||||||
|
summary_additions INTEGER,
|
||||||
|
summary_deletions INTEGER,
|
||||||
|
summary_files INTEGER,
|
||||||
|
summary_diffs TEXT,
|
||||||
|
revert TEXT,
|
||||||
|
permission TEXT,
|
||||||
|
time_created INTEGER NOT NULL,
|
||||||
|
time_updated INTEGER NOT NULL,
|
||||||
|
time_compacting INTEGER,
|
||||||
|
time_archived INTEGER,
|
||||||
|
workspace_id TEXT,
|
||||||
|
path TEXT,
|
||||||
|
agent TEXT,
|
||||||
|
model TEXT,
|
||||||
|
cost REAL NOT NULL DEFAULT 0,
|
||||||
|
tokens_input INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tokens_output INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tokens_reasoning INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tokens_cache_read INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tokens_cache_write INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE message (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
time_created INTEGER NOT NULL,
|
||||||
|
time_updated INTEGER NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE part (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
message_id TEXT NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
time_created INTEGER NOT NULL,
|
||||||
|
time_updated INTEGER NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (message_id) REFERENCES message(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX part_session_idx ON part (session_id);
|
||||||
|
CREATE INDEX session_project_idx ON session (project_id);
|
||||||
|
CREATE INDEX message_session_time_created_id_idx ON message (session_id, time_created, id);
|
||||||
|
CREATE INDEX part_message_id_id_idx ON part (message_id, id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
).run(
|
||||||
|
'project-1',
|
||||||
|
workspacePath,
|
||||||
|
1_700_000_000_000,
|
||||||
|
1_700_000_001_000,
|
||||||
|
'[]',
|
||||||
|
);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO session (
|
||||||
|
id, project_id, slug, directory, title, version, time_created, time_updated, time_archived,
|
||||||
|
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
'open-session-1',
|
||||||
|
'project-1',
|
||||||
|
'open-session-1',
|
||||||
|
workspacePath,
|
||||||
|
'OpenCode indexed title',
|
||||||
|
'0.0.0',
|
||||||
|
1_700_000_000_000,
|
||||||
|
1_700_000_004_000,
|
||||||
|
null,
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
7,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
const userMessageData = JSON.stringify({
|
||||||
|
role: 'user',
|
||||||
|
time: { created: 1_700_000_001_000 },
|
||||||
|
agent: 'test',
|
||||||
|
model: { providerID: 'anthropic', modelID: 'claude' },
|
||||||
|
});
|
||||||
|
const assistantMessageData = JSON.stringify({
|
||||||
|
role: 'assistant',
|
||||||
|
time: { created: 1_700_000_002_000, completed: 1_700_000_003_000 },
|
||||||
|
parentID: 'message-user',
|
||||||
|
modelID: 'anthropic/claude-sonnet-4-5',
|
||||||
|
providerID: 'anthropic',
|
||||||
|
mode: 'default',
|
||||||
|
agent: 'test',
|
||||||
|
path: { cwd: '.', root: '.' },
|
||||||
|
cost: 0.01,
|
||||||
|
tokens: {
|
||||||
|
input: 10,
|
||||||
|
output: 20,
|
||||||
|
reasoning: 0,
|
||||||
|
cache: { read: 3, write: 2 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
).run('message-user', 'open-session-1', 1_700_000_001_000, 1_700_000_001_500, userMessageData);
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
).run('message-assistant', 'open-session-1', 1_700_000_002_000, 1_700_000_003_000, assistantMessageData);
|
||||||
|
|
||||||
|
const insertPart = db.prepare(`
|
||||||
|
INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
insertPart.run(
|
||||||
|
'part-user-text',
|
||||||
|
'message-user',
|
||||||
|
'open-session-1',
|
||||||
|
1_700_000_001_000,
|
||||||
|
1_700_000_001_000,
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify('Build the OpenCode integration.'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
insertPart.run(
|
||||||
|
'part-reasoning',
|
||||||
|
'message-assistant',
|
||||||
|
'open-session-1',
|
||||||
|
1_700_000_002_000,
|
||||||
|
1_700_000_002_000,
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'reasoning',
|
||||||
|
text: 'I will inspect the provider shape first.',
|
||||||
|
time: { start: 0, end: 1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
insertPart.run(
|
||||||
|
'part-assistant-text',
|
||||||
|
'message-assistant',
|
||||||
|
'open-session-1',
|
||||||
|
1_700_000_002_500,
|
||||||
|
1_700_000_002_500,
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'text',
|
||||||
|
text: 'The provider is wired.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
insertPart.run(
|
||||||
|
'part-tool',
|
||||||
|
'message-assistant',
|
||||||
|
'open-session-1',
|
||||||
|
1_700_000_003_000,
|
||||||
|
1_700_000_003_000,
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'tool',
|
||||||
|
tool: 'bash',
|
||||||
|
callID: 'tool-call-1',
|
||||||
|
state: {
|
||||||
|
status: 'completed',
|
||||||
|
input: { command: 'npm test' },
|
||||||
|
output: 'ok',
|
||||||
|
title: 'bash',
|
||||||
|
metadata: {},
|
||||||
|
time: { start: 0, end: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test('OpenCode session synchronizer indexes sqlite sessions without deletable transcript paths', { concurrency: false }, async () => {
|
||||||
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-'));
|
||||||
|
const workspacePath = path.join(tempRoot, 'workspace');
|
||||||
|
await mkdir(workspacePath, { recursive: true });
|
||||||
|
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createOpenCodeDatabase(tempRoot, workspacePath);
|
||||||
|
await withIsolatedDatabase(() => {
|
||||||
|
const synchronizer = new OpenCodeSessionSynchronizer();
|
||||||
|
const processed = synchronizer.synchronize();
|
||||||
|
|
||||||
|
return Promise.resolve(processed).then((count) => {
|
||||||
|
assert.equal(count, 1);
|
||||||
|
const indexed = sessionsDb.getSessionById('open-session-1');
|
||||||
|
assert.equal(indexed?.provider, 'opencode');
|
||||||
|
assert.equal(indexed?.project_path, workspacePath);
|
||||||
|
assert.equal(indexed?.custom_name, 'OpenCode indexed title');
|
||||||
|
assert.equal(indexed?.jsonl_path, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restoreHomeDir();
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => {
|
||||||
|
const provider = new OpenCodeSessionsProvider();
|
||||||
|
const normalized = provider.normalizeMessage({
|
||||||
|
type: 'text',
|
||||||
|
sessionID: 'open-session-live',
|
||||||
|
text: JSON.stringify('hello bro'),
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
assert.equal(normalized.length, 1);
|
||||||
|
assert.equal(normalized[0]?.kind, 'stream_delta');
|
||||||
|
assert.equal(normalized[0]?.content, 'hello bro');
|
||||||
|
|
||||||
|
const userEcho = provider.normalizeMessage({
|
||||||
|
type: 'text',
|
||||||
|
sessionID: 'open-session-live',
|
||||||
|
role: 'user',
|
||||||
|
text: 'hello bro',
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
assert.deepEqual(userEcho, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OpenCode sessions provider reads sqlite history and token usage', { concurrency: false }, async () => {
|
||||||
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-history-'));
|
||||||
|
const workspacePath = path.join(tempRoot, 'workspace');
|
||||||
|
await mkdir(workspacePath, { recursive: true });
|
||||||
|
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createOpenCodeDatabase(tempRoot, workspacePath);
|
||||||
|
const provider = new OpenCodeSessionsProvider();
|
||||||
|
const history = await provider.fetchHistory('open-session-1');
|
||||||
|
|
||||||
|
assert.equal(history.total, 4);
|
||||||
|
assert.equal(history.messages[0]?.kind, 'text');
|
||||||
|
assert.equal(history.messages[0]?.role, 'user');
|
||||||
|
assert.equal(history.messages[0]?.content, 'Build the OpenCode integration.');
|
||||||
|
assert.equal(history.messages[1]?.kind, 'thinking');
|
||||||
|
assert.equal(history.messages[2]?.content, 'The provider is wired.');
|
||||||
|
assert.equal(history.messages[3]?.kind, 'tool_use');
|
||||||
|
assert.deepEqual(history.messages[3]?.toolResult, { content: 'ok', isError: false });
|
||||||
|
assert.deepEqual(history.tokenUsage, {
|
||||||
|
used: 42,
|
||||||
|
inputTokens: 13,
|
||||||
|
outputTokens: 20,
|
||||||
|
breakdown: {
|
||||||
|
input: 13,
|
||||||
|
output: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const paged = await provider.fetchHistory('open-session-1', { limit: 2, offset: 0 });
|
||||||
|
assert.equal(paged.messages.length, 2);
|
||||||
|
assert.equal(paged.hasMore, true);
|
||||||
|
assert.equal(paged.messages[0]?.content, 'The provider is wired.');
|
||||||
|
} finally {
|
||||||
|
restoreHomeDir();
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user