├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── cursor_chat_finder.py ├── cursor_chat_viewer └── index.html ├── extract_cursor_chat.py ├── frontend ├── package-lock.json ├── package.json ├── public │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ └── manifest.json └── src │ ├── App.js │ ├── components │ ├── ChatDetail.js │ ├── ChatList.js │ └── Header.js │ ├── index.css │ └── index.js ├── requirements.txt ├── server.py └── vscdb_to_sqlite.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | extracted_chat_history.json 176 | .DS_Store 177 | 178 | __pycache__/* 179 | local_chats/* 180 | *.sqlite 181 | *.vscdb 182 | .cursor/rules/python-version.mdc 183 | frontend/node_modules/* 184 | cursor_chats.json 185 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | 8 | { 9 | 10 | "name": "Flask", 11 | "type": "debugpy", 12 | "request": "launch", 13 | "program": "${workspaceFolder}/server.py", 14 | "env": { 15 | "FLASK_APP": "server.py", 16 | "FLASK_ENV": "development" 17 | }, 18 | "console": "integratedTerminal", // Show output in integrated terminal 19 | "python": "${workspaceFolder}/venv/bin/python", 20 | "envFile": "${workspaceFolder}/.env" 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cursor View 2 | 3 | Cursor View is a local tool to view, search, and export all your Cursor AI chat histories in one place. It works by scanning your local Cursor application data directories and extracting chat data from the SQLite databases. 4 | 5 | **Privacy Note**: All data processing happens locally on your machine. No data is sent to any external servers. 6 | 7 | Screenshot 2025-05-01 at 8 22 43 AM-min 8 | 9 | ## Setup & Running 10 | 11 | 1. Clone this repository 12 | 2. Install Python dependencies: 13 | ``` 14 | python3 -m pip install -r requirements.txt 15 | ``` 16 | 3. Install frontend dependencies and build (optional, pre-built files included): 17 | ``` 18 | cd frontend 19 | npm install 20 | npm run build 21 | ``` 22 | 4. Start the server: 23 | ``` 24 | python3 server.py 25 | ``` 26 | 5. Open your browser to http://localhost:5000 27 | 28 | ## Features 29 | 30 | - Browse all Cursor chat sessions 31 | - Search through chat history 32 | - Export chats as JSON or standalone HTML 33 | - Organize chats by project 34 | - View timestamps of conversations 35 | -------------------------------------------------------------------------------- /cursor_chat_finder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Find and extract all Cursor chat histories from a user's system. 4 | This script locates all workspace and session databases and extracts chat data. 5 | """ 6 | 7 | import os 8 | import glob 9 | import json 10 | import pathlib 11 | import platform 12 | import datetime 13 | from typing import List, Dict, Any, Optional 14 | 15 | from extract_cursor_chat import load_chat_session 16 | 17 | def get_cursor_storage_path() -> pathlib.Path: 18 | """Get the path where Cursor stores its data based on the OS.""" 19 | system = platform.system() 20 | home = pathlib.Path.home() 21 | 22 | if system == "Darwin": # macOS 23 | return home / "Library" / "Application Support" / "Cursor" 24 | elif system == "Windows": 25 | return home / "AppData" / "Roaming" / "Cursor" 26 | elif system == "Linux": 27 | return home / ".config" / "Cursor" 28 | else: 29 | raise RuntimeError(f"Unsupported platform: {system}") 30 | 31 | def find_workspace_dbs() -> List[Dict[str, pathlib.Path]]: 32 | """Find all workspace databases (state.vscdb) and their associated session DBs.""" 33 | cursor_path = get_cursor_storage_path() 34 | workspace_storage = cursor_path / "User" / "workspaceStorage" 35 | 36 | if not workspace_storage.exists(): 37 | return [] 38 | 39 | results = [] 40 | 41 | # Possible locations for session databases 42 | extension_dirs = [ 43 | cursor_path / "User" / "globalStorage" / "cursor.cursor", 44 | cursor_path / "User" / "globalStorage" / "cursor" 45 | ] 46 | 47 | # Find all session databases across all possible locations 48 | all_session_dbs = [] 49 | for extension_dir in extension_dirs: 50 | if extension_dir.exists(): 51 | for session_db in extension_dir.glob("*.sqlite"): 52 | all_session_dbs.append(session_db) 53 | 54 | # If no session DBs found, try more generic patterns 55 | if not all_session_dbs: 56 | for pattern in ["*.sqlite", "*.db", "*.sqlite3"]: 57 | for extension_dir in extension_dirs: 58 | if extension_dir.exists(): 59 | for session_db in extension_dir.glob(pattern): 60 | all_session_dbs.append(session_db) 61 | 62 | # Create a dummy workspace entry if no workspaces but we have session DBs 63 | if all_session_dbs and not any(workspace_storage.glob("*")): 64 | results.append({ 65 | "workspace_db": None, # We'll handle None values in extract_all_chats 66 | "session_dbs": all_session_dbs, 67 | "workspace_id": "unknown" 68 | }) 69 | return results 70 | 71 | # Process workspaces 72 | for workspace_dir in workspace_storage.glob("*"): 73 | workspace_db = workspace_dir / "state.vscdb" 74 | if not workspace_db.exists(): 75 | continue 76 | 77 | # For now, just associate all session DBs with all workspaces 78 | # This is a simplification but ensures we don't miss any chats 79 | results.append({ 80 | "workspace_db": workspace_db, 81 | "session_dbs": all_session_dbs, 82 | "workspace_id": workspace_dir.name 83 | }) 84 | 85 | return results 86 | 87 | def extract_all_chats() -> List[Dict[str, Any]]: 88 | """Extract all chat sessions from all workspaces.""" 89 | all_workspaces = find_workspace_dbs() 90 | all_chats = [] 91 | 92 | if not all_workspaces: 93 | # Create sample data for demo purposes 94 | return create_sample_chats() 95 | 96 | for workspace in all_workspaces: 97 | workspace_db = workspace["workspace_db"] 98 | 99 | for session_db in workspace["session_dbs"]: 100 | try: 101 | # Get file modification time as a proxy for chat date 102 | mod_time = datetime.datetime.fromtimestamp(session_db.stat().st_mtime) 103 | chat_date = mod_time.strftime("%Y-%m-%d %H:%M:%S") 104 | 105 | # Extract the chat session 106 | session = load_chat_session(workspace_db, session_db) if workspace_db else create_fallback_session(session_db) 107 | 108 | # Skip sessions with no messages 109 | if not session.messages: 110 | continue 111 | 112 | # Add metadata 113 | chat_data = session.to_dict() 114 | chat_data["date"] = chat_date 115 | chat_data["session_id"] = session_db.stem 116 | chat_data["workspace_id"] = workspace["workspace_id"] 117 | 118 | all_chats.append(chat_data) 119 | except Exception as e: 120 | print(f"Error extracting chat from {session_db}: {e}") 121 | 122 | # Sort by date (newest first) 123 | all_chats.sort(key=lambda x: x["date"], reverse=True) 124 | 125 | # If still no chats, return sample data for demo 126 | if not all_chats: 127 | return create_sample_chats() 128 | 129 | return all_chats 130 | 131 | def create_fallback_session(session_db: pathlib.Path): 132 | """Create a fallback session when workspace_db is not available""" 133 | from extract_cursor_chat import ChatSession, extract_messages 134 | project = {"name": "Unknown Project", "rootPath": "/"} 135 | messages = extract_messages(session_db) 136 | return ChatSession(project, messages) 137 | 138 | def create_sample_chats() -> List[Dict[str, Any]]: 139 | """Create sample chat data for demo purposes""" 140 | return [ 141 | { 142 | "project": {"name": "Sample Project", "rootPath": "/path/to/sample"}, 143 | "messages": [ 144 | {"role": "user", "content": "Can you help me with this React component?"}, 145 | {"role": "assistant", "content": "Of course! What specific issues are you having with the component?"} 146 | ], 147 | "date": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), 148 | "session_id": "sample1", 149 | "workspace_id": "sample" 150 | }, 151 | { 152 | "project": {"name": "Demo API", "rootPath": "/path/to/demo-api"}, 153 | "messages": [ 154 | {"role": "user", "content": "How do I properly structure my Flask API?"}, 155 | {"role": "assistant", "content": "For Flask APIs, I recommend organizing your code with a blueprint structure. Here's an example..."} 156 | ], 157 | "date": (datetime.datetime.now() - datetime.timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"), 158 | "session_id": "sample2", 159 | "workspace_id": "sample" 160 | } 161 | ] 162 | 163 | def save_all_chats(output_path: pathlib.Path): 164 | """Save all extracted chats to a JSON file.""" 165 | chats = extract_all_chats() 166 | output_path.write_text(json.dumps(chats, ensure_ascii=False, indent=2)) 167 | return chats 168 | 169 | if __name__ == "__main__": 170 | import argparse 171 | 172 | parser = argparse.ArgumentParser(description="Extract all Cursor chat histories") 173 | parser.add_argument("--out", type=pathlib.Path, default=pathlib.Path("cursor_chats.json"), 174 | help="Output JSON file (default: cursor_chats.json)") 175 | args = parser.parse_args() 176 | 177 | chats = save_all_chats(args.out) 178 | print(f"Extracted {len(chats)} chat sessions to {args.out}") -------------------------------------------------------------------------------- /cursor_chat_viewer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Cursor View 7 | 138 | 139 | 140 |
141 |
142 |

Cursor View

143 | 144 |
145 | 146 |
Workspace: ea4da77c5e01c7da80db58aa84beae7c
No conversations found in this workspace.
Workspace: dad94eff5e55a90da64e8a9b968c03d3
No conversations found in this workspace.
Workspace: 88b37f1c1e20acaa9797382d4d2a123e
No conversations found in this workspace.
Workspace: ff6e5790f423c7a83ad7528754aa8421
No conversations found in this workspace.
Workspace: 2979e6fd3152b9d37864ce125bf9096c
No conversations found in this workspace.
Workspace: e6075cfac54e62c8f00ab252e2507a10
No conversations found in this workspace.
Workspace: 0ffdfbc3b86de59f0da4e0054e8243fd
No conversations found in this workspace.
Workspace: 04308cbb1ebcf23ca4b69b62d6d41d7e
No conversations found in this workspace.
147 |
148 | 149 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /extract_cursor_chat.py: -------------------------------------------------------------------------------- 1 | 2 | #!/usr/bin/env python3 3 | """ 4 | Extract Cursor chat history from both workspaceStorage *and* session‑specific 5 | SQLite databases. 6 | 7 | Expected inputs 8 | -------------- 9 | 1. `state.vscdb` – lives in `workspaceStorage/***/state.vscdb` 10 | • Has ItemTable with `history.entries` (for project root, open files) 11 | 2. `state.sqlite` or similar – lives in the extension folder 12 | • Has cursorDiskKV with per‑bubble chat data (`bubbleId:*` keys) 13 | 14 | What we build 15 | ------------- 16 | `ChatSession` dataclass with: 17 | 18 | project – {'name': 'CommunityVibe', 'rootPath': '...'} 19 | messages – [{'role': 'user'|'assistant', 'content': str}] 20 | 21 | CLI usage 22 | --------- 23 | python extract_cursor_chat.py --workspace state.vscdb --session state.sqlite --out chat.json 24 | """ 25 | 26 | from __future__ import annotations 27 | 28 | import argparse, json, os, pathlib, sqlite3, sys 29 | from dataclasses import dataclass 30 | from typing import Dict, List, Any, Iterable, Tuple 31 | 32 | 33 | # ------------------------------------------------------------ 34 | # Shared helpers 35 | # ------------------------------------------------------------ 36 | def _load_json(cur: sqlite3.Cursor, table: str, key: str): 37 | cur.execute(f"SELECT value FROM {table} WHERE key=?", (key,)) 38 | row = cur.fetchone() 39 | if not row: 40 | return None 41 | try: 42 | return json.loads(row[0]) 43 | except Exception: 44 | return None 45 | 46 | 47 | # ------------------------------------------------------------ 48 | # Project metadata (from workspace DB) 49 | # ------------------------------------------------------------ 50 | def extract_project(workspace_db: pathlib.Path) -> Dict[str, str]: 51 | con = sqlite3.connect(workspace_db) 52 | cur = con.cursor() 53 | entries = _load_json(cur, "ItemTable", "history.entries") or [] 54 | con.close() 55 | 56 | file_paths: List[str] = [] 57 | for entry in entries: 58 | res = entry.get("editor", {}).get("resource", "") 59 | if res.startswith("file:///"): 60 | file_paths.append(res[len("file://"):]) 61 | 62 | if not file_paths: 63 | return {} 64 | 65 | root = os.path.commonprefix(file_paths).rstrip("/") 66 | if "/" in root: 67 | root = root[: root.rfind("/")] 68 | 69 | return {"name": os.path.basename(root), "rootPath": "/" + root} 70 | 71 | 72 | # ------------------------------------------------------------ 73 | # Messages from session DB (cursorDiskKV) 74 | # ------------------------------------------------------------ 75 | def _iter_bubble_messages(session_db: pathlib.Path) -> Iterable[Tuple[int, Dict[str, str]]]: 76 | """Yield (rowid, msg_dict) for every bubble with non‑empty text.""" 77 | con = sqlite3.connect(session_db) 78 | cur = con.cursor() 79 | cur.execute("SELECT rowid, key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'") 80 | for rowid, key, val in cur.fetchall(): 81 | try: 82 | bubble = json.loads(val) 83 | except Exception: 84 | continue 85 | text = bubble.get("text", "").strip() 86 | if not text: 87 | continue 88 | role = "user" if bubble.get("type") == 1 else "assistant" 89 | yield rowid, {"role": role, "content": text} 90 | con.close() 91 | 92 | 93 | def extract_messages(session_db: pathlib.Path) -> List[Dict[str, str]]: 94 | # Sort by rowid (= insertion order) 95 | msgs = [msg for _, msg in sorted(_iter_bubble_messages(session_db), key=lambda t: t[0])] 96 | return msgs 97 | 98 | 99 | # ------------------------------------------------------------ 100 | # Dataclass wrapper 101 | # ------------------------------------------------------------ 102 | @dataclass 103 | class ChatSession: 104 | project: Dict[str, str] 105 | messages: List[Dict[str, str]] 106 | 107 | def to_dict(self) -> Dict[str, Any]: 108 | return {"project": self.project, "messages": self.messages} 109 | 110 | 111 | def load_chat_session(workspace_db: pathlib.Path, session_db: pathlib.Path) -> "ChatSession": 112 | project = extract_project(workspace_db) 113 | messages = extract_messages(session_db) 114 | return ChatSession(project, messages) 115 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cursor-chat-viewer", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.11.1", 7 | "@emotion/styled": "^11.11.0", 8 | "@mui/icons-material": "^5.14.3", 9 | "@mui/material": "^5.14.3", 10 | "axios": "^1.4.0", 11 | "react": "^18.2.0", 12 | "react-dom": "^18.2.0", 13 | "react-markdown": "^8.0.7", 14 | "react-router-dom": "^6.14.2", 15 | "react-scripts": "5.0.1", 16 | "styled-components": "^6.0.7" 17 | }, 18 | "scripts": { 19 | "start": "react-scripts start", 20 | "build": "react-scripts build", 21 | "test": "react-scripts test", 22 | "eject": "react-scripts eject" 23 | }, 24 | "eslintConfig": { 25 | "extends": [ 26 | "react-app", 27 | "react-app/jest" 28 | ] 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | }, 42 | "proxy": "http://localhost:5000" 43 | } -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saharmor/cursor-view/8c7a53fd54d2b910142136d61709253c87a3f87d/frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saharmor/cursor-view/8c7a53fd54d2b910142136d61709253c87a3f87d/frontend/public/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saharmor/cursor-view/8c7a53fd54d2b910142136d61709253c87a3f87d/frontend/public/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saharmor/cursor-view/8c7a53fd54d2b910142136d61709253c87a3f87d/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Cursor View 20 | 21 | 22 | 23 |
24 | 25 | -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saharmor/cursor-view/8c7a53fd54d2b910142136d61709253c87a3f87d/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saharmor/cursor-view/8c7a53fd54d2b910142136d61709253c87a3f87d/frontend/public/logo512.png -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Cursor Chat", 3 | "name": "Cursor View", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } -------------------------------------------------------------------------------- /frontend/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; 3 | import { ThemeProvider, createTheme } from '@mui/material/styles'; 4 | import CssBaseline from '@mui/material/CssBaseline'; 5 | 6 | import ChatList from './components/ChatList'; 7 | import ChatDetail from './components/ChatDetail'; 8 | import Header from './components/Header'; 9 | 10 | // Define our color palette centrally - using rich, modern colors 11 | const colors = { 12 | primary: { 13 | main: '#00bbff', // Rich purple 14 | light: '#66d6ff', // Light purple 15 | dark: '#005e80', // Dark purple 16 | }, 17 | secondary: { 18 | main: '#FF6B35', // Vibrant orange 19 | light: '#FF8F5E', // Light orange 20 | dark: '#E04F1D', // Dark orange 21 | }, 22 | tertiary: { 23 | main: '#3EBD64', // Vibrant green 24 | light: '#5FD583', // Light green 25 | dark: '#2A9E4A', // Dark green 26 | }, 27 | highlightColor: '#0cbcff8f', // New bright blue with transparency 28 | background: { 29 | default: '#121212', // Dark background 30 | paper: '#1E1E1E', // Slightly lighter dark for cards/elements 31 | gradient: 'linear-gradient(135deg, #6E2CF4 0%, #FF6B35 50%, #3EBD64 100%)', // Gradient from purple to orange to green 32 | }, 33 | text: { 34 | primary: '#FFFFFF', // White text 35 | secondary: '#B3B3B3', // Lighter gray for secondary text 36 | }, 37 | info: { 38 | main: '#39C0F7', // Bright blue 39 | }, 40 | success: { 41 | main: '#3EBD64', // Green 42 | }, 43 | warning: { 44 | main: '#FAAD14', // Amber 45 | }, 46 | error: { 47 | main: '#F5222D', // Red 48 | }, 49 | }; 50 | 51 | // Create a modern, sophisticated dark theme for the app 52 | const modernTheme = createTheme({ 53 | palette: { 54 | mode: 'dark', 55 | primary: colors.primary, 56 | secondary: colors.secondary, 57 | background: colors.background, 58 | text: colors.text, 59 | info: colors.info, 60 | success: colors.success, 61 | warning: colors.warning, 62 | error: colors.error, 63 | highlight: { 64 | main: colors.highlightColor 65 | } 66 | }, 67 | typography: { 68 | fontFamily: "'Inter', 'Roboto', 'Helvetica', 'Arial', sans-serif", 69 | h4: { 70 | fontWeight: 700, 71 | }, 72 | h5: { 73 | fontWeight: 600, 74 | }, 75 | }, 76 | shape: { 77 | borderRadius: 10, 78 | }, 79 | components: { 80 | MuiCard: { 81 | styleOverrides: { 82 | root: { 83 | borderRadius: 10, 84 | boxShadow: `0 4px 10px ${colors.highlightColor}`, 85 | backgroundColor: colors.background.paper, 86 | }, 87 | }, 88 | }, 89 | MuiPaper: { 90 | styleOverrides: { 91 | root: { 92 | borderRadius: 10, 93 | backgroundColor: colors.background.paper, 94 | }, 95 | }, 96 | }, 97 | MuiAppBar: { 98 | styleOverrides: { 99 | root: { 100 | background: colors.background.gradient, 101 | }, 102 | }, 103 | }, 104 | MuiTextField: { 105 | styleOverrides: { 106 | root: { 107 | '& .MuiOutlinedInput-root': { 108 | backgroundColor: 'transparent', 109 | '&:hover': { 110 | backgroundColor: 'transparent', 111 | }, 112 | '&.Mui-focused': { 113 | backgroundColor: 'transparent', 114 | } 115 | } 116 | } 117 | } 118 | }, 119 | MuiButton: { 120 | styleOverrides: { 121 | root: { 122 | textTransform: 'none', 123 | borderRadius: 6, 124 | fontWeight: 500, 125 | color: 'white', 126 | }, 127 | contained: { 128 | boxShadow: `0 2px 4px ${colors.highlightColor}`, 129 | }, 130 | outlined: { 131 | color: 'white', 132 | borderColor: colors.highlightColor, 133 | '&:hover': { 134 | borderColor: colors.highlightColor, 135 | } 136 | }, 137 | }, 138 | }, 139 | MuiChip: { 140 | styleOverrides: { 141 | root: { 142 | borderRadius: 4, 143 | }, 144 | }, 145 | }, 146 | MuiCircularProgress: { 147 | styleOverrides: { 148 | root: { 149 | color: colors.highlightColor 150 | } 151 | } 152 | } 153 | }, 154 | }); 155 | 156 | // Export the colors so they can be used in other components 157 | export { colors }; 158 | 159 | function App() { 160 | return ( 161 | 162 | 163 | 164 |
165 | 166 | } /> 167 | } /> 168 | 169 | 170 | 171 | ); 172 | } 173 | 174 | export default App; -------------------------------------------------------------------------------- /frontend/src/components/ChatDetail.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useParams, Link } from 'react-router-dom'; 3 | import axios from 'axios'; 4 | import ReactMarkdown from 'react-markdown'; 5 | import { 6 | Container, 7 | Typography, 8 | Box, 9 | Paper, 10 | Divider, 11 | CircularProgress, 12 | Chip, 13 | Button, 14 | Avatar, 15 | alpha, 16 | Stack, 17 | Dialog, 18 | DialogTitle, 19 | DialogContent, 20 | DialogActions, 21 | FormControlLabel, 22 | Checkbox, 23 | DialogContentText, 24 | Radio, 25 | RadioGroup, 26 | FormControl, 27 | FormLabel, 28 | } from '@mui/material'; 29 | import ArrowBackIcon from '@mui/icons-material/ArrowBack'; 30 | import FolderIcon from '@mui/icons-material/Folder'; 31 | import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; 32 | import PersonIcon from '@mui/icons-material/Person'; 33 | import SmartToyIcon from '@mui/icons-material/SmartToy'; 34 | import StorageIcon from '@mui/icons-material/Storage'; 35 | import AccountTreeIcon from '@mui/icons-material/AccountTree'; 36 | import DataObjectIcon from '@mui/icons-material/DataObject'; 37 | import FileDownloadIcon from '@mui/icons-material/FileDownload'; 38 | import WarningIcon from '@mui/icons-material/Warning'; 39 | import { colors } from '../App'; 40 | 41 | const ChatDetail = () => { 42 | const { sessionId } = useParams(); 43 | const [chat, setChat] = useState(null); 44 | const [loading, setLoading] = useState(true); 45 | const [error, setError] = useState(null); 46 | const [exportModalOpen, setExportModalOpen] = useState(false); 47 | const [formatDialogOpen, setFormatDialogOpen] = useState(false); 48 | const [exportFormat, setExportFormat] = useState('html'); 49 | const [dontShowExportWarning, setDontShowExportWarning] = useState(false); 50 | 51 | useEffect(() => { 52 | const fetchChat = async () => { 53 | try { 54 | const response = await axios.get(`/api/chat/${sessionId}`); 55 | setChat(response.data); 56 | setLoading(false); 57 | } catch (err) { 58 | setError(err.message); 59 | setLoading(false); 60 | } 61 | }; 62 | 63 | fetchChat(); 64 | 65 | // Check if user has previously chosen to not show the export warning 66 | const warningPreference = document.cookie 67 | .split('; ') 68 | .find(row => row.startsWith('dontShowExportWarning=')); 69 | 70 | if (warningPreference) { 71 | setDontShowExportWarning(warningPreference.split('=')[1] === 'true'); 72 | } 73 | }, [sessionId]); 74 | 75 | // Handle format dialog selection 76 | const handleFormatDialogOpen = () => { 77 | setFormatDialogOpen(true); 78 | }; 79 | 80 | const handleFormatDialogClose = (confirmed) => { 81 | setFormatDialogOpen(false); 82 | 83 | if (confirmed) { 84 | // After format selection, show warning dialog or proceed directly 85 | if (dontShowExportWarning) { 86 | proceedWithExport(exportFormat); 87 | } else { 88 | setExportModalOpen(true); 89 | } 90 | } 91 | }; 92 | 93 | // Handle export warning confirmation 94 | const handleExportWarningClose = (confirmed) => { 95 | setExportModalOpen(false); 96 | 97 | // Save preference in cookies if "Don't show again" is checked 98 | if (dontShowExportWarning) { 99 | const expiryDate = new Date(); 100 | expiryDate.setFullYear(expiryDate.getFullYear() + 1); // Cookie lasts 1 year 101 | document.cookie = `dontShowExportWarning=true; expires=${expiryDate.toUTCString()}; path=/`; 102 | } 103 | 104 | // If confirmed, proceed with export 105 | if (confirmed) { 106 | proceedWithExport(exportFormat); 107 | } 108 | }; 109 | 110 | // Function to initiate export process 111 | const handleExport = () => { 112 | // First open format selection dialog 113 | handleFormatDialogOpen(); 114 | }; 115 | 116 | // Function to actually perform the export 117 | const proceedWithExport = async (format) => { 118 | try { 119 | // Request the exported chat as a raw Blob so we can download it directly 120 | const response = await axios.get( 121 | `/api/chat/${sessionId}/export?format=${format}`, 122 | { responseType: 'blob' } 123 | ); 124 | 125 | const blob = response.data; 126 | 127 | // Guard-check to avoid downloading an empty file 128 | if (!blob || blob.size === 0) { 129 | throw new Error('Received empty or invalid content from server'); 130 | } 131 | 132 | // Ensure the blob has the correct MIME type 133 | const mimeType = format === 'json' ? 'application/json;charset=utf-8' : 'text/html;charset=utf-8'; 134 | const typedBlob = blob.type ? blob : new Blob([blob], { type: mimeType }); 135 | 136 | // Download Logic 137 | const extension = format === 'json' ? 'json' : 'html'; 138 | const filename = `cursor-chat-${sessionId.slice(0, 8)}.${extension}`; 139 | const link = document.createElement('a'); 140 | 141 | // Create an object URL for the (possibly re-typed) blob 142 | const url = URL.createObjectURL(typedBlob); 143 | link.href = url; 144 | link.download = filename; 145 | 146 | // Append link to the body (required for Firefox) 147 | document.body.appendChild(link); 148 | 149 | // Programmatically click the link to trigger the download 150 | link.click(); 151 | 152 | // Clean up: remove the link and revoke the object URL 153 | document.body.removeChild(link); 154 | URL.revokeObjectURL(url); 155 | } catch (err) { 156 | console.error('Export failed:', err); 157 | alert('Failed to export chat – check console for details'); 158 | } 159 | }; 160 | 161 | if (loading) { 162 | return ( 163 | 164 | 165 | 166 | ); 167 | } 168 | 169 | if (error) { 170 | return ( 171 | 172 | 173 | Error: {error} 174 | 175 | 176 | ); 177 | } 178 | 179 | if (!chat) { 180 | return ( 181 | 182 | 183 | Chat not found 184 | 185 | 186 | ); 187 | } 188 | 189 | // Format the date safely 190 | let dateDisplay = 'Unknown date'; 191 | try { 192 | if (chat.date) { 193 | const dateObj = new Date(chat.date * 1000); 194 | // Check if date is valid 195 | if (!isNaN(dateObj.getTime())) { 196 | dateDisplay = dateObj.toLocaleString(); 197 | } 198 | } 199 | } catch (err) { 200 | console.error('Error formatting date:', err); 201 | } 202 | 203 | // Ensure messages exist 204 | const messages = Array.isArray(chat.messages) ? chat.messages : []; 205 | const projectName = chat.project?.name || 'Unknown Project'; 206 | 207 | return ( 208 | 209 | {/* Format Selection Dialog */} 210 | handleFormatDialogClose(false)} 213 | aria-labelledby="format-selection-dialog-title" 214 | > 215 | 216 | 217 | Export Format 218 | 219 | 220 | 221 | Please select the export format for your chat: 222 | 223 | 224 | setExportFormat(e.target.value)} 229 | > 230 | } label="HTML" /> 231 | } label="JSON" /> 232 | 233 | 234 | 235 | 236 | 239 | 242 | 243 | 244 | 245 | {/* Export Warning Modal */} 246 | handleExportWarningClose(false)} 249 | aria-labelledby="export-warning-dialog-title" 250 | > 251 | 252 | 253 | Export Warning 254 | 255 | 256 | 257 | Please make sure your exported chat doesn't include sensitive data such as API keys and customer information. 258 | 259 | setDontShowExportWarning(e.target.checked)} 264 | /> 265 | } 266 | label="Don't show this warning again" 267 | sx={{ mt: 2 }} 268 | /> 269 | 270 | 271 | 274 | 277 | 278 | 279 | 280 | 281 | 293 | 294 | 324 | 325 | 326 | 334 | 340 | 341 | 342 | 343 | {projectName} 344 | 345 | } 347 | label={dateDisplay} 348 | size="small" 349 | sx={{ 350 | fontWeight: 500, 351 | color: 'white', 352 | '& .MuiChip-icon': { color: 'white' }, 353 | '& .MuiChip-label': { px: 1 } 354 | }} 355 | /> 356 | 357 | 358 | 359 | 360 | 366 | 367 | 368 | 369 | Path: {chat.project?.rootPath || 'Unknown location'} 370 | 371 | 372 | 373 | {chat.workspace_id && ( 374 | 375 | 376 | 377 | Workspace: {chat.workspace_id} 378 | 379 | 380 | )} 381 | 382 | {chat.db_path && ( 383 | 384 | 385 | 386 | DB: {chat.db_path.split('/').pop()} 387 | 388 | 389 | )} 390 | 391 | 392 | 393 | 394 | 395 | Conversation History 396 | 397 | 398 | {messages.length === 0 ? ( 399 | 400 | 401 | No messages found in this conversation. 402 | 403 | 404 | ) : ( 405 | 406 | {messages.map((message, index) => ( 407 | 408 | 409 | 418 | {message.role === 'user' ? : } 419 | 420 | 421 | {message.role === 'user' ? 'You' : 'Cursor Assistant'} 422 | 423 | 424 | 425 | 437 | 466 | {typeof message.content === 'string' ? ( 467 | 468 | {message.content} 469 | 470 | ) : ( 471 | Content unavailable 472 | )} 473 | 474 | 475 | 476 | ))} 477 | 478 | )} 479 | 480 | ); 481 | }; 482 | 483 | export default ChatDetail; -------------------------------------------------------------------------------- /frontend/src/components/ChatList.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import axios from 'axios'; 4 | import { 5 | Container, 6 | Typography, 7 | Box, 8 | Card, 9 | CardContent, 10 | Grid, 11 | Chip, 12 | CircularProgress, 13 | Divider, 14 | Paper, 15 | Alert, 16 | Button, 17 | Collapse, 18 | IconButton, 19 | alpha, 20 | TextField, 21 | InputAdornment, 22 | CardActions, 23 | Tooltip, 24 | Dialog, 25 | DialogTitle, 26 | DialogContent, 27 | DialogActions, 28 | FormControlLabel, 29 | Checkbox, 30 | DialogContentText, 31 | Switch, 32 | } from '@mui/material'; 33 | import FolderIcon from '@mui/icons-material/Folder'; 34 | import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; 35 | import MessageIcon from '@mui/icons-material/Message'; 36 | import InfoIcon from '@mui/icons-material/Info'; 37 | import RefreshIcon from '@mui/icons-material/Refresh'; 38 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; 39 | import ExpandLessIcon from '@mui/icons-material/ExpandLess'; 40 | import SearchIcon from '@mui/icons-material/Search'; 41 | import ClearIcon from '@mui/icons-material/Clear'; 42 | import FileDownloadIcon from '@mui/icons-material/FileDownload'; 43 | import WarningIcon from '@mui/icons-material/Warning'; 44 | import { colors } from '../App'; 45 | 46 | const ChatList = () => { 47 | const [chats, setChats] = useState([]); 48 | const [loading, setLoading] = useState(true); 49 | const [error, setError] = useState(null); 50 | const [isDemo, setIsDemo] = useState(false); 51 | const [showDemoChats, setShowDemoChats] = useState(false); 52 | const [expandedProjects, setExpandedProjects] = useState({}); 53 | const [searchQuery, setSearchQuery] = useState(''); 54 | const [exportModalOpen, setExportModalOpen] = useState(false); 55 | const [dontShowExportWarning, setDontShowExportWarning] = useState(false); 56 | const [currentExportSession, setCurrentExportSession] = useState(null); 57 | 58 | const fetchChats = async () => { 59 | setLoading(true); 60 | try { 61 | const response = await axios.get('/api/chats'); 62 | const chatData = response.data; 63 | 64 | // Check if these are sample chats (demo data) 65 | const isSampleData = chatData.length > 0 && chatData[0].session_id?.startsWith('sample'); 66 | setIsDemo(isSampleData); 67 | 68 | // FOR DEMO: Add two extra projects with multiple sessions 69 | const createDemoSessions = (projectName, projectPath, workspaceId, numSessions) => { 70 | const sessions = []; 71 | for (let i = 1; i <= numSessions; i++) { 72 | const numMessages = Math.floor(Math.random() * 25) + 5; // 5 to 30 messages 73 | const messages = Array.from({ length: numMessages }, (_, index) => ({ 74 | content: `Demo Message ${index + 1} in session ${i} for ${projectName}. Lorem ipsum dolor sit amet, consectetur adipiscing elit.`, 75 | timestamp: Date.now() / 1000 - (index * Math.random() * 7200 + 60), // Simulate messages over time with randomness 76 | })); 77 | 78 | sessions.push({ 79 | project: { name: projectName, rootPath: projectPath, workspace_id: workspaceId }, 80 | messages: messages, 81 | date: Date.now() / 1000 - (i * 86400 * Math.random()), // Vary session dates slightly 82 | session_id: `demo-session-${projectName.toLowerCase().replace(/\s+/g, '-')}-${i}`, 83 | workspace_id: workspaceId, 84 | db_path: `demo/db/path/${projectName}` // Simplified demo path 85 | }); 86 | } 87 | return sessions; 88 | }; 89 | 90 | let demoProjects = []; 91 | if (showDemoChats) { 92 | demoProjects = [ 93 | ...createDemoSessions("dolores-voice-eval", "/Users/saharm/Documents/codebase/dolores-voice-eval", "demo-voice-eval", 3), 94 | ...createDemoSessions("dolores-agent", "/Users/saharm/Documents/codebase/dolores-agent", "demo-agent", 4) 95 | ]; 96 | } 97 | 98 | const combinedData = [...demoProjects, ...chatData]; 99 | 100 | setChats(combinedData); 101 | setLoading(false); 102 | } catch (err) { 103 | setError(err.message); 104 | setLoading(false); 105 | } 106 | }; 107 | 108 | // Toggle demo chats visibility 109 | const toggleDemoChats = () => { 110 | const newValue = !showDemoChats; 111 | setShowDemoChats(newValue); 112 | // Save preference to localStorage 113 | localStorage.setItem('showDemoChats', newValue ? 'true' : 'false'); 114 | fetchChats(); 115 | }; 116 | 117 | useEffect(() => { 118 | // Load demo chats preference from localStorage (default to false) 119 | const savedShowDemoChats = localStorage.getItem('showDemoChats') === 'true'; 120 | setShowDemoChats(savedShowDemoChats); 121 | 122 | fetchChats(); 123 | 124 | // Check if user has previously chosen to not show the export warning 125 | const warningPreference = document.cookie 126 | .split('; ') 127 | .find(row => row.startsWith('dontShowExportWarning=')); 128 | 129 | if (warningPreference) { 130 | setDontShowExportWarning(warningPreference.split('=')[1] === 'true'); 131 | } 132 | }, []); 133 | 134 | // Watch for changes to showDemoChats and refetch when it changes 135 | useEffect(() => { 136 | fetchChats(); 137 | }, [showDemoChats]); 138 | 139 | const toggleProjectExpand = (projectName) => { 140 | setExpandedProjects(prev => ({ 141 | ...prev, 142 | [projectName]: !prev[projectName] 143 | })); 144 | }; 145 | 146 | // Filter chats based on search query 147 | const filteredChatsByProject = () => { 148 | if (!searchQuery.trim()) { 149 | return chats.reduce((acc, chat) => { 150 | const projectName = chat.project?.name || 'Unknown Project'; 151 | 152 | if (!acc[projectName]) { 153 | acc[projectName] = { 154 | name: projectName, 155 | path: chat.project?.rootPath || 'Unknown', 156 | chats: [] 157 | }; 158 | } 159 | 160 | if (chat.project?.rootPath && 161 | acc[projectName].path === 'Unknown') { 162 | acc[projectName].path = chat.project.rootPath; 163 | } 164 | 165 | acc[projectName].chats.push(chat); 166 | return acc; 167 | }, {}); 168 | } 169 | 170 | const query = searchQuery.toLowerCase(); 171 | return chats.reduce((acc, chat) => { 172 | const projectName = chat.project?.name || 'Unknown Project'; 173 | 174 | // Check if project name matches 175 | const projectMatches = projectName.toLowerCase().includes(query); 176 | 177 | // Check if any message content matches 178 | const contentMatches = Array.isArray(chat.messages) && chat.messages.some(msg => 179 | typeof msg.content === 'string' && msg.content.toLowerCase().includes(query) 180 | ); 181 | 182 | if (projectMatches || contentMatches) { 183 | if (!acc[projectName]) { 184 | acc[projectName] = { 185 | name: projectName, 186 | path: chat.project?.rootPath || 'Unknown', 187 | chats: [] 188 | }; 189 | } 190 | 191 | if (chat.project?.rootPath && 192 | acc[projectName].path === 'Unknown') { 193 | acc[projectName].path = chat.project.rootPath; 194 | } 195 | 196 | acc[projectName].chats.push(chat); 197 | } 198 | 199 | return acc; 200 | }, {}); 201 | }; 202 | 203 | // Clear search query 204 | const clearSearch = () => { 205 | setSearchQuery(''); 206 | }; 207 | 208 | // Handle search input change 209 | const handleSearchChange = (event) => { 210 | setSearchQuery(event.target.value); 211 | }; 212 | 213 | // Handle export warning confirmation 214 | const handleExportWarningClose = (confirmed) => { 215 | setExportModalOpen(false); 216 | 217 | // Save preference in cookies if "Don't show again" is checked 218 | if (dontShowExportWarning) { 219 | const expiryDate = new Date(); 220 | expiryDate.setFullYear(expiryDate.getFullYear() + 1); // Cookie lasts 1 year 221 | document.cookie = `dontShowExportWarning=true; expires=${expiryDate.toUTCString()}; path=/`; 222 | } 223 | 224 | // If confirmed, proceed with export 225 | if (confirmed && currentExportSession) { 226 | proceedWithExport(currentExportSession); 227 | } 228 | 229 | // Reset current export session 230 | setCurrentExportSession(null); 231 | }; 232 | 233 | // Function to initiate export process 234 | const handleExport = (e, sessionId) => { 235 | // Prevent navigation to chat detail 236 | e.preventDefault(); 237 | e.stopPropagation(); 238 | 239 | // Check if warning should be shown 240 | if (dontShowExportWarning) { 241 | proceedWithExport(sessionId); 242 | } else { 243 | setCurrentExportSession(sessionId); 244 | setExportModalOpen(true); 245 | } 246 | }; 247 | 248 | // Function to actually perform the export 249 | const proceedWithExport = async (sessionId) => { 250 | try { 251 | console.log("Starting HTML export for session:", sessionId); 252 | console.log(`Making API request to: /api/chat/${sessionId}/export`); 253 | 254 | const response = await axios.get( 255 | `/api/chat/${sessionId}/export`, 256 | { responseType: 'blob' } 257 | ); 258 | 259 | const blob = response.data; 260 | console.log('Received blob size:', blob ? blob.size : 0); 261 | 262 | if (!blob || blob.size === 0) { 263 | throw new Error('Received empty or invalid content from server'); 264 | } 265 | 266 | // Ensure the blob has the correct MIME type before saving 267 | const typedBlob = blob.type ? blob : new Blob([blob], { type: 'text/html;charset=utf-8' }); 268 | console.log('Prepared typed blob, size:', typedBlob.size); 269 | 270 | // --- Download Logic Start --- 271 | const filename = `cursor-chat-${sessionId.slice(0, 8)}.html`; 272 | const link = document.createElement('a'); 273 | 274 | // Create an object URL for the (possibly re-typed) blob 275 | const url = URL.createObjectURL(typedBlob); 276 | link.href = url; 277 | link.download = filename; 278 | 279 | // Append link to the body (required for Firefox) 280 | document.body.appendChild(link); 281 | 282 | // Programmatically click the link to trigger the download 283 | link.click(); 284 | 285 | // Clean up: remove the link and revoke the object URL 286 | document.body.removeChild(link); 287 | URL.revokeObjectURL(url); 288 | console.log("Download initiated and cleanup complete"); 289 | // --- Download Logic End --- 290 | 291 | } catch (error) { 292 | // ADDED: More detailed error logging 293 | console.error('Detailed export error:', error); 294 | if (error.response) { 295 | // The request was made and the server responded with a status code 296 | // that falls out of the range of 2xx 297 | console.error('Error Response Data:', error.response.data); 298 | console.error('Error Response Status:', error.response.status); 299 | console.error('Error Response Headers:', error.response.headers); 300 | } else if (error.request) { 301 | // The request was made but no response was received 302 | // `error.request` is an instance of XMLHttpRequest in the browser 303 | console.error('Error Request:', error.request); 304 | } else { 305 | // Something happened in setting up the request that triggered an Error 306 | console.error('Error Message:', error.message); 307 | } 308 | console.error('Error Config:', error.config); 309 | 310 | const errorMessage = error.response ? 311 | `Server error: ${error.response.status}` : 312 | error.request ? 313 | 'No response received from server' : 314 | error.message || 'Unknown error setting up request'; 315 | alert(`Failed to export chat: ${errorMessage}`); 316 | } 317 | }; 318 | 319 | if (loading) { 320 | return ( 321 | 322 | 323 | 324 | ); 325 | } 326 | 327 | if (error) { 328 | return ( 329 | 330 | 331 | Error: {error} 332 | 333 | 334 | ); 335 | } 336 | 337 | const chatsByProject = filteredChatsByProject(); 338 | 339 | return ( 340 | 341 | {/* No need to show error again since we have the conditional return above */} 342 | 343 | 344 | 345 | Cursor Chat History 346 | 347 | 348 | 355 | } 356 | label="Show Demo Chats" 357 | /> 358 | 373 | 374 | 375 | 376 | {/* Export Warning Modal */} 377 | handleExportWarningClose(false)} 380 | aria-labelledby="export-warning-dialog-title" 381 | > 382 | 383 | 384 | Export Warning 385 | 386 | 387 | 388 | Please make sure your exported chat doesn't include sensitive data such as API keys and customer information. 389 | 390 | setDontShowExportWarning(e.target.checked)} 395 | /> 396 | } 397 | label="Don't show this warning again" 398 | sx={{ mt: 2 }} 399 | /> 400 | 401 | 402 | 409 | 412 | 413 | 414 | 415 | {/* Search Bar */} 416 | 427 | 428 | 429 | ), 430 | endAdornment: searchQuery && ( 431 | 432 | 438 | 439 | 440 | 441 | ), 442 | sx: { borderRadius: 2 } 443 | }} 444 | /> 445 | 446 | {isDemo && ( 447 | 448 | 449 | Currently showing demo data. No actual Cursor View data was found on your system. 450 | 451 | 452 | Cursor chat databases are typically stored in: 453 | 454 |
  • Mac: ~/Library/Application Support/Cursor/User/globalStorage/cursor.cursor
  • 455 |
  • Windows: %APPDATA%\Cursor\User\globalStorage\cursor.cursor
  • 456 |
  • Linux: ~/.config/Cursor/User/globalStorage/cursor.cursor
  • 457 |
    458 |
    459 | 471 |
    472 | )} 473 | 474 | {Object.keys(chatsByProject).length === 0 ? ( 475 | 483 | 484 | 485 | {searchQuery ? 'No Results Found' : 'No Chat History Found'} 486 | 487 | 488 | {searchQuery 489 | ? `We couldn't find any chats matching "${searchQuery}".` 490 | : "We couldn't find any Cursor chat data on your system. This could be because:"} 491 | 492 | {!searchQuery && ( 493 | 494 | 495 |
  • You haven't used Cursor's AI Assistant yet
  • 496 |
  • Your Cursor databases are stored in a non-standard location
  • 497 |
  • There might be permission issues accessing the database files
  • 498 |
    499 |
    500 | )} 501 | {searchQuery ? ( 502 | 512 | ) : ( 513 | 523 | )} 524 |
    525 | ) : ( 526 | Object.entries(chatsByProject).map(([projectName, projectData]) => { 527 | return ( 528 | 529 | 541 | toggleProjectExpand(projectName)} 554 | > 555 | 556 | 557 | 558 | 559 | {projectData.name} 560 | 561 | 574 | 575 | { 586 | // Prevent the click from reaching the parent Box 587 | e.stopPropagation(); 588 | toggleProjectExpand(projectName); 589 | }} 590 | > 591 | {expandedProjects[projectName] ? : } 592 | 593 | 594 | 595 | {projectData.path} 596 | 597 | 598 | 599 | 600 | 601 | 602 | {projectData.chats.map((chat, index) => { 603 | // Format the date safely 604 | let dateDisplay = 'Unknown date'; 605 | try { 606 | if (chat.date) { 607 | const dateObj = new Date(chat.date * 1000); 608 | // Check if date is valid 609 | if (!isNaN(dateObj.getTime())) { 610 | dateDisplay = dateObj.toLocaleString(); 611 | } 612 | } 613 | } catch (err) { 614 | console.error('Error formatting date:', err); 615 | } 616 | 617 | return ( 618 | 619 | 636 | 637 | 643 | 644 | 645 | 646 | {dateDisplay} 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | 655 | 656 | {Array.isArray(chat.messages) ? chat.messages.length : 0} messages 657 | 658 | 659 | 660 | {chat.db_path && ( 661 | 672 | DB: {chat.db_path.split('/').slice(-2).join('/')} 673 | 674 | )} 675 | 676 | {Array.isArray(chat.messages) && chat.messages[0] && chat.messages[0].content && ( 677 | 685 | 697 | {typeof chat.messages[0].content === 'string' 698 | ? chat.messages[0].content.substring(0, 100) + (chat.messages[0].content.length > 100 ? '...' : '') 699 | : 'Content unavailable'} 700 | 701 | 702 | )} 703 | 704 | 705 | 706 | handleExport(e, chat.session_id)} 709 | sx={{ 710 | ml: 'auto', 711 | position: 'relative', 712 | '&::after': dontShowExportWarning ? null : { 713 | content: '""', 714 | position: 'absolute', 715 | width: '6px', 716 | height: '6px', 717 | backgroundColor: 'warning.main', 718 | borderRadius: '50%', 719 | top: '2px', 720 | right: '2px' 721 | } 722 | }} 723 | > 724 | 725 | 726 | 727 | 728 | 729 | 730 | ); 731 | })} 732 | 733 | 734 | 735 | ); 736 | }) 737 | )} 738 |
    739 | ); 740 | }; 741 | 742 | export default ChatList; -------------------------------------------------------------------------------- /frontend/src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { AppBar, Toolbar, Typography, Box, Container, Button } from '@mui/material'; 4 | import ChatIcon from '@mui/icons-material/Chat'; 5 | import GitHubIcon from '@mui/icons-material/GitHub'; 6 | import { colors } from '../App'; 7 | 8 | const Header = () => { 9 | return ( 10 | 11 | 12 | 13 | 23 | 24 | 25 | Cursor View 26 | 27 | 28 | 29 | 49 | 50 | 51 | 52 | ); 53 | }; 54 | 55 | export default Header; -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 5 | -webkit-font-smoothing: antialiased; 6 | -moz-osx-font-smoothing: grayscale; 7 | /* Background is now managed by Material UI theme */ 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 12 | background-color: #2D2D2D; 13 | color: #E6E6E6; 14 | padding: 0.2em 0.4em; 15 | border-radius: 3px; 16 | font-size: 85%; 17 | } 18 | 19 | pre { 20 | background-color: #2D2D2D; 21 | color: #E6E6E6; 22 | padding: 16px; 23 | border-radius: 6px; 24 | overflow: auto; 25 | } 26 | 27 | a { 28 | color: #6E2CF4; 29 | text-decoration: none; 30 | } 31 | 32 | a:hover { 33 | text-decoration: none; 34 | } 35 | 36 | /* Custom scrollbar */ 37 | ::-webkit-scrollbar { 38 | width: 10px; 39 | height: 10px; 40 | } 41 | 42 | ::-webkit-scrollbar-track { 43 | background: #202020; 44 | } 45 | 46 | ::-webkit-scrollbar-thumb { 47 | background: #4A4A4A; 48 | border-radius: 5px; 49 | } 50 | 51 | ::-webkit-scrollbar-thumb:hover { 52 | background: #5F5F5F; 53 | } -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | 5 | // Add global styles 6 | import './index.css'; 7 | 8 | const root = ReactDOM.createRoot(document.getElementById('root')); 9 | root.render( 10 | 11 | 12 | 13 | ); -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask>=2.0.0 2 | flask-cors>=3.0.10 -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Simple API server to serve Cursor chat data for the web interface. 4 | """ 5 | 6 | import json 7 | import uuid 8 | import logging 9 | import datetime 10 | import os 11 | import platform 12 | import sqlite3 13 | import argparse 14 | import pathlib 15 | from collections import defaultdict 16 | from typing import Dict, Any, Iterable 17 | from pathlib import Path 18 | from flask import Flask, Response, jsonify, send_from_directory, request 19 | from flask_cors import CORS 20 | 21 | # Configure logging 22 | logging.basicConfig(level=logging.INFO, 23 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') 24 | logger = logging.getLogger(__name__) 25 | 26 | app = Flask(__name__, static_folder='frontend/build') 27 | CORS(app) 28 | 29 | ################################################################################ 30 | # Cursor storage roots 31 | ################################################################################ 32 | def cursor_root() -> pathlib.Path: 33 | h = pathlib.Path.home() 34 | s = platform.system() 35 | if s == "Darwin": return h / "Library" / "Application Support" / "Cursor" 36 | if s == "Windows": return h / "AppData" / "Roaming" / "Cursor" 37 | if s == "Linux": return h / ".config" / "Cursor" 38 | raise RuntimeError(f"Unsupported OS: {s}") 39 | 40 | ################################################################################ 41 | # Helpers 42 | ################################################################################ 43 | def j(cur: sqlite3.Cursor, table: str, key: str): 44 | cur.execute(f"SELECT value FROM {table} WHERE key=?", (key,)) 45 | row = cur.fetchone() 46 | if row: 47 | try: return json.loads(row[0]) 48 | except Exception as e: 49 | logger.debug(f"Failed to parse JSON for {key}: {e}") 50 | return None 51 | 52 | def iter_bubbles_from_disk_kv(db: pathlib.Path) -> Iterable[tuple[str,str,str,str]]: 53 | """Yield (composerId, role, text, db_path) from cursorDiskKV table.""" 54 | try: 55 | con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) 56 | cur = con.cursor() 57 | # Check if table exists 58 | cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'") 59 | if not cur.fetchone(): 60 | con.close() 61 | return 62 | 63 | cur.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'") 64 | except sqlite3.DatabaseError as e: 65 | logger.debug(f"Database error with {db}: {e}") 66 | return 67 | 68 | db_path_str = str(db) 69 | 70 | for k, v in cur.fetchall(): 71 | try: 72 | if v is None: 73 | continue 74 | 75 | b = json.loads(v) 76 | except Exception as e: 77 | logger.debug(f"Failed to parse bubble JSON for key {k}: {e}") 78 | continue 79 | 80 | txt = (b.get("text") or b.get("richText") or "").strip() 81 | if not txt: continue 82 | role = "user" if b.get("type") == 1 else "assistant" 83 | composerId = k.split(":")[1] # Format is bubbleId:composerId:bubbleId 84 | yield composerId, role, txt, db_path_str 85 | 86 | con.close() 87 | 88 | def iter_chat_from_item_table(db: pathlib.Path) -> Iterable[tuple[str,str,str,str]]: 89 | """Yield (composerId, role, text, db_path) from ItemTable.""" 90 | try: 91 | con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) 92 | cur = con.cursor() 93 | 94 | # Try to get chat data from workbench.panel.aichat.view.aichat.chatdata 95 | chat_data = j(cur, "ItemTable", "workbench.panel.aichat.view.aichat.chatdata") 96 | if chat_data and "tabs" in chat_data: 97 | for tab in chat_data.get("tabs", []): 98 | tab_id = tab.get("tabId", "unknown") 99 | for bubble in tab.get("bubbles", []): 100 | bubble_type = bubble.get("type") 101 | if not bubble_type: 102 | continue 103 | 104 | # Extract text from various possible fields 105 | text = "" 106 | if "text" in bubble: 107 | text = bubble["text"] 108 | elif "content" in bubble: 109 | text = bubble["content"] 110 | 111 | if text and isinstance(text, str): 112 | role = "user" if bubble_type == "user" else "assistant" 113 | yield tab_id, role, text, str(db) 114 | 115 | # Check for composer data 116 | composer_data = j(cur, "ItemTable", "composer.composerData") 117 | if composer_data: 118 | for comp in composer_data.get("allComposers", []): 119 | comp_id = comp.get("composerId", "unknown") 120 | messages = comp.get("messages", []) 121 | for msg in messages: 122 | role = msg.get("role", "unknown") 123 | content = msg.get("content", "") 124 | if content: 125 | yield comp_id, role, content, str(db) 126 | 127 | # Also check for aiService entries 128 | for key_prefix in ["aiService.prompts", "aiService.generations"]: 129 | try: 130 | cur.execute("SELECT key, value FROM ItemTable WHERE key LIKE ?", (f"{key_prefix}%",)) 131 | for k, v in cur.fetchall(): 132 | try: 133 | data = json.loads(v) 134 | if isinstance(data, list): 135 | for item in data: 136 | if "id" in item and "text" in item: 137 | role = "user" if "prompts" in key_prefix else "assistant" 138 | yield item.get("id", "unknown"), role, item.get("text", ""), str(db) 139 | except json.JSONDecodeError: 140 | continue 141 | except sqlite3.Error: 142 | continue 143 | 144 | except sqlite3.DatabaseError as e: 145 | logger.debug(f"Database error in ItemTable with {db}: {e}") 146 | return 147 | finally: 148 | if 'con' in locals(): 149 | con.close() 150 | 151 | def iter_composer_data(db: pathlib.Path) -> Iterable[tuple[str,dict,str]]: 152 | """Yield (composerId, composerData, db_path) from cursorDiskKV table.""" 153 | try: 154 | con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) 155 | cur = con.cursor() 156 | # Check if table exists 157 | cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'") 158 | if not cur.fetchone(): 159 | con.close() 160 | return 161 | 162 | cur.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'") 163 | except sqlite3.DatabaseError as e: 164 | logger.debug(f"Database error with {db}: {e}") 165 | return 166 | 167 | db_path_str = str(db) 168 | 169 | for k, v in cur.fetchall(): 170 | try: 171 | if v is None: 172 | continue 173 | 174 | composer_data = json.loads(v) 175 | composer_id = k.split(":")[1] 176 | yield composer_id, composer_data, db_path_str 177 | 178 | except Exception as e: 179 | logger.debug(f"Failed to parse composer data for key {k}: {e}") 180 | continue 181 | 182 | con.close() 183 | 184 | ################################################################################ 185 | # Workspace discovery 186 | ################################################################################ 187 | def workspaces(base: pathlib.Path): 188 | ws_root = base / "User" / "workspaceStorage" 189 | if not ws_root.exists(): 190 | return 191 | for folder in ws_root.iterdir(): 192 | db = folder / "state.vscdb" 193 | if db.exists(): 194 | yield folder.name, db 195 | 196 | def extract_project_name_from_path(root_path, debug=False): 197 | """ 198 | Extract a project name from a path, skipping user directories. 199 | """ 200 | if not root_path or root_path == '/': 201 | return "Root" 202 | 203 | path_parts = [p for p in root_path.split('/') if p] 204 | 205 | # Skip common user directory patterns 206 | project_name = None 207 | home_dir_patterns = ['Users', 'home'] 208 | 209 | # Get current username for comparison 210 | current_username = os.path.basename(os.path.expanduser('~')) 211 | 212 | # Find user directory in path 213 | username_index = -1 214 | for i, part in enumerate(path_parts): 215 | if part in home_dir_patterns: 216 | username_index = i + 1 217 | break 218 | 219 | # If this is just /Users/username with no deeper path, don't use username as project 220 | if username_index >= 0 and username_index < len(path_parts) and path_parts[username_index] == current_username: 221 | if len(path_parts) <= username_index + 1: 222 | return "Home Directory" 223 | 224 | if username_index >= 0 and username_index + 1 < len(path_parts): 225 | # First try specific project directories we know about by name 226 | known_projects = ['genaisf', 'cursor-view', 'cursor', 'cursor-apps', 'universal-github', 'inquiry'] 227 | 228 | # Look at the most specific/deepest part of the path first 229 | for i in range(len(path_parts)-1, username_index, -1): 230 | if path_parts[i] in known_projects: 231 | project_name = path_parts[i] 232 | if debug: 233 | logger.debug(f"Found known project name from specific list: {project_name}") 234 | break 235 | 236 | # If no known project found, use the last part of the path as it's likely the project directory 237 | if not project_name and len(path_parts) > username_index + 1: 238 | # Check if we have a structure like /Users/username/Documents/codebase/project_name 239 | if 'Documents' in path_parts and 'codebase' in path_parts: 240 | doc_index = path_parts.index('Documents') 241 | codebase_index = path_parts.index('codebase') 242 | 243 | # If there's a path component after 'codebase', use that as the project name 244 | if codebase_index + 1 < len(path_parts): 245 | project_name = path_parts[codebase_index + 1] 246 | if debug: 247 | logger.debug(f"Found project name in Documents/codebase structure: {project_name}") 248 | 249 | # If no specific structure found, use the last component of the path 250 | if not project_name: 251 | project_name = path_parts[-1] 252 | if debug: 253 | logger.debug(f"Using last path component as project name: {project_name}") 254 | 255 | # Skip username as project name 256 | if project_name == current_username: 257 | project_name = 'Home Directory' 258 | if debug: 259 | logger.debug(f"Avoided using username as project name") 260 | 261 | # Skip common project container directories 262 | project_containers = ['Documents', 'Projects', 'Code', 'workspace', 'repos', 'git', 'src', 'codebase'] 263 | if project_name in project_containers: 264 | # Don't use container directories as project names 265 | # Try to use the next component if available 266 | container_index = path_parts.index(project_name) 267 | if container_index + 1 < len(path_parts): 268 | project_name = path_parts[container_index + 1] 269 | if debug: 270 | logger.debug(f"Skipped container dir, using next component as project name: {project_name}") 271 | 272 | # If we still don't have a project name, use the first non-system directory after username 273 | if not project_name and username_index + 1 < len(path_parts): 274 | system_dirs = ['Library', 'Applications', 'System', 'var', 'opt', 'tmp'] 275 | for i in range(username_index + 1, len(path_parts)): 276 | if path_parts[i] not in system_dirs and path_parts[i] not in project_containers: 277 | project_name = path_parts[i] 278 | if debug: 279 | logger.debug(f"Using non-system dir as project name: {project_name}") 280 | break 281 | else: 282 | # If not in a user directory, use the basename 283 | project_name = path_parts[-1] if path_parts else "Root" 284 | if debug: 285 | logger.debug(f"Using basename as project name: {project_name}") 286 | 287 | # Final check: don't return username as project name 288 | if project_name == current_username: 289 | project_name = "Home Directory" 290 | if debug: 291 | logger.debug(f"Final check: replaced username with 'Home Directory'") 292 | 293 | return project_name if project_name else "Unknown Project" 294 | 295 | def workspace_info(db: pathlib.Path): 296 | try: 297 | con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) 298 | cur = con.cursor() 299 | 300 | # Get file paths from history entries to extract the project name 301 | proj = {"name": "(unknown)", "rootPath": "(unknown)"} 302 | ents = j(cur,"ItemTable","history.entries") or [] 303 | 304 | # Extract file paths from history entries, stripping the file:/// scheme 305 | paths = [] 306 | for e in ents: 307 | resource = e.get("editor", {}).get("resource", "") 308 | if resource and resource.startswith("file:///"): 309 | paths.append(resource[len("file:///"):]) 310 | 311 | # If we found file paths, extract the project name using the longest common prefix 312 | if paths: 313 | logger.debug(f"Found {len(paths)} paths in history entries") 314 | 315 | # Get the longest common prefix 316 | common_prefix = os.path.commonprefix(paths) 317 | logger.debug(f"Common prefix: {common_prefix}") 318 | 319 | # Find the last directory separator in the common prefix 320 | last_separator_index = common_prefix.rfind('/') 321 | if last_separator_index > 0: 322 | project_root = common_prefix[:last_separator_index] 323 | logger.debug(f"Project root from common prefix: {project_root}") 324 | 325 | # Extract the project name using the helper function 326 | project_name = extract_project_name_from_path(project_root, debug=True) 327 | 328 | proj = {"name": project_name, "rootPath": "/" + project_root.lstrip('/')} 329 | 330 | # Try backup methods if we didn't get a project name 331 | if proj["name"] == "(unknown)": 332 | logger.debug("Trying backup methods for project name") 333 | 334 | # Check debug.selectedroot as a fallback 335 | selected_root = j(cur, "ItemTable", "debug.selectedroot") 336 | if selected_root and isinstance(selected_root, str) and selected_root.startswith("file:///"): 337 | path = selected_root[len("file:///"):] 338 | if path: 339 | root_path = "/" + path.strip("/") 340 | logger.debug(f"Project root from debug.selectedroot: {root_path}") 341 | 342 | # Extract the project name using the helper function 343 | project_name = extract_project_name_from_path(root_path, debug=True) 344 | 345 | if project_name: 346 | proj = {"name": project_name, "rootPath": root_path} 347 | 348 | # composers meta 349 | comp_meta={} 350 | cd = j(cur,"ItemTable","composer.composerData") or {} 351 | for c in cd.get("allComposers",[]): 352 | comp_meta[c["composerId"]] = { 353 | "title": c.get("name","(untitled)"), 354 | "createdAt": c.get("createdAt"), 355 | "lastUpdatedAt": c.get("lastUpdatedAt") 356 | } 357 | 358 | # Try to get composer info from workbench.panel.aichat.view.aichat.chatdata 359 | chat_data = j(cur, "ItemTable", "workbench.panel.aichat.view.aichat.chatdata") or {} 360 | for tab in chat_data.get("tabs", []): 361 | tab_id = tab.get("tabId") 362 | if tab_id and tab_id not in comp_meta: 363 | comp_meta[tab_id] = { 364 | "title": f"Chat {tab_id[:8]}", 365 | "createdAt": None, 366 | "lastUpdatedAt": None 367 | } 368 | except sqlite3.DatabaseError as e: 369 | logger.debug(f"Error getting workspace info from {db}: {e}") 370 | proj = {"name": "(unknown)", "rootPath": "(unknown)"} 371 | comp_meta = {} 372 | finally: 373 | if 'con' in locals(): 374 | con.close() 375 | 376 | return proj, comp_meta 377 | 378 | ################################################################################ 379 | # GlobalStorage 380 | ################################################################################ 381 | def global_storage_path(base: pathlib.Path) -> pathlib.Path: 382 | """Return path to the global storage state.vscdb.""" 383 | global_db = base / "User" / "globalStorage" / "state.vscdb" 384 | if global_db.exists(): 385 | return global_db 386 | 387 | # Legacy paths 388 | g_dirs = [base/"User"/"globalStorage"/"cursor.cursor", 389 | base/"User"/"globalStorage"/"cursor"] 390 | for d in g_dirs: 391 | if d.exists(): 392 | for file in d.glob("*.sqlite"): 393 | return file 394 | 395 | return None 396 | 397 | ################################################################################ 398 | # Extraction pipeline 399 | ################################################################################ 400 | def extract_chats() -> list[Dict[str,Any]]: 401 | root = cursor_root() 402 | logger.debug(f"Using Cursor root: {root}") 403 | 404 | # Diagnostic: Check for AI-related keys in the first workspace 405 | if os.environ.get("CURSOR_CHAT_DIAGNOSTICS"): 406 | try: 407 | first_ws = next(workspaces(root)) 408 | if first_ws: 409 | ws_id, db = first_ws 410 | logger.debug(f"\n--- DIAGNOSTICS for workspace {ws_id} ---") 411 | con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) 412 | cur = con.cursor() 413 | 414 | # List all tables 415 | cur.execute("SELECT name FROM sqlite_master WHERE type='table'") 416 | tables = [row[0] for row in cur.fetchall()] 417 | logger.debug(f"Tables in workspace DB: {tables}") 418 | 419 | # Search for AI-related keys 420 | if "ItemTable" in tables: 421 | for pattern in ['%ai%', '%chat%', '%composer%', '%prompt%', '%generation%']: 422 | cur.execute("SELECT key FROM ItemTable WHERE key LIKE ?", (pattern,)) 423 | keys = [row[0] for row in cur.fetchall()] 424 | if keys: 425 | logger.debug(f"Keys matching '{pattern}': {keys}") 426 | 427 | con.close() 428 | 429 | # Check global storage 430 | global_db = global_storage_path(root) 431 | if global_db: 432 | logger.debug(f"\n--- DIAGNOSTICS for global storage ---") 433 | con = sqlite3.connect(f"file:{global_db}?mode=ro", uri=True) 434 | cur = con.cursor() 435 | 436 | # List all tables 437 | cur.execute("SELECT name FROM sqlite_master WHERE type='table'") 438 | tables = [row[0] for row in cur.fetchall()] 439 | logger.debug(f"Tables in global DB: {tables}") 440 | 441 | # Search for AI-related keys in ItemTable 442 | if "ItemTable" in tables: 443 | for pattern in ['%ai%', '%chat%', '%composer%', '%prompt%', '%generation%']: 444 | cur.execute("SELECT key FROM ItemTable WHERE key LIKE ?", (pattern,)) 445 | keys = [row[0] for row in cur.fetchall()] 446 | if keys: 447 | logger.debug(f"Keys matching '{pattern}': {keys}") 448 | 449 | # Check for keys in cursorDiskKV 450 | if "cursorDiskKV" in tables: 451 | cur.execute("SELECT DISTINCT substr(key, 1, instr(key, ':') - 1) FROM cursorDiskKV") 452 | prefixes = [row[0] for row in cur.fetchall()] 453 | logger.debug(f"Key prefixes in cursorDiskKV: {prefixes}") 454 | 455 | con.close() 456 | 457 | logger.debug("\n--- END DIAGNOSTICS ---\n") 458 | except Exception as e: 459 | logger.debug(f"Error in diagnostics: {e}") 460 | 461 | # map lookups 462 | ws_proj : Dict[str,Dict[str,Any]] = {} 463 | comp_meta: Dict[str,Dict[str,Any]] = {} 464 | comp2ws : Dict[str,str] = {} 465 | sessions : Dict[str,Dict[str,Any]] = defaultdict(lambda: {"messages":[]}) 466 | 467 | # 1. Process workspace DBs first 468 | logger.debug("Processing workspace databases...") 469 | ws_count = 0 470 | for ws_id, db in workspaces(root): 471 | ws_count += 1 472 | logger.debug(f"Processing workspace {ws_id} - {db}") 473 | proj, meta = workspace_info(db) 474 | ws_proj[ws_id] = proj 475 | for cid, m in meta.items(): 476 | comp_meta[cid] = m 477 | comp2ws[cid] = ws_id 478 | 479 | # Extract chat data from workspace's state.vscdb 480 | msg_count = 0 481 | for cid, role, text, db_path in iter_chat_from_item_table(db): 482 | # Add the message 483 | sessions[cid]["messages"].append({"role": role, "content": text}) 484 | # Make sure to record the database path 485 | if "db_path" not in sessions[cid]: 486 | sessions[cid]["db_path"] = db_path 487 | msg_count += 1 488 | if cid not in comp_meta: 489 | comp_meta[cid] = {"title": f"Chat {cid[:8]}", "createdAt": None, "lastUpdatedAt": None} 490 | comp2ws[cid] = ws_id 491 | logger.debug(f" - Extracted {msg_count} messages from workspace {ws_id}") 492 | 493 | logger.debug(f"Processed {ws_count} workspaces") 494 | 495 | # 2. Process global storage 496 | global_db = global_storage_path(root) 497 | if global_db: 498 | logger.debug(f"Processing global storage: {global_db}") 499 | # Extract bubbles from cursorDiskKV 500 | msg_count = 0 501 | for cid, role, text, db_path in iter_bubbles_from_disk_kv(global_db): 502 | sessions[cid]["messages"].append({"role": role, "content": text}) 503 | # Record the database path 504 | if "db_path" not in sessions[cid]: 505 | sessions[cid]["db_path"] = db_path 506 | msg_count += 1 507 | if cid not in comp_meta: 508 | comp_meta[cid] = {"title": f"Chat {cid[:8]}", "createdAt": None, "lastUpdatedAt": None} 509 | comp2ws[cid] = "(global)" 510 | logger.debug(f" - Extracted {msg_count} messages from global cursorDiskKV bubbles") 511 | 512 | # Extract composer data 513 | comp_count = 0 514 | for cid, data, db_path in iter_composer_data(global_db): 515 | if cid not in comp_meta: 516 | created_at = data.get("createdAt") 517 | comp_meta[cid] = { 518 | "title": f"Chat {cid[:8]}", 519 | "createdAt": created_at, 520 | "lastUpdatedAt": created_at 521 | } 522 | comp2ws[cid] = "(global)" 523 | 524 | # Record the database path 525 | if "db_path" not in sessions[cid]: 526 | sessions[cid]["db_path"] = db_path 527 | 528 | # Extract conversation from composer data 529 | conversation = data.get("conversation", []) 530 | if conversation: 531 | msg_count = 0 532 | for msg in conversation: 533 | msg_type = msg.get("type") 534 | if msg_type is None: 535 | continue 536 | 537 | # Type 1 = user, Type 2 = assistant 538 | role = "user" if msg_type == 1 else "assistant" 539 | content = msg.get("text", "") 540 | if content and isinstance(content, str): 541 | sessions[cid]["messages"].append({"role": role, "content": content}) 542 | msg_count += 1 543 | 544 | if msg_count > 0: 545 | comp_count += 1 546 | logger.debug(f" - Added {msg_count} messages from composer {cid[:8]}") 547 | 548 | if comp_count > 0: 549 | logger.debug(f" - Extracted data from {comp_count} composers in global cursorDiskKV") 550 | 551 | # Also try ItemTable in global DB 552 | try: 553 | con = sqlite3.connect(f"file:{global_db}?mode=ro", uri=True) 554 | chat_data = j(con.cursor(), "ItemTable", "workbench.panel.aichat.view.aichat.chatdata") 555 | if chat_data: 556 | msg_count = 0 557 | for tab in chat_data.get("tabs", []): 558 | tab_id = tab.get("tabId") 559 | if tab_id and tab_id not in comp_meta: 560 | comp_meta[tab_id] = { 561 | "title": f"Global Chat {tab_id[:8]}", 562 | "createdAt": None, 563 | "lastUpdatedAt": None 564 | } 565 | comp2ws[tab_id] = "(global)" 566 | 567 | for bubble in tab.get("bubbles", []): 568 | content = "" 569 | if "text" in bubble: 570 | content = bubble["text"] 571 | elif "content" in bubble: 572 | content = bubble["content"] 573 | 574 | if content and isinstance(content, str): 575 | role = "user" if bubble.get("type") == "user" else "assistant" 576 | sessions[tab_id]["messages"].append({"role": role, "content": content}) 577 | msg_count += 1 578 | logger.debug(f" - Extracted {msg_count} messages from global chat data") 579 | con.close() 580 | except Exception as e: 581 | logger.debug(f"Error processing global ItemTable: {e}") 582 | 583 | # 3. Build final list 584 | out = [] 585 | for cid, data in sessions.items(): 586 | if not data["messages"]: 587 | continue 588 | ws_id = comp2ws.get(cid, "(unknown)") 589 | project = ws_proj.get(ws_id, {"name": "(unknown)", "rootPath": "(unknown)"}) 590 | meta = comp_meta.get(cid, {"title": "(untitled)", "createdAt": None, "lastUpdatedAt": None}) 591 | 592 | # Create the output object with the db_path included 593 | chat_data = { 594 | "project": project, 595 | "session": {"composerId": cid, **meta}, 596 | "messages": data["messages"], 597 | "workspace_id": ws_id, 598 | } 599 | 600 | # Add the database path if available 601 | if "db_path" in data: 602 | chat_data["db_path"] = data["db_path"] 603 | 604 | out.append(chat_data) 605 | 606 | # Sort by last updated time if available 607 | out.sort(key=lambda s: s["session"].get("lastUpdatedAt") or 0, reverse=True) 608 | logger.debug(f"Total chat sessions extracted: {len(out)}") 609 | return out 610 | 611 | def extract_project_from_git_repos(workspace_id, debug=False): 612 | """ 613 | Extract project name from the git repositories in a workspace. 614 | Returns None if no repositories found or unable to access the DB. 615 | """ 616 | if not workspace_id or workspace_id == "unknown" or workspace_id == "(unknown)" or workspace_id == "(global)": 617 | if debug: 618 | logger.debug(f"Invalid workspace ID: {workspace_id}") 619 | return None 620 | 621 | # Find the workspace DB 622 | cursor_base = cursor_root() 623 | workspace_db_path = cursor_base / "User" / "workspaceStorage" / workspace_id / "state.vscdb" 624 | 625 | if not workspace_db_path.exists(): 626 | if debug: 627 | logger.debug(f"Workspace DB not found for ID: {workspace_id}") 628 | return None 629 | 630 | try: 631 | # Connect to the workspace DB 632 | if debug: 633 | logger.debug(f"Connecting to workspace DB: {workspace_db_path}") 634 | con = sqlite3.connect(f"file:{workspace_db_path}?mode=ro", uri=True) 635 | cur = con.cursor() 636 | 637 | # Look for git repositories 638 | git_data = j(cur, "ItemTable", "scm:view:visibleRepositories") 639 | if not git_data or not isinstance(git_data, dict) or 'all' not in git_data: 640 | if debug: 641 | logger.debug(f"No git repositories found in workspace {workspace_id}, git_data: {git_data}") 642 | con.close() 643 | return None 644 | 645 | # Extract repo paths from the 'all' key 646 | repos = git_data.get('all', []) 647 | if not repos or not isinstance(repos, list): 648 | if debug: 649 | logger.debug(f"No repositories in 'all' key for workspace {workspace_id}, repos: {repos}") 650 | con.close() 651 | return None 652 | 653 | if debug: 654 | logger.debug(f"Found {len(repos)} git repositories in workspace {workspace_id}: {repos}") 655 | 656 | # Process each repo path 657 | for repo in repos: 658 | if not isinstance(repo, str): 659 | continue 660 | 661 | # Look for git:Git:file:/// pattern 662 | if "git:Git:file:///" in repo: 663 | # Extract the path part 664 | path = repo.split("file:///")[-1] 665 | path_parts = [p for p in path.split('/') if p] 666 | 667 | if path_parts: 668 | # Use the last part as the project name 669 | project_name = path_parts[-1] 670 | if debug: 671 | logger.debug(f"Found project name '{project_name}' from git repo in workspace {workspace_id}") 672 | con.close() 673 | return project_name 674 | else: 675 | if debug: 676 | logger.debug(f"No 'git:Git:file:///' pattern in repo: {repo}") 677 | 678 | if debug: 679 | logger.debug(f"No suitable git repos found in workspace {workspace_id}") 680 | con.close() 681 | except Exception as e: 682 | if debug: 683 | logger.debug(f"Error extracting git repos from workspace {workspace_id}: {e}") 684 | return None 685 | 686 | return None 687 | 688 | def format_chat_for_frontend(chat): 689 | """Format the chat data to match what the frontend expects.""" 690 | try: 691 | # Generate a unique ID for this chat if it doesn't have one 692 | session_id = str(uuid.uuid4()) 693 | if 'session' in chat and chat['session'] and isinstance(chat['session'], dict): 694 | session_id = chat['session'].get('composerId', session_id) 695 | 696 | # Format date from createdAt timestamp or use current date 697 | date = int(datetime.datetime.now().timestamp()) 698 | if 'session' in chat and chat['session'] and isinstance(chat['session'], dict): 699 | created_at = chat['session'].get('createdAt') 700 | if created_at and isinstance(created_at, (int, float)): 701 | # Convert from milliseconds to seconds 702 | date = created_at / 1000 703 | 704 | # Ensure project has expected fields 705 | project = chat.get('project', {}) 706 | if not isinstance(project, dict): 707 | project = {} 708 | 709 | # Get workspace_id from chat 710 | workspace_id = chat.get('workspace_id', 'unknown') 711 | 712 | # Get the database path information 713 | db_path = chat.get('db_path', 'Unknown database path') 714 | 715 | # If project name is a username or unknown, try to extract a better name from rootPath 716 | if project.get('rootPath'): 717 | current_name = project.get('name', '') 718 | username = os.path.basename(os.path.expanduser('~')) 719 | 720 | # Check if project name is username or unknown or very generic 721 | if (current_name == username or 722 | current_name == '(unknown)' or 723 | current_name == 'Root' or 724 | # Check if rootPath is directly under /Users/username with no additional path components 725 | (project.get('rootPath').startswith(f'/Users/{username}') and 726 | project.get('rootPath').count('/') <= 3)): 727 | 728 | # Try to extract a better name from the path 729 | project_name = extract_project_name_from_path(project.get('rootPath'), debug=False) 730 | 731 | # Only use the new name if it's meaningful 732 | if (project_name and 733 | project_name != 'Unknown Project' and 734 | project_name != username and 735 | project_name not in ['Documents', 'Downloads', 'Desktop']): 736 | 737 | logger.debug(f"Improved project name from '{current_name}' to '{project_name}'") 738 | project['name'] = project_name 739 | elif project.get('rootPath').startswith(f'/Users/{username}/Documents/codebase/'): 740 | # Special case for /Users/saharmor/Documents/codebase/X 741 | parts = project.get('rootPath').split('/') 742 | if len(parts) > 5: # /Users/username/Documents/codebase/X 743 | project['name'] = parts[5] 744 | logger.debug(f"Set project name to specific codebase subdirectory: {parts[5]}") 745 | else: 746 | project['name'] = "cursor-view" # Current project as default 747 | 748 | # If the project doesn't have a rootPath or it's very generic, enhance it with workspace_id 749 | if not project.get('rootPath') or project.get('rootPath') == '/' or project.get('rootPath') == '/Users': 750 | if workspace_id != 'unknown': 751 | # Use workspace_id to create a more specific path 752 | if not project.get('rootPath'): 753 | project['rootPath'] = f"/workspace/{workspace_id}" 754 | elif project.get('rootPath') == '/' or project.get('rootPath') == '/Users': 755 | project['rootPath'] = f"{project['rootPath']}/workspace/{workspace_id}" 756 | 757 | # FALLBACK: If project name is still generic, try to extract it from git repositories 758 | if project.get('name') in ['Home Directory', '(unknown)']: 759 | git_project_name = extract_project_from_git_repos(workspace_id, debug=True) 760 | if git_project_name: 761 | logger.debug(f"Improved project name from '{project.get('name')}' to '{git_project_name}' using git repo") 762 | project['name'] = git_project_name 763 | 764 | # Add workspace_id to the project data explicitly 765 | project['workspace_id'] = workspace_id 766 | 767 | # Ensure messages exist and are properly formatted 768 | messages = chat.get('messages', []) 769 | if not isinstance(messages, list): 770 | messages = [] 771 | 772 | # Create properly formatted chat object 773 | return { 774 | 'project': project, 775 | 'messages': messages, 776 | 'date': date, 777 | 'session_id': session_id, 778 | 'workspace_id': workspace_id, 779 | 'db_path': db_path # Include the database path in the output 780 | } 781 | except Exception as e: 782 | logger.error(f"Error formatting chat: {e}") 783 | # Return a minimal valid object if there's an error 784 | return { 785 | 'project': {'name': 'Error', 'rootPath': '/'}, 786 | 'messages': [], 787 | 'date': int(datetime.datetime.now().timestamp()), 788 | 'session_id': str(uuid.uuid4()), 789 | 'workspace_id': 'error', 790 | 'db_path': 'Error retrieving database path' 791 | } 792 | 793 | @app.route('/api/chats', methods=['GET']) 794 | def get_chats(): 795 | """Get all chat sessions.""" 796 | try: 797 | logger.info(f"Received request for chats from {request.remote_addr}") 798 | chats = extract_chats() 799 | logger.info(f"Retrieved {len(chats)} chats") 800 | 801 | # Format each chat for the frontend 802 | formatted_chats = [] 803 | for chat in chats: 804 | try: 805 | formatted_chat = format_chat_for_frontend(chat) 806 | formatted_chats.append(formatted_chat) 807 | except Exception as e: 808 | logger.error(f"Error formatting individual chat: {e}") 809 | # Skip this chat if it can't be formatted 810 | continue 811 | 812 | logger.info(f"Returning {len(formatted_chats)} formatted chats") 813 | return jsonify(formatted_chats) 814 | except Exception as e: 815 | logger.error(f"Error in get_chats: {e}", exc_info=True) 816 | return jsonify({"error": str(e)}), 500 817 | 818 | @app.route('/api/chat/', methods=['GET']) 819 | def get_chat(session_id): 820 | """Get a specific chat session by ID.""" 821 | try: 822 | logger.info(f"Received request for chat {session_id} from {request.remote_addr}") 823 | chats = extract_chats() 824 | 825 | for chat in chats: 826 | # Check for a matching composerId safely 827 | if 'session' in chat and chat['session'] and isinstance(chat['session'], dict): 828 | if chat['session'].get('composerId') == session_id: 829 | formatted_chat = format_chat_for_frontend(chat) 830 | return jsonify(formatted_chat) 831 | 832 | logger.warning(f"Chat with ID {session_id} not found") 833 | return jsonify({"error": "Chat not found"}), 404 834 | except Exception as e: 835 | logger.error(f"Error in get_chat: {e}", exc_info=True) 836 | return jsonify({"error": str(e)}), 500 837 | 838 | @app.route('/api/chat//export', methods=['GET']) 839 | def export_chat(session_id): 840 | """Export a specific chat session as standalone HTML or JSON.""" 841 | try: 842 | logger.info(f"Received request to export chat {session_id} from {request.remote_addr}") 843 | export_format = request.args.get('format', 'html').lower() 844 | chats = extract_chats() 845 | 846 | for chat in chats: 847 | # Check for a matching composerId safely 848 | if 'session' in chat and chat['session'] and isinstance(chat['session'], dict): 849 | if chat['session'].get('composerId') == session_id: 850 | formatted_chat = format_chat_for_frontend(chat) 851 | 852 | if export_format == 'json': 853 | # Export as JSON 854 | return Response( 855 | json.dumps(formatted_chat, indent=2), 856 | mimetype="application/json; charset=utf-8", 857 | headers={ 858 | "Content-Disposition": f'attachment; filename="cursor-chat-{session_id[:8]}.json"', 859 | "Cache-Control": "no-store", 860 | }, 861 | ) 862 | else: 863 | # Default to HTML export 864 | html_content = generate_standalone_html(formatted_chat) 865 | return Response( 866 | html_content, 867 | mimetype="text/html; charset=utf-8", 868 | headers={ 869 | "Content-Disposition": f'attachment; filename="cursor-chat-{session_id[:8]}.html"', 870 | "Content-Length": str(len(html_content)), 871 | "Cache-Control": "no-store", 872 | }, 873 | ) 874 | 875 | logger.warning(f"Chat with ID {session_id} not found for export") 876 | return jsonify({"error": "Chat not found"}), 404 877 | except Exception as e: 878 | logger.error(f"Error in export_chat: {e}", exc_info=True) 879 | return jsonify({"error": str(e)}), 500 880 | 881 | def generate_standalone_html(chat): 882 | """Generate a standalone HTML representation of the chat.""" 883 | logger.info(f"Generating HTML for session ID: {chat.get('session_id', 'N/A')}") 884 | try: 885 | # Format date for display 886 | date_display = "Unknown date" 887 | if chat.get('date'): 888 | try: 889 | date_obj = datetime.datetime.fromtimestamp(chat['date']) 890 | date_display = date_obj.strftime("%Y-%m-%d %H:%M:%S") 891 | except Exception as e: 892 | logger.warning(f"Error formatting date: {e}") 893 | 894 | # Get project info 895 | project_name = chat.get('project', {}).get('name', 'Unknown Project') 896 | project_path = chat.get('project', {}).get('rootPath', 'Unknown Path') 897 | logger.info(f"Project: {project_name}, Path: {project_path}, Date: {date_display}") 898 | 899 | # Build the HTML content 900 | messages_html = "" 901 | messages = chat.get('messages', []) 902 | logger.info(f"Found {len(messages)} messages for the chat.") 903 | 904 | if not messages: 905 | logger.warning("No messages found in the chat object to generate HTML.") 906 | messages_html = "

    No messages found in this conversation.

    " 907 | else: 908 | for i, msg in enumerate(messages): 909 | role = msg.get('role', 'unknown') 910 | content = msg.get('content', '') 911 | logger.debug(f"Processing message {i+1}/{len(messages)} - Role: {role}, Content length: {len(content)}") 912 | 913 | if not content or not isinstance(content, str): 914 | logger.warning(f"Message {i+1} has invalid content: {content}") 915 | content = "Content unavailable" 916 | 917 | # Simple HTML escaping 918 | escaped_content = content.replace("&", "&").replace("<", "<").replace(">", ">") 919 | 920 | # Convert markdown code blocks (handle potential nesting issues simply) 921 | processed_content = "" 922 | in_code_block = False 923 | for line in escaped_content.split('\n'): 924 | if line.strip().startswith("```"): 925 | if not in_code_block: 926 | processed_content += "
    "
     927 |                             in_code_block = True
     928 |                             # Remove the first ``` marker
     929 |                             line = line.strip()[3:] 
     930 |                         else:
     931 |                             processed_content += "
    \n" 932 | in_code_block = False 933 | line = "" # Skip the closing ``` line 934 | 935 | if in_code_block: 936 | # Inside code block, preserve spacing and add line breaks 937 | processed_content += line + "\n" 938 | else: 939 | # Outside code block, use
    for newlines 940 | processed_content += line + "
    " 941 | 942 | # Close any unclosed code block at the end 943 | if in_code_block: 944 | processed_content += "" 945 | 946 | avatar = "👤" if role == "user" else "🤖" 947 | name = "You" if role == "user" else "Cursor Assistant" 948 | bg_color = "#f0f7ff" if role == "user" else "#f0fff7" 949 | border_color = "#3f51b5" if role == "user" else "#00796b" 950 | 951 | messages_html += f""" 952 |
    953 |
    954 |
    955 | {avatar} 956 |
    957 |
    {name}
    958 |
    959 |
    960 | {processed_content} 961 |
    962 |
    963 | """ 964 | 965 | # Create the complete HTML document 966 | html = f""" 967 | 968 | 969 | 970 | 971 | Cursor Chat - {project_name} 972 | 984 | 985 | 986 |
    987 |

    Cursor Chat: {project_name}

    988 |
    989 |
    990 |
    Project: {project_name}
    991 |
    Path: {project_path}
    992 |
    Date: {date_display}
    993 |
    Session ID: {chat.get('session_id', 'Unknown')}
    994 |
    995 |

    Conversation History

    996 |
    997 | {messages_html} 998 |
    999 | 1002 | 1003 | """ 1004 | 1005 | logger.info(f"Finished generating HTML. Total length: {len(html)}") 1006 | return html 1007 | except Exception as e: 1008 | logger.error(f"Error generating HTML for session {chat.get('session_id', 'N/A')}: {e}", exc_info=True) 1009 | # Return an HTML formatted error message 1010 | return f"

    Error generating chat export

    Error: {e}

    " 1011 | 1012 | # Serve React app 1013 | @app.route('/', defaults={'path': ''}) 1014 | @app.route('/') 1015 | def serve_react(path): 1016 | if path and Path(app.static_folder, path).exists(): 1017 | return send_from_directory(app.static_folder, path) 1018 | return send_from_directory(app.static_folder, 'index.html') 1019 | 1020 | if __name__ == '__main__': 1021 | parser = argparse.ArgumentParser(description='Run the Cursor Chat View server') 1022 | parser.add_argument('--port', type=int, default=5000, help='Port to run the server on') 1023 | parser.add_argument('--debug', action='store_true', help='Run in debug mode') 1024 | args = parser.parse_args() 1025 | 1026 | logger.info(f"Starting server on port {args.port}") 1027 | app.run(host='127.0.0.1', port=args.port, debug=args.debug) -------------------------------------------------------------------------------- /vscdb_to_sqlite.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Convert a vscdb file to a sqlite file by copying and validating the database. 4 | 5 | Usage: 6 | python vscdb_to_sqlite.py [input_vscdb_file] [output_sqlite_file] 7 | 8 | If output file is not specified, it creates a file with the same name but .sqlite extension. 9 | """ 10 | 11 | import sqlite3 12 | import sys 13 | import os 14 | import shutil 15 | from pathlib import Path 16 | 17 | 18 | def validate_sqlite_db(file_path): 19 | """Validate that the file is a valid SQLite database.""" 20 | try: 21 | conn = sqlite3.connect(file_path) 22 | cursor = conn.cursor() 23 | 24 | # Try to query SQLite's internal tables to validate it's a SQLite DB 25 | cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") 26 | tables = cursor.fetchall() 27 | 28 | conn.close() 29 | return True, f"Valid SQLite database with {len(tables)} tables" 30 | except sqlite3.Error as e: 31 | return False, f"Not a valid SQLite database: {str(e)}" 32 | 33 | 34 | def convert_vscdb_to_sqlite(input_file, output_file=None): 35 | """ 36 | Convert a .vscdb file to a .sqlite file by copying it and validating. 37 | 38 | Args: 39 | input_file: Path to the input .vscdb file 40 | output_file: Path to the output .sqlite file. If None, uses input name with .sqlite extension 41 | 42 | Returns: 43 | tuple: (success, message) 44 | """ 45 | input_path = Path(input_file) 46 | 47 | # Validate input file exists 48 | if not input_path.exists(): 49 | return False, f"Input file not found: {input_file}" 50 | 51 | # Determine output path if not provided 52 | if output_file is None: 53 | output_path = input_path.with_suffix('.sqlite') 54 | else: 55 | output_path = Path(output_file) 56 | 57 | # Copy the file 58 | try: 59 | shutil.copy2(input_path, output_path) 60 | print(f"Copied {input_path} to {output_path}") 61 | except Exception as e: 62 | return False, f"Error copying file: {str(e)}" 63 | 64 | # Validate the copied file is a valid SQLite database 65 | is_valid, message = validate_sqlite_db(output_path) 66 | if is_valid: 67 | return True, f"Successfully converted to {output_path}\n{message}" 68 | else: 69 | # If not valid, remove the copied file 70 | output_path.unlink(missing_ok=True) 71 | return False, f"Conversion failed: {message}" 72 | 73 | 74 | def main(): 75 | if len(sys.argv) < 2: 76 | print("Usage: python vscdb_to_sqlite.py [input_vscdb_file] [output_sqlite_file]") 77 | return 78 | 79 | input_file = sys.argv[1] 80 | output_file = sys.argv[2] if len(sys.argv) > 2 else None 81 | 82 | success, message = convert_vscdb_to_sqlite(input_file, output_file) 83 | print(message) 84 | sys.exit(0 if success else 1) 85 | 86 | 87 | if __name__ == "__main__": 88 | main() --------------------------------------------------------------------------------