├── .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 |
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 |
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 | You need to enable JavaScript to run this app.
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 | handleFormatDialogClose(false)} color="highlight">
237 | Cancel
238 |
239 | handleFormatDialogClose(true)} color="highlight" variant="contained">
240 | Continue
241 |
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 | handleExportWarningClose(false)} color="highlight">
272 | Cancel
273 |
274 | handleExportWarningClose(true)} color="highlight" variant="contained">
275 | Continue Export
276 |
277 |
278 |
279 |
280 |
281 | }
285 | variant="outlined"
286 | sx={{
287 | borderRadius: 2,
288 | color: 'white'
289 | }}
290 | >
291 | Back to all chats
292 |
293 |
294 | }
297 | variant="contained"
298 | color="highlight"
299 | sx={{
300 | borderRadius: 2,
301 | position: 'relative',
302 | '&:hover': {
303 | backgroundColor: alpha(colors.highlightColor, 0.8),
304 | },
305 | '&::after': dontShowExportWarning ? null : {
306 | content: '""',
307 | position: 'absolute',
308 | borderRadius: '50%',
309 | top: '4px',
310 | right: '4px',
311 | width: '8px', // Adjusted size for button
312 | height: '8px' // Adjusted size for button
313 | },
314 | // Conditionally add the background color if the warning should be shown
315 | ...( !dontShowExportWarning && {
316 | '&::after': {
317 | backgroundColor: 'warning.main'
318 | }
319 | })
320 | }}
321 | >
322 | Export
323 |
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 | }
361 | onClick={fetchChats}
362 | sx={{
363 | color: colors.highlightColor,
364 | borderColor: alpha(colors.highlightColor, 0.5),
365 | '&:hover': {
366 | borderColor: colors.highlightColor,
367 | backgroundColor: alpha(colors.highlightColor, 0.1),
368 | }
369 | }}
370 | >
371 | Refresh
372 |
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 | handleExportWarningClose(false)}
404 | color="primary"
405 | sx={{ color: 'white' }}
406 | >
407 | Cancel
408 |
409 | handleExportWarningClose(true)} color="highlight" variant="contained">
410 | Continue Export
411 |
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 | }
461 | onClick={fetchChats}
462 | sx={{
463 | mt: 1,
464 | color: 'white'
465 | }}
466 | size="small"
467 | variant="outlined"
468 | >
469 | Refresh
470 |
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 | }
504 | onClick={clearSearch}
505 | variant="contained"
506 | color="primary"
507 | size="large"
508 | sx={{ borderRadius: 2 }}
509 | >
510 | Clear Search
511 |
512 | ) : (
513 | }
515 | onClick={fetchChats}
516 | variant="contained"
517 | color="primary"
518 | size="large"
519 | sx={{ borderRadius: 2 }}
520 | >
521 | Retry Detection
522 |
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 | }
35 | variant="outlined"
36 | color="inherit"
37 | size="small"
38 | sx={{
39 | borderColor: 'rgba(255,255,255,0.5)',
40 | color: 'white',
41 | '&:hover': {
42 | borderColor: 'rgba(255,255,255,0.8)',
43 | backgroundColor: colors.highlightColor
44 | }
45 | }}
46 | >
47 | GitHub
48 |
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 |
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 |
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()
--------------------------------------------------------------------------------