syncanything 0.3.0__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {syncanything-0.3.0/src/syncanything.egg-info → syncanything-0.3.2}/PKG-INFO +35 -14
- {syncanything-0.3.0 → syncanything-0.3.2}/README.md +34 -13
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/__init__.py +1 -1
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/cli.py +2 -2
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/connections.py +1 -1
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/index.py +31 -13
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/mcp.py +1 -1
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/sources/__init__.py +3 -1
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/sources/citeanything.py +2 -2
- syncanything-0.3.2/src/syncanything/sources/cursor.py +397 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/static/app.js +5 -5
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/static/index.html +1 -1
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/static/styles.css +21 -7
- {syncanything-0.3.0 → syncanything-0.3.2/src/syncanything.egg-info}/PKG-INFO +35 -14
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything.egg-info/SOURCES.txt +1 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/tests/test_syncanything.py +128 -4
- {syncanything-0.3.0 → syncanything-0.3.2}/LICENSE +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/pyproject.toml +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/setup.cfg +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/__main__.py +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/metrics.py +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/models.py +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/service.py +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/sources/base.py +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/sources/claude.py +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/sources/codex.py +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/sources/kimi.py +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/sources/pi.py +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/static/__init__.py +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/static/logo.svg +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/web.py +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything.egg-info/dependency_links.txt +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything.egg-info/entry_points.txt +0 -0
- {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: syncanything
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: An agent-native context index across AI products.
|
|
5
5
|
Author: Chizhong Wang
|
|
6
6
|
License-Expression: MIT
|
|
@@ -41,7 +41,7 @@ SyncAnything is a local, agent-native index for conversations across AI products
|
|
|
41
41
|
|
|
42
42
|
Phase 1 is intentionally read-only:
|
|
43
43
|
|
|
44
|
-
- discovers local Claude Code, Codex, Kimi Code, and Pi sessions;
|
|
44
|
+
- discovers local Claude Code, Codex, Cursor, Kimi Code, and Pi sessions;
|
|
45
45
|
- connects to CiteAnything as a product-level context source;
|
|
46
46
|
- indexes visible user and assistant text in SQLite FTS5;
|
|
47
47
|
- searches Chinese and English conversation text;
|
|
@@ -68,7 +68,7 @@ For a single headless connection:
|
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
70
|
export SYNCANYTHING_CITEANYTHING_API_KEY="ca_your_context_read_key"
|
|
71
|
-
export CITEANYTHING_BASE_URL="https://citeanything.
|
|
71
|
+
export CITEANYTHING_BASE_URL="https://citeanything.app"
|
|
72
72
|
syncanything index
|
|
73
73
|
syncanything serve --no-index
|
|
74
74
|
```
|
|
@@ -216,7 +216,9 @@ Start the stdio server with:
|
|
|
216
216
|
syncanything mcp
|
|
217
217
|
```
|
|
218
218
|
|
|
219
|
-
|
|
219
|
+
### Claude Code
|
|
220
|
+
|
|
221
|
+
Add to `~/.claude/settings.json` or the project `.mcp.json`:
|
|
220
222
|
|
|
221
223
|
```json
|
|
222
224
|
{
|
|
@@ -229,6 +231,23 @@ Example MCP client configuration:
|
|
|
229
231
|
}
|
|
230
232
|
```
|
|
231
233
|
|
|
234
|
+
### Cursor
|
|
235
|
+
|
|
236
|
+
Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per-project):
|
|
237
|
+
|
|
238
|
+
```json
|
|
239
|
+
{
|
|
240
|
+
"mcpServers": {
|
|
241
|
+
"syncanything": {
|
|
242
|
+
"command": "syncanything",
|
|
243
|
+
"args": ["mcp"]
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Restart Cursor after saving. The MCP server appears under **Settings → MCP** when connected.
|
|
250
|
+
|
|
232
251
|
For a non-default local home or database, keep the database and connection metadata together. If `--db` is supplied and `SYNCANYTHING_HOME` is not set, SyncAnything infers the home directory from the database parent:
|
|
233
252
|
|
|
234
253
|
```json
|
|
@@ -284,16 +303,18 @@ rather than re-downloaded.
|
|
|
284
303
|
|
|
285
304
|
## Source support
|
|
286
305
|
|
|
287
|
-
| Source | Location | Phase 1 status |
|
|
288
|
-
|
|
289
|
-
| Claude Code | `~/.claude/projects/**/*.jsonl` | Verified locally |
|
|
290
|
-
| Codex | `~/.codex/sessions/**/*.jsonl` | Verified locally |
|
|
291
|
-
|
|
|
292
|
-
|
|
|
293
|
-
|
|
|
294
|
-
|
|
|
295
|
-
|
|
|
296
|
-
|
|
|
306
|
+
| | Source | Location | Phase 1 status |
|
|
307
|
+
|---|---|---|---|
|
|
308
|
+
| <img src="docs/logos/claudecode-color.svg" width="20" alt="Claude Code"> | Claude Code | `~/.claude/projects/**/*.jsonl` | Verified locally |
|
|
309
|
+
| <img src="docs/logos/codex-color.svg" width="20" alt="Codex"> | Codex | `~/.codex/sessions/**/*.jsonl` | Verified locally |
|
|
310
|
+
| <img src="docs/logos/cursor.svg" width="20" alt="Cursor"> | Cursor App | `state.vscdb` (composerHeaders + cursorDiskKV) | Verified locally |
|
|
311
|
+
| <img src="docs/logos/cursor.svg" width="20" alt="Cursor"> | Cursor CLI | `~/.cursor/chats/*/*/meta.json` + `agent-transcripts` | Verified locally |
|
|
312
|
+
| <img src="docs/logos/kimi-color.svg" width="20" alt="Kimi Code"> | Kimi Code (legacy) | `~/.kimi/sessions/*/*/context.jsonl` | Verified locally |
|
|
313
|
+
| <img src="docs/logos/kimi-color.svg" width="20" alt="Kimi Code"> | Kimi Code (current) | `~/.kimi-code/sessions/*/*/agents/main/wire.jsonl` | Adapter included |
|
|
314
|
+
| <img src="docs/logos/pi.svg" width="20" alt="Pi"> | Pi | `~/.pi/agent/sessions/**/*.jsonl` | Official format implemented |
|
|
315
|
+
| <img src="docs/logos/citeanything.png" width="20" alt="CiteAnything"> | CiteAnything | Authenticated Conversation API | Product-level adapter included |
|
|
316
|
+
| <img src="docs/logos/opencode.svg" width="20" alt="OpenCode"> | OpenCode | SQLite session store | Next adapter |
|
|
317
|
+
| <img src="docs/logos/grok.svg" width="20" alt="Grok Build"> | Grok Build | Pending stable local export contract | Next adapter |
|
|
297
318
|
|
|
298
319
|
## Development
|
|
299
320
|
|
|
@@ -12,7 +12,7 @@ SyncAnything is a local, agent-native index for conversations across AI products
|
|
|
12
12
|
|
|
13
13
|
Phase 1 is intentionally read-only:
|
|
14
14
|
|
|
15
|
-
- discovers local Claude Code, Codex, Kimi Code, and Pi sessions;
|
|
15
|
+
- discovers local Claude Code, Codex, Cursor, Kimi Code, and Pi sessions;
|
|
16
16
|
- connects to CiteAnything as a product-level context source;
|
|
17
17
|
- indexes visible user and assistant text in SQLite FTS5;
|
|
18
18
|
- searches Chinese and English conversation text;
|
|
@@ -39,7 +39,7 @@ For a single headless connection:
|
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
41
|
export SYNCANYTHING_CITEANYTHING_API_KEY="ca_your_context_read_key"
|
|
42
|
-
export CITEANYTHING_BASE_URL="https://citeanything.
|
|
42
|
+
export CITEANYTHING_BASE_URL="https://citeanything.app"
|
|
43
43
|
syncanything index
|
|
44
44
|
syncanything serve --no-index
|
|
45
45
|
```
|
|
@@ -187,7 +187,9 @@ Start the stdio server with:
|
|
|
187
187
|
syncanything mcp
|
|
188
188
|
```
|
|
189
189
|
|
|
190
|
-
|
|
190
|
+
### Claude Code
|
|
191
|
+
|
|
192
|
+
Add to `~/.claude/settings.json` or the project `.mcp.json`:
|
|
191
193
|
|
|
192
194
|
```json
|
|
193
195
|
{
|
|
@@ -200,6 +202,23 @@ Example MCP client configuration:
|
|
|
200
202
|
}
|
|
201
203
|
```
|
|
202
204
|
|
|
205
|
+
### Cursor
|
|
206
|
+
|
|
207
|
+
Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per-project):
|
|
208
|
+
|
|
209
|
+
```json
|
|
210
|
+
{
|
|
211
|
+
"mcpServers": {
|
|
212
|
+
"syncanything": {
|
|
213
|
+
"command": "syncanything",
|
|
214
|
+
"args": ["mcp"]
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Restart Cursor after saving. The MCP server appears under **Settings → MCP** when connected.
|
|
221
|
+
|
|
203
222
|
For a non-default local home or database, keep the database and connection metadata together. If `--db` is supplied and `SYNCANYTHING_HOME` is not set, SyncAnything infers the home directory from the database parent:
|
|
204
223
|
|
|
205
224
|
```json
|
|
@@ -255,16 +274,18 @@ rather than re-downloaded.
|
|
|
255
274
|
|
|
256
275
|
## Source support
|
|
257
276
|
|
|
258
|
-
| Source | Location | Phase 1 status |
|
|
259
|
-
|
|
260
|
-
| Claude Code | `~/.claude/projects/**/*.jsonl` | Verified locally |
|
|
261
|
-
| Codex | `~/.codex/sessions/**/*.jsonl` | Verified locally |
|
|
262
|
-
|
|
|
263
|
-
|
|
|
264
|
-
|
|
|
265
|
-
|
|
|
266
|
-
|
|
|
267
|
-
|
|
|
277
|
+
| | Source | Location | Phase 1 status |
|
|
278
|
+
|---|---|---|---|
|
|
279
|
+
| <img src="docs/logos/claudecode-color.svg" width="20" alt="Claude Code"> | Claude Code | `~/.claude/projects/**/*.jsonl` | Verified locally |
|
|
280
|
+
| <img src="docs/logos/codex-color.svg" width="20" alt="Codex"> | Codex | `~/.codex/sessions/**/*.jsonl` | Verified locally |
|
|
281
|
+
| <img src="docs/logos/cursor.svg" width="20" alt="Cursor"> | Cursor App | `state.vscdb` (composerHeaders + cursorDiskKV) | Verified locally |
|
|
282
|
+
| <img src="docs/logos/cursor.svg" width="20" alt="Cursor"> | Cursor CLI | `~/.cursor/chats/*/*/meta.json` + `agent-transcripts` | Verified locally |
|
|
283
|
+
| <img src="docs/logos/kimi-color.svg" width="20" alt="Kimi Code"> | Kimi Code (legacy) | `~/.kimi/sessions/*/*/context.jsonl` | Verified locally |
|
|
284
|
+
| <img src="docs/logos/kimi-color.svg" width="20" alt="Kimi Code"> | Kimi Code (current) | `~/.kimi-code/sessions/*/*/agents/main/wire.jsonl` | Adapter included |
|
|
285
|
+
| <img src="docs/logos/pi.svg" width="20" alt="Pi"> | Pi | `~/.pi/agent/sessions/**/*.jsonl` | Official format implemented |
|
|
286
|
+
| <img src="docs/logos/citeanything.png" width="20" alt="CiteAnything"> | CiteAnything | Authenticated Conversation API | Product-level adapter included |
|
|
287
|
+
| <img src="docs/logos/opencode.svg" width="20" alt="OpenCode"> | OpenCode | SQLite session store | Next adapter |
|
|
288
|
+
| <img src="docs/logos/grok.svg" width="20" alt="Grok Build"> | Grok Build | Pending stable local export contract | Next adapter |
|
|
268
289
|
|
|
269
290
|
## Development
|
|
270
291
|
|
|
@@ -47,14 +47,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
47
47
|
search_parser = subparsers.add_parser("search", help="Search indexed sessions.")
|
|
48
48
|
search_parser.add_argument("query")
|
|
49
49
|
search_parser.add_argument(
|
|
50
|
-
"--source", choices=["claude", "codex", "kimi", "pi", "citeanything"]
|
|
50
|
+
"--source", choices=["claude", "codex", "cursor", "kimi", "pi", "citeanything"]
|
|
51
51
|
)
|
|
52
52
|
search_parser.add_argument("--limit", type=int, default=20)
|
|
53
53
|
search_parser.add_argument("--json", action="store_true")
|
|
54
54
|
|
|
55
55
|
list_parser = subparsers.add_parser("list", help="List recent sessions.")
|
|
56
56
|
list_parser.add_argument(
|
|
57
|
-
"--source", choices=["claude", "codex", "kimi", "pi", "citeanything"]
|
|
57
|
+
"--source", choices=["claude", "codex", "cursor", "kimi", "pi", "citeanything"]
|
|
58
58
|
)
|
|
59
59
|
list_parser.add_argument("--limit", type=int, default=30)
|
|
60
60
|
list_parser.add_argument("--json", action="store_true")
|
|
@@ -160,22 +160,40 @@ class ConversationIndex:
|
|
|
160
160
|
except ValueError:
|
|
161
161
|
return 0.0
|
|
162
162
|
|
|
163
|
+
#: Remote sources are synced at most once per this interval during reads.
|
|
164
|
+
REMOTE_SYNC_INTERVAL: float = float(os.environ.get("SYNCANYTHING_REMOTE_SYNC_INTERVAL", "300"))
|
|
165
|
+
|
|
163
166
|
def refresh(self, max_age_seconds: float = 2.0, force: bool = False) -> dict[str, Any] | None:
|
|
164
|
-
"""Incrementally re-index
|
|
167
|
+
"""Incrementally re-index sources before a read.
|
|
165
168
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
Returns None when a recent refresh made this call unnecessary.
|
|
169
|
+
Local sources are scanned on every call (throttled to *max_age_seconds*).
|
|
170
|
+
Remote sources (CiteAnything) are synced when their last pull is older
|
|
171
|
+
than ``REMOTE_SYNC_INTERVAL`` (default 5 min, env-configurable).
|
|
170
172
|
"""
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
173
|
+
now = time.time()
|
|
174
|
+
report: dict[str, Any] | None = None
|
|
175
|
+
|
|
176
|
+
if force or (now - self.last_refresh("local")) >= max_age_seconds:
|
|
177
|
+
sources = self.adapters if self.adapters is not None else local_adapters()
|
|
178
|
+
local = [adapter for adapter in sources if not adapter.is_remote]
|
|
179
|
+
if local:
|
|
180
|
+
report = self.index_all(adapters=local)
|
|
181
|
+
self._set_meta("last_refresh:local", str(now))
|
|
182
|
+
|
|
183
|
+
if force or (now - self.last_refresh("remote")) >= self.REMOTE_SYNC_INTERVAL:
|
|
184
|
+
all_sources = self.adapters if self.adapters is not None else default_adapters()
|
|
185
|
+
remote = [adapter for adapter in all_sources if adapter.is_remote]
|
|
186
|
+
if remote:
|
|
187
|
+
remote_report = self.index_all(adapters=remote)
|
|
188
|
+
self._set_meta("last_refresh:remote", str(now))
|
|
189
|
+
if report is None:
|
|
190
|
+
report = remote_report
|
|
191
|
+
else:
|
|
192
|
+
for key in ("indexed", "unchanged", "empty", "skipped", "removed"):
|
|
193
|
+
report[key] += remote_report.get(key, 0)
|
|
194
|
+
report["errors"].extend(remote_report.get("errors", []))
|
|
195
|
+
report["sources"].update(remote_report.get("sources", {}))
|
|
196
|
+
|
|
179
197
|
return report
|
|
180
198
|
|
|
181
199
|
def index_all(
|
|
@@ -19,7 +19,7 @@ TOOLS = [
|
|
|
19
19
|
"query": {"type": "string", "description": "Exact text or phrase to find."},
|
|
20
20
|
"source": {
|
|
21
21
|
"type": "string",
|
|
22
|
-
"description": "Optional source filter: claude, codex, kimi, pi, or citeanything.",
|
|
22
|
+
"description": "Optional source filter: claude, codex, cursor, kimi, pi, or citeanything.",
|
|
23
23
|
},
|
|
24
24
|
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
|
|
25
25
|
},
|
|
@@ -2,12 +2,13 @@ from syncanything.sources.base import SourceAdapter
|
|
|
2
2
|
from syncanything.sources.citeanything import CiteAnythingAdapter
|
|
3
3
|
from syncanything.sources.claude import ClaudeAdapter
|
|
4
4
|
from syncanything.sources.codex import CodexAdapter
|
|
5
|
+
from syncanything.sources.cursor import CursorAdapter
|
|
5
6
|
from syncanything.sources.kimi import KimiAdapter
|
|
6
7
|
from syncanything.sources.pi import PiAdapter
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
def default_adapters() -> list[SourceAdapter]:
|
|
10
|
-
return [ClaudeAdapter(), CodexAdapter(), KimiAdapter(), PiAdapter(), CiteAnythingAdapter()]
|
|
11
|
+
return [ClaudeAdapter(), CodexAdapter(), CursorAdapter(), KimiAdapter(), PiAdapter(), CiteAnythingAdapter()]
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
def local_adapters() -> list[SourceAdapter]:
|
|
@@ -21,6 +22,7 @@ __all__ = [
|
|
|
21
22
|
"CiteAnythingAdapter",
|
|
22
23
|
"ClaudeAdapter",
|
|
23
24
|
"CodexAdapter",
|
|
25
|
+
"CursorAdapter",
|
|
24
26
|
"KimiAdapter",
|
|
25
27
|
"PiAdapter",
|
|
26
28
|
"default_adapters",
|
|
@@ -57,7 +57,7 @@ class CiteAnythingAdapter(SourceAdapter):
|
|
|
57
57
|
self.base_url = (
|
|
58
58
|
base_url
|
|
59
59
|
or os.environ.get("CITEANYTHING_BASE_URL")
|
|
60
|
-
or "https://citeanything.
|
|
60
|
+
or "https://citeanything.app"
|
|
61
61
|
).rstrip("/")
|
|
62
62
|
self.api_key = (
|
|
63
63
|
api_key
|
|
@@ -109,7 +109,7 @@ class CiteAnythingAdapter(SourceAdapter):
|
|
|
109
109
|
host = base_url.split("://", 1)[-1].split("/", 1)[0].lower()
|
|
110
110
|
if host == "citeanything.cn":
|
|
111
111
|
return "china"
|
|
112
|
-
if host
|
|
112
|
+
if host in {"citeanything.app", "citeanything.veri-glow.com"}:
|
|
113
113
|
return "international"
|
|
114
114
|
return "".join(
|
|
115
115
|
character if character.isalnum() else "-" for character in host
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import sqlite3
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Iterable
|
|
10
|
+
|
|
11
|
+
from syncanything.connections import syncanything_home
|
|
12
|
+
from syncanything.models import Message, Session
|
|
13
|
+
from syncanything.sources.base import SourceAdapter, choose_title, read_jsonl, visible_text
|
|
14
|
+
|
|
15
|
+
_BUBBLE_TYPE_USER = 1
|
|
16
|
+
_BUBBLE_TYPE_ASSISTANT = 2
|
|
17
|
+
_USER_QUERY_RE = re.compile(r"<user_query>\s*(.*?)\s*</user_query>", re.DOTALL | re.IGNORECASE)
|
|
18
|
+
_TIMESTAMP_RE = re.compile(r"<timestamp>\s*.*?\s*</timestamp>\s*", re.DOTALL | re.IGNORECASE)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _cursor_data_root() -> Path:
|
|
22
|
+
if sys.platform == "darwin":
|
|
23
|
+
return Path.home() / "Library" / "Application Support" / "Cursor"
|
|
24
|
+
if sys.platform == "win32":
|
|
25
|
+
return Path.home() / "AppData" / "Roaming" / "Cursor"
|
|
26
|
+
return Path.home() / ".config" / "Cursor"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cursor_cli_chats_root() -> Path:
|
|
30
|
+
return Path.home() / ".cursor" / "chats"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _cursor_projects_root() -> Path:
|
|
34
|
+
return Path.home() / ".cursor" / "projects"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _find_state_dbs() -> list[Path]:
|
|
38
|
+
root = _cursor_data_root() / "User"
|
|
39
|
+
if not root.exists():
|
|
40
|
+
return []
|
|
41
|
+
candidates = [root / "globalStorage" / "state.vscdb"]
|
|
42
|
+
workspace_storage = root / "workspaceStorage"
|
|
43
|
+
if workspace_storage.exists():
|
|
44
|
+
for workspace in workspace_storage.iterdir():
|
|
45
|
+
candidates.append(workspace / "state.vscdb")
|
|
46
|
+
return [path for path in candidates if path.exists()]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _ms_to_iso(ms: int | None) -> str | None:
|
|
50
|
+
if not isinstance(ms, int) or ms <= 0:
|
|
51
|
+
return None
|
|
52
|
+
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _clean_cli_user_text(text: str) -> str:
|
|
56
|
+
"""Prefer the <user_query> body when Cursor CLI wraps the turn."""
|
|
57
|
+
match = _USER_QUERY_RE.search(text)
|
|
58
|
+
if match:
|
|
59
|
+
return match.group(1).strip()
|
|
60
|
+
cleaned = _TIMESTAMP_RE.sub("", text).strip()
|
|
61
|
+
return cleaned or text.strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _write_cache(cache_path: Path, snapshot: dict[str, Any]) -> None:
|
|
65
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
encoded = json.dumps(snapshot, ensure_ascii=False, sort_keys=True) + "\n"
|
|
67
|
+
temporary = cache_path.with_suffix(".json.tmp")
|
|
68
|
+
temporary.write_text(encoded, encoding="utf-8")
|
|
69
|
+
temporary.replace(cache_path)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _cached_matches(cache_path: Path, last_updated: Any) -> bool:
|
|
73
|
+
if not cache_path.exists():
|
|
74
|
+
return False
|
|
75
|
+
try:
|
|
76
|
+
cached = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
77
|
+
except (OSError, json.JSONDecodeError):
|
|
78
|
+
return False
|
|
79
|
+
return isinstance(cached, dict) and cached.get("lastUpdatedAt") == last_updated
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CursorAdapter(SourceAdapter):
|
|
83
|
+
"""Index Cursor App composers and Cursor CLI agent chats as one source."""
|
|
84
|
+
|
|
85
|
+
name = "cursor"
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
cache_root: Path | None = None,
|
|
90
|
+
*,
|
|
91
|
+
chats_root: Path | None = None,
|
|
92
|
+
projects_root: Path | None = None,
|
|
93
|
+
state_dbs: list[Path] | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
self.cache_root = cache_root or syncanything_home() / "connectors" / "cursor"
|
|
96
|
+
self.chats_root = chats_root if chats_root is not None else _cursor_cli_chats_root()
|
|
97
|
+
self.projects_root = projects_root if projects_root is not None else _cursor_projects_root()
|
|
98
|
+
self.state_dbs = state_dbs
|
|
99
|
+
|
|
100
|
+
def discover(self) -> Iterable[Path]:
|
|
101
|
+
seen_ids: set[str] = set()
|
|
102
|
+
self._sync_from_databases(seen_ids)
|
|
103
|
+
self._sync_from_cli_chats(seen_ids)
|
|
104
|
+
if not self.cache_root.exists():
|
|
105
|
+
return []
|
|
106
|
+
cached = list(self.cache_root.glob("composer-*.json")) + list(self.cache_root.glob("cli-*.json"))
|
|
107
|
+
return sorted(cached)
|
|
108
|
+
|
|
109
|
+
def _sync_from_databases(self, seen_ids: set[str]) -> None:
|
|
110
|
+
db_paths = self.state_dbs if self.state_dbs is not None else _find_state_dbs()
|
|
111
|
+
for db_path in db_paths:
|
|
112
|
+
try:
|
|
113
|
+
self._extract_composers(db_path, seen_ids)
|
|
114
|
+
except (sqlite3.Error, OSError):
|
|
115
|
+
continue
|
|
116
|
+
if not self.cache_root.exists():
|
|
117
|
+
return
|
|
118
|
+
for cached in self.cache_root.glob("composer-*.json"):
|
|
119
|
+
composer_id = cached.stem.split("composer-", 1)[-1]
|
|
120
|
+
if composer_id not in seen_ids:
|
|
121
|
+
cached.unlink(missing_ok=True)
|
|
122
|
+
|
|
123
|
+
def _extract_composers(self, db_path: Path, seen_ids: set[str]) -> None:
|
|
124
|
+
connection = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
125
|
+
connection.row_factory = sqlite3.Row
|
|
126
|
+
try:
|
|
127
|
+
rows = connection.execute(
|
|
128
|
+
"SELECT composerId, lastUpdatedAt, value "
|
|
129
|
+
"FROM composerHeaders "
|
|
130
|
+
"WHERE isSubagent = 0 AND composerId != 'empty-state-draft'"
|
|
131
|
+
).fetchall()
|
|
132
|
+
for row in rows:
|
|
133
|
+
composer_id = row["composerId"]
|
|
134
|
+
if composer_id in seen_ids:
|
|
135
|
+
continue
|
|
136
|
+
seen_ids.add(composer_id)
|
|
137
|
+
last_updated = row["lastUpdatedAt"]
|
|
138
|
+
cache_path = self.cache_root / f"composer-{composer_id}.json"
|
|
139
|
+
if _cached_matches(cache_path, last_updated):
|
|
140
|
+
continue
|
|
141
|
+
header = json.loads(row["value"]) if row["value"] else {}
|
|
142
|
+
if not isinstance(header, dict):
|
|
143
|
+
header = {}
|
|
144
|
+
bubbles = connection.execute(
|
|
145
|
+
"SELECT value FROM cursorDiskKV WHERE key LIKE ?",
|
|
146
|
+
(f"bubbleId:{composer_id}:%",),
|
|
147
|
+
).fetchall()
|
|
148
|
+
bubble_data = []
|
|
149
|
+
for bubble_row in bubbles:
|
|
150
|
+
try:
|
|
151
|
+
bubble = json.loads(bubble_row["value"])
|
|
152
|
+
except (json.JSONDecodeError, TypeError):
|
|
153
|
+
continue
|
|
154
|
+
if isinstance(bubble, dict):
|
|
155
|
+
bubble_data.append(bubble)
|
|
156
|
+
if not bubble_data:
|
|
157
|
+
if cache_path.exists():
|
|
158
|
+
cache_path.unlink(missing_ok=True)
|
|
159
|
+
continue
|
|
160
|
+
snapshot = {
|
|
161
|
+
"kind": "app",
|
|
162
|
+
"composerId": composer_id,
|
|
163
|
+
"name": header.get("name", ""),
|
|
164
|
+
"lastUpdatedAt": last_updated,
|
|
165
|
+
"createdAt": header.get("createdAt"),
|
|
166
|
+
"unifiedMode": header.get("unifiedMode", ""),
|
|
167
|
+
"bubbles": bubble_data,
|
|
168
|
+
}
|
|
169
|
+
_write_cache(cache_path, snapshot)
|
|
170
|
+
finally:
|
|
171
|
+
connection.close()
|
|
172
|
+
|
|
173
|
+
def _sync_from_cli_chats(self, seen_ids: set[str]) -> None:
|
|
174
|
+
cli_ids: set[str] = set()
|
|
175
|
+
if self.chats_root.exists():
|
|
176
|
+
for meta_path in sorted(self.chats_root.glob("*/*/meta.json")):
|
|
177
|
+
session_dir = meta_path.parent
|
|
178
|
+
session_id = session_dir.name
|
|
179
|
+
if session_id in seen_ids or session_id in cli_ids:
|
|
180
|
+
continue
|
|
181
|
+
try:
|
|
182
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
183
|
+
except (OSError, json.JSONDecodeError):
|
|
184
|
+
continue
|
|
185
|
+
if not isinstance(meta, dict) or not meta.get("hasConversation", True):
|
|
186
|
+
continue
|
|
187
|
+
last_updated = meta.get("updatedAtMs") or meta.get("createdAtMs")
|
|
188
|
+
cache_path = self.cache_root / f"cli-{session_id}.json"
|
|
189
|
+
if _cached_matches(cache_path, last_updated):
|
|
190
|
+
cli_ids.add(session_id)
|
|
191
|
+
seen_ids.add(session_id)
|
|
192
|
+
continue
|
|
193
|
+
messages = self._cli_messages(session_id, session_dir)
|
|
194
|
+
if not messages:
|
|
195
|
+
if cache_path.exists():
|
|
196
|
+
cache_path.unlink(missing_ok=True)
|
|
197
|
+
continue
|
|
198
|
+
snapshot = {
|
|
199
|
+
"kind": "cli",
|
|
200
|
+
"sessionId": session_id,
|
|
201
|
+
"name": meta.get("title") or "",
|
|
202
|
+
"cwd": meta.get("cwd"),
|
|
203
|
+
"createdAt": meta.get("createdAtMs"),
|
|
204
|
+
"lastUpdatedAt": last_updated,
|
|
205
|
+
"messages": messages,
|
|
206
|
+
}
|
|
207
|
+
_write_cache(cache_path, snapshot)
|
|
208
|
+
cli_ids.add(session_id)
|
|
209
|
+
seen_ids.add(session_id)
|
|
210
|
+
|
|
211
|
+
if not self.cache_root.exists():
|
|
212
|
+
return
|
|
213
|
+
for cached in self.cache_root.glob("cli-*.json"):
|
|
214
|
+
session_id = cached.stem.split("cli-", 1)[-1]
|
|
215
|
+
if session_id not in cli_ids:
|
|
216
|
+
cached.unlink(missing_ok=True)
|
|
217
|
+
|
|
218
|
+
def _cli_messages(self, session_id: str, session_dir: Path) -> list[dict[str, str | None]]:
|
|
219
|
+
transcript = self._find_cli_transcript(session_id)
|
|
220
|
+
if transcript is not None:
|
|
221
|
+
messages = self._messages_from_transcript(transcript)
|
|
222
|
+
if messages:
|
|
223
|
+
return messages
|
|
224
|
+
store_path = session_dir / "store.db"
|
|
225
|
+
if store_path.exists():
|
|
226
|
+
return self._messages_from_store(store_path)
|
|
227
|
+
return []
|
|
228
|
+
|
|
229
|
+
def _find_cli_transcript(self, session_id: str) -> Path | None:
|
|
230
|
+
if not self.projects_root.exists():
|
|
231
|
+
return None
|
|
232
|
+
direct = list(self.projects_root.glob(f"*/agent-transcripts/{session_id}/{session_id}.jsonl"))
|
|
233
|
+
if direct:
|
|
234
|
+
return direct[0]
|
|
235
|
+
matches = list(self.projects_root.glob(f"**/agent-transcripts/{session_id}/{session_id}.jsonl"))
|
|
236
|
+
return matches[0] if matches else None
|
|
237
|
+
|
|
238
|
+
def _messages_from_transcript(self, path: Path) -> list[dict[str, str | None]]:
|
|
239
|
+
messages: list[dict[str, str | None]] = []
|
|
240
|
+
for record in read_jsonl(path):
|
|
241
|
+
role = record.get("role")
|
|
242
|
+
if role not in {"user", "assistant"}:
|
|
243
|
+
continue
|
|
244
|
+
payload = record.get("message")
|
|
245
|
+
content = payload.get("content") if isinstance(payload, dict) else record.get("content")
|
|
246
|
+
text = visible_text(content)
|
|
247
|
+
if not text:
|
|
248
|
+
continue
|
|
249
|
+
if role == "user":
|
|
250
|
+
text = _clean_cli_user_text(text)
|
|
251
|
+
if not text:
|
|
252
|
+
continue
|
|
253
|
+
messages.append({"role": role, "text": text, "timestamp": None})
|
|
254
|
+
return messages
|
|
255
|
+
|
|
256
|
+
def _messages_from_store(self, store_path: Path) -> list[dict[str, str | None]]:
|
|
257
|
+
"""Best-effort fallback when agent-transcript JSONL is missing.
|
|
258
|
+
|
|
259
|
+
Cursor CLI store.db is content-addressed and partially protobuf-wrapped.
|
|
260
|
+
Only fully UTF-8 JSON blobs with visible user/assistant text are kept.
|
|
261
|
+
"""
|
|
262
|
+
messages: list[dict[str, str | None]] = []
|
|
263
|
+
try:
|
|
264
|
+
connection = sqlite3.connect(f"file:{store_path}?mode=ro", uri=True)
|
|
265
|
+
except sqlite3.Error:
|
|
266
|
+
return []
|
|
267
|
+
try:
|
|
268
|
+
rows = connection.execute("SELECT data FROM blobs").fetchall()
|
|
269
|
+
except sqlite3.Error:
|
|
270
|
+
connection.close()
|
|
271
|
+
return []
|
|
272
|
+
for (data,) in rows:
|
|
273
|
+
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
274
|
+
continue
|
|
275
|
+
raw = bytes(data)
|
|
276
|
+
try:
|
|
277
|
+
text = raw.decode("utf-8")
|
|
278
|
+
except UnicodeDecodeError:
|
|
279
|
+
continue
|
|
280
|
+
text = text.strip()
|
|
281
|
+
if not text.startswith("{"):
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
payload = json.loads(text)
|
|
285
|
+
except json.JSONDecodeError:
|
|
286
|
+
continue
|
|
287
|
+
if not isinstance(payload, dict):
|
|
288
|
+
continue
|
|
289
|
+
role = payload.get("role")
|
|
290
|
+
if role not in {"user", "assistant"}:
|
|
291
|
+
continue
|
|
292
|
+
body = visible_text(payload.get("content"))
|
|
293
|
+
if not body:
|
|
294
|
+
continue
|
|
295
|
+
if role == "user":
|
|
296
|
+
body = _clean_cli_user_text(body)
|
|
297
|
+
if not body:
|
|
298
|
+
continue
|
|
299
|
+
# Skip giant system-like user dumps that are not the visible query.
|
|
300
|
+
if role == "user" and body.lstrip().startswith("<user_info>"):
|
|
301
|
+
continue
|
|
302
|
+
messages.append({"role": role, "text": body, "timestamp": None})
|
|
303
|
+
connection.close()
|
|
304
|
+
return messages
|
|
305
|
+
|
|
306
|
+
def parse(self, path: Path) -> Session | None:
|
|
307
|
+
try:
|
|
308
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
309
|
+
except (OSError, json.JSONDecodeError):
|
|
310
|
+
return None
|
|
311
|
+
if not isinstance(payload, dict):
|
|
312
|
+
return None
|
|
313
|
+
kind = payload.get("kind")
|
|
314
|
+
if kind == "cli" or (
|
|
315
|
+
"sessionId" in payload and "messages" in payload and "bubbles" not in payload
|
|
316
|
+
):
|
|
317
|
+
return self._parse_cli(path, payload)
|
|
318
|
+
return self._parse_app(path, payload)
|
|
319
|
+
|
|
320
|
+
def _parse_app(self, path: Path, payload: dict[str, Any]) -> Session | None:
|
|
321
|
+
composer_id = payload.get("composerId", path.stem.split("composer-", 1)[-1])
|
|
322
|
+
bubbles = payload.get("bubbles", [])
|
|
323
|
+
if not isinstance(bubbles, list):
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
timed: list[tuple[str, Message]] = []
|
|
327
|
+
for bubble in bubbles:
|
|
328
|
+
if not isinstance(bubble, dict):
|
|
329
|
+
continue
|
|
330
|
+
bubble_type = bubble.get("type")
|
|
331
|
+
if bubble_type == _BUBBLE_TYPE_USER:
|
|
332
|
+
role = "user"
|
|
333
|
+
elif bubble_type == _BUBBLE_TYPE_ASSISTANT:
|
|
334
|
+
role = "assistant"
|
|
335
|
+
else:
|
|
336
|
+
continue
|
|
337
|
+
if bubble.get("capabilityType") is not None:
|
|
338
|
+
continue
|
|
339
|
+
text = bubble.get("text", "")
|
|
340
|
+
if not isinstance(text, str) or not text.strip():
|
|
341
|
+
continue
|
|
342
|
+
timestamp = bubble.get("createdAt", "")
|
|
343
|
+
timed.append((timestamp, Message(role=role, text=text.strip(), timestamp=timestamp or None)))
|
|
344
|
+
|
|
345
|
+
timed.sort(key=lambda pair: pair[0])
|
|
346
|
+
messages = [message for _, message in timed]
|
|
347
|
+
|
|
348
|
+
if not messages:
|
|
349
|
+
return None
|
|
350
|
+
started_at = _ms_to_iso(payload.get("createdAt")) or (messages[0].timestamp if messages else None)
|
|
351
|
+
updated_at = _ms_to_iso(payload.get("lastUpdatedAt")) or (messages[-1].timestamp if messages else None)
|
|
352
|
+
return Session(
|
|
353
|
+
source=self.name,
|
|
354
|
+
native_id=str(composer_id),
|
|
355
|
+
path=path,
|
|
356
|
+
messages=messages,
|
|
357
|
+
title=choose_title(messages, payload.get("name") or None),
|
|
358
|
+
started_at=started_at,
|
|
359
|
+
updated_at=updated_at,
|
|
360
|
+
metadata={"cursor_surface": "app"},
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def _parse_cli(self, path: Path, payload: dict[str, Any]) -> Session | None:
|
|
364
|
+
session_id = str(payload.get("sessionId") or path.stem.split("cli-", 1)[-1])
|
|
365
|
+
raw_messages = payload.get("messages", [])
|
|
366
|
+
if not isinstance(raw_messages, list):
|
|
367
|
+
return None
|
|
368
|
+
messages: list[Message] = []
|
|
369
|
+
for item in raw_messages:
|
|
370
|
+
if not isinstance(item, dict):
|
|
371
|
+
continue
|
|
372
|
+
role = item.get("role")
|
|
373
|
+
text = item.get("text")
|
|
374
|
+
if role not in {"user", "assistant"} or not isinstance(text, str) or not text.strip():
|
|
375
|
+
continue
|
|
376
|
+
timestamp = item.get("timestamp")
|
|
377
|
+
messages.append(
|
|
378
|
+
Message(
|
|
379
|
+
role=role,
|
|
380
|
+
text=text.strip(),
|
|
381
|
+
timestamp=timestamp if isinstance(timestamp, str) else None,
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
if not messages:
|
|
385
|
+
return None
|
|
386
|
+
cwd = payload.get("cwd")
|
|
387
|
+
return Session(
|
|
388
|
+
source=self.name,
|
|
389
|
+
native_id=session_id,
|
|
390
|
+
path=path,
|
|
391
|
+
messages=messages,
|
|
392
|
+
title=choose_title(messages, payload.get("name") or None),
|
|
393
|
+
cwd=cwd if isinstance(cwd, str) else None,
|
|
394
|
+
started_at=_ms_to_iso(payload.get("createdAt")),
|
|
395
|
+
updated_at=_ms_to_iso(payload.get("lastUpdatedAt")),
|
|
396
|
+
metadata={"cursor_surface": "cli"},
|
|
397
|
+
)
|
|
@@ -26,7 +26,7 @@ const translations = {
|
|
|
26
26
|
"stats.characters": "总字数",
|
|
27
27
|
"stats.characters.detail": "{words} 个词",
|
|
28
28
|
"stats.tokens": "估算 Token",
|
|
29
|
-
"stats.tokens.detail": "按中日韩 1.5
|
|
29
|
+
"stats.tokens.detail": "按中日韩 1.5 字 / 拉丁 4 字符估算",
|
|
30
30
|
"stats.storage": "索引占用",
|
|
31
31
|
"stats.storage.detail": "正文 {text},索引膨胀 {ratio} 倍",
|
|
32
32
|
"stats.books": "相当于",
|
|
@@ -54,7 +54,7 @@ const translations = {
|
|
|
54
54
|
"connections.remove": "移除",
|
|
55
55
|
"connections.site": "站点",
|
|
56
56
|
"connections.site.china": "中国站 · citeanything.cn",
|
|
57
|
-
"connections.site.international": "国际站 · citeanything.
|
|
57
|
+
"connections.site.international": "国际站 · citeanything.app",
|
|
58
58
|
"connections.site.custom": "自定义",
|
|
59
59
|
"connections.name": "连接名称",
|
|
60
60
|
"connections.base_url": "站点地址",
|
|
@@ -97,7 +97,7 @@ const translations = {
|
|
|
97
97
|
"stats.characters": "Words",
|
|
98
98
|
"stats.characters.detail": "{characters} characters",
|
|
99
99
|
"stats.tokens": "Estimated tokens",
|
|
100
|
-
"stats.tokens.detail": "Estimated at 1.5 CJK
|
|
100
|
+
"stats.tokens.detail": "Estimated at 1.5 CJK / 4 Latin characters per token",
|
|
101
101
|
"stats.storage": "Index on disk",
|
|
102
102
|
"stats.storage.detail": "{text} of text, {ratio}x index overhead",
|
|
103
103
|
"stats.books": "Equivalent to",
|
|
@@ -125,7 +125,7 @@ const translations = {
|
|
|
125
125
|
"connections.remove": "Remove",
|
|
126
126
|
"connections.site": "Site",
|
|
127
127
|
"connections.site.china": "China · citeanything.cn",
|
|
128
|
-
"connections.site.international": "International · citeanything.
|
|
128
|
+
"connections.site.international": "International · citeanything.app",
|
|
129
129
|
"connections.site.custom": "Custom",
|
|
130
130
|
"connections.name": "Connection name",
|
|
131
131
|
"connections.base_url": "Site URL",
|
|
@@ -173,7 +173,7 @@ const dialog = document.querySelector("#session-dialog");
|
|
|
173
173
|
const connectionsDialog = document.querySelector("#connections-dialog");
|
|
174
174
|
let searchTimer;
|
|
175
175
|
|
|
176
|
-
const sourceNames = { claude: "Claude Code", codex: "Codex", kimi: "Kimi Code", pi: "Pi", citeanything: "CiteAnything" };
|
|
176
|
+
const sourceNames = { claude: "Claude Code", codex: "Codex", cursor: "Cursor", kimi: "Kimi Code", pi: "Pi", citeanything: "CiteAnything" };
|
|
177
177
|
|
|
178
178
|
function t(key, params = {}) {
|
|
179
179
|
const table = translations[state.language] || translations.en;
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
<span data-i18n="connections.site">站点</span>
|
|
77
77
|
<select id="connection-site">
|
|
78
78
|
<option value="china" data-i18n="connections.site.china">中国站 · citeanything.cn</option>
|
|
79
|
-
<option value="international" data-i18n="connections.site.international">国际站 · citeanything.
|
|
79
|
+
<option value="international" data-i18n="connections.site.international">国际站 · citeanything.app</option>
|
|
80
80
|
<option value="custom" data-i18n="connections.site.custom">自定义</option>
|
|
81
81
|
</select>
|
|
82
82
|
</label>
|
|
@@ -46,6 +46,8 @@ button { cursor: pointer; }
|
|
|
46
46
|
.brand small { color: var(--muted); font-weight: 500; margin-left: -4px; }
|
|
47
47
|
.status { margin-left: auto; color: var(--muted); font-size: 13px; }
|
|
48
48
|
|
|
49
|
+
/* A label that breaks across lines makes the toolbar taller than the bar. */
|
|
50
|
+
.quiet-button { white-space: nowrap; }
|
|
49
51
|
.quiet-button, .icon-button {
|
|
50
52
|
border: 1px solid var(--line);
|
|
51
53
|
background: rgba(255,255,255,.8);
|
|
@@ -86,22 +88,29 @@ h1 span { color: var(--green); }
|
|
|
86
88
|
#language-toggle { min-width: 54px; text-align: center; }
|
|
87
89
|
|
|
88
90
|
.stats-section { margin-bottom: 46px; }
|
|
89
|
-
|
|
91
|
+
/* Six fixed cards lay out evenly as 3x2; auto-fit would strand one on its own
|
|
92
|
+
row at common widths. The tracks stay wide enough that a grouped figure never
|
|
93
|
+
has to break between digits. */
|
|
94
|
+
.stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
|
90
95
|
.stat-card { display: grid; gap: 6px; align-content: start; padding: 16px 18px; background: rgba(255,255,255,.86); border: 1px solid var(--line); border-radius: 14px; }
|
|
91
96
|
.stat-label { color: var(--muted); font-size: 12px; font-weight: 650; }
|
|
92
|
-
.stat-value { font-size:
|
|
97
|
+
.stat-value { font-size: 23px; font-weight: 680; line-height: 1.15; letter-spacing: -.035em; }
|
|
93
98
|
.stat-detail { color: var(--muted); font-size: 11px; line-height: 1.5; }
|
|
94
99
|
.stat-note { margin: 14px 0 0; color: var(--muted); font-size: 12px; line-height: 1.65; }
|
|
95
100
|
|
|
96
101
|
.section-heading { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
|
|
97
102
|
.section-heading h2 { font-size: 20px; margin: 0; letter-spacing: -.02em; }
|
|
98
103
|
.section-heading span { color: var(--muted); font-size: 13px; }
|
|
99
|
-
|
|
104
|
+
/* minmax(0, …) lets a card narrower than its own content shrink instead of
|
|
105
|
+
widening the track, which a long source path or English date would do. */
|
|
106
|
+
.results { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
|
|
100
107
|
.result-card { padding: 20px 22px; background: rgba(255,255,255,.86); border: 1px solid var(--line); border-radius: 15px; transition: transform .18s, border-color .18s, box-shadow .18s; outline: none; }
|
|
101
108
|
.result-card:hover, .result-card:focus { transform: translateY(-2px); border-color: #a9dbc8; box-shadow: 0 12px 36px rgba(31,55,45,.07); }
|
|
102
|
-
.card-meta { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; }
|
|
109
|
+
.card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; color: var(--muted); font-size: 12px; }
|
|
103
110
|
.source-badge { display: inline-flex; align-items: center; border-radius: 5px; padding: 4px 7px; color: var(--green-dark); background: var(--green-soft); font-size: 11px; font-weight: 720; text-transform: uppercase; letter-spacing: .05em; }
|
|
104
|
-
|
|
111
|
+
/* A pasted URL in a title has no break opportunity and would otherwise set the
|
|
112
|
+
card's minimum width, pushing the whole page into horizontal scroll. */
|
|
113
|
+
.result-card h3 { margin: 12px 0 8px; font-size: 18px; letter-spacing: -.015em; overflow-wrap: anywhere; }
|
|
105
114
|
.snippet { margin: 0; color: #55625c; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
106
115
|
mark { background: #c9f5df; color: inherit; border-radius: 2px; }
|
|
107
116
|
.card-foot { display: flex; align-items: center; margin-top: 14px; gap: 12px; color: var(--muted); font-size: 12px; }
|
|
@@ -159,11 +168,16 @@ dialog::backdrop { background: rgba(20, 35, 29, .38); backdrop-filter: blur(4px)
|
|
|
159
168
|
.topbar { padding: 0 4vw; gap: 10px; }
|
|
160
169
|
.brand small, .status, .search-box kbd { display: none; }
|
|
161
170
|
/* Only the first button takes the free space; the rest sit beside it. */
|
|
162
|
-
#language-toggle { margin-left: auto; }
|
|
171
|
+
#language-toggle { margin-left: auto; min-width: 0; }
|
|
172
|
+
/* Three buttons plus the wordmark do not fit 390px at full size. */
|
|
173
|
+
.brand span { font-size: 15px; }
|
|
174
|
+
.quiet-button { padding: 7px 9px; font-size: 12px; }
|
|
163
175
|
.hero { padding-top: 54px; }
|
|
164
176
|
h1 { font-size: 43px; }
|
|
165
|
-
.stat-grid { grid-template-columns: repeat(
|
|
177
|
+
.stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
166
178
|
.stat-value { font-size: 21px; }
|
|
179
|
+
/* Let the count drop below a heading that has wrapped rather than crowd it. */
|
|
180
|
+
.section-heading { flex-wrap: wrap; gap: 2px 12px; }
|
|
167
181
|
.card-foot .cwd { max-width: 58vw; }
|
|
168
182
|
.dialog-actions { align-items: flex-start; flex-direction: column; }
|
|
169
183
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: syncanything
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: An agent-native context index across AI products.
|
|
5
5
|
Author: Chizhong Wang
|
|
6
6
|
License-Expression: MIT
|
|
@@ -41,7 +41,7 @@ SyncAnything is a local, agent-native index for conversations across AI products
|
|
|
41
41
|
|
|
42
42
|
Phase 1 is intentionally read-only:
|
|
43
43
|
|
|
44
|
-
- discovers local Claude Code, Codex, Kimi Code, and Pi sessions;
|
|
44
|
+
- discovers local Claude Code, Codex, Cursor, Kimi Code, and Pi sessions;
|
|
45
45
|
- connects to CiteAnything as a product-level context source;
|
|
46
46
|
- indexes visible user and assistant text in SQLite FTS5;
|
|
47
47
|
- searches Chinese and English conversation text;
|
|
@@ -68,7 +68,7 @@ For a single headless connection:
|
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
70
|
export SYNCANYTHING_CITEANYTHING_API_KEY="ca_your_context_read_key"
|
|
71
|
-
export CITEANYTHING_BASE_URL="https://citeanything.
|
|
71
|
+
export CITEANYTHING_BASE_URL="https://citeanything.app"
|
|
72
72
|
syncanything index
|
|
73
73
|
syncanything serve --no-index
|
|
74
74
|
```
|
|
@@ -216,7 +216,9 @@ Start the stdio server with:
|
|
|
216
216
|
syncanything mcp
|
|
217
217
|
```
|
|
218
218
|
|
|
219
|
-
|
|
219
|
+
### Claude Code
|
|
220
|
+
|
|
221
|
+
Add to `~/.claude/settings.json` or the project `.mcp.json`:
|
|
220
222
|
|
|
221
223
|
```json
|
|
222
224
|
{
|
|
@@ -229,6 +231,23 @@ Example MCP client configuration:
|
|
|
229
231
|
}
|
|
230
232
|
```
|
|
231
233
|
|
|
234
|
+
### Cursor
|
|
235
|
+
|
|
236
|
+
Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per-project):
|
|
237
|
+
|
|
238
|
+
```json
|
|
239
|
+
{
|
|
240
|
+
"mcpServers": {
|
|
241
|
+
"syncanything": {
|
|
242
|
+
"command": "syncanything",
|
|
243
|
+
"args": ["mcp"]
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Restart Cursor after saving. The MCP server appears under **Settings → MCP** when connected.
|
|
250
|
+
|
|
232
251
|
For a non-default local home or database, keep the database and connection metadata together. If `--db` is supplied and `SYNCANYTHING_HOME` is not set, SyncAnything infers the home directory from the database parent:
|
|
233
252
|
|
|
234
253
|
```json
|
|
@@ -284,16 +303,18 @@ rather than re-downloaded.
|
|
|
284
303
|
|
|
285
304
|
## Source support
|
|
286
305
|
|
|
287
|
-
| Source | Location | Phase 1 status |
|
|
288
|
-
|
|
289
|
-
| Claude Code | `~/.claude/projects/**/*.jsonl` | Verified locally |
|
|
290
|
-
| Codex | `~/.codex/sessions/**/*.jsonl` | Verified locally |
|
|
291
|
-
|
|
|
292
|
-
|
|
|
293
|
-
|
|
|
294
|
-
|
|
|
295
|
-
|
|
|
296
|
-
|
|
|
306
|
+
| | Source | Location | Phase 1 status |
|
|
307
|
+
|---|---|---|---|
|
|
308
|
+
| <img src="docs/logos/claudecode-color.svg" width="20" alt="Claude Code"> | Claude Code | `~/.claude/projects/**/*.jsonl` | Verified locally |
|
|
309
|
+
| <img src="docs/logos/codex-color.svg" width="20" alt="Codex"> | Codex | `~/.codex/sessions/**/*.jsonl` | Verified locally |
|
|
310
|
+
| <img src="docs/logos/cursor.svg" width="20" alt="Cursor"> | Cursor App | `state.vscdb` (composerHeaders + cursorDiskKV) | Verified locally |
|
|
311
|
+
| <img src="docs/logos/cursor.svg" width="20" alt="Cursor"> | Cursor CLI | `~/.cursor/chats/*/*/meta.json` + `agent-transcripts` | Verified locally |
|
|
312
|
+
| <img src="docs/logos/kimi-color.svg" width="20" alt="Kimi Code"> | Kimi Code (legacy) | `~/.kimi/sessions/*/*/context.jsonl` | Verified locally |
|
|
313
|
+
| <img src="docs/logos/kimi-color.svg" width="20" alt="Kimi Code"> | Kimi Code (current) | `~/.kimi-code/sessions/*/*/agents/main/wire.jsonl` | Adapter included |
|
|
314
|
+
| <img src="docs/logos/pi.svg" width="20" alt="Pi"> | Pi | `~/.pi/agent/sessions/**/*.jsonl` | Official format implemented |
|
|
315
|
+
| <img src="docs/logos/citeanything.png" width="20" alt="CiteAnything"> | CiteAnything | Authenticated Conversation API | Product-level adapter included |
|
|
316
|
+
| <img src="docs/logos/opencode.svg" width="20" alt="OpenCode"> | OpenCode | SQLite session store | Next adapter |
|
|
317
|
+
| <img src="docs/logos/grok.svg" width="20" alt="Grok Build"> | Grok Build | Pending stable local export contract | Next adapter |
|
|
297
318
|
|
|
298
319
|
## Development
|
|
299
320
|
|
|
@@ -21,6 +21,7 @@ src/syncanything/sources/base.py
|
|
|
21
21
|
src/syncanything/sources/citeanything.py
|
|
22
22
|
src/syncanything/sources/claude.py
|
|
23
23
|
src/syncanything/sources/codex.py
|
|
24
|
+
src/syncanything/sources/cursor.py
|
|
24
25
|
src/syncanything/sources/kimi.py
|
|
25
26
|
src/syncanything/sources/pi.py
|
|
26
27
|
src/syncanything/static/__init__.py
|
|
@@ -29,6 +29,7 @@ from syncanything.service import SyncAnythingService
|
|
|
29
29
|
from syncanything.sources.citeanything import CiteAnythingAdapter
|
|
30
30
|
from syncanything.sources.claude import ClaudeAdapter
|
|
31
31
|
from syncanything.sources.codex import CodexAdapter
|
|
32
|
+
from syncanything.sources.cursor import CursorAdapter
|
|
32
33
|
from syncanything.sources.kimi import KimiAdapter
|
|
33
34
|
from syncanything.sources.pi import PiAdapter
|
|
34
35
|
|
|
@@ -300,6 +301,126 @@ class AdapterTests(unittest.TestCase):
|
|
|
300
301
|
self.assertEqual(pi.id, "pi:p1")
|
|
301
302
|
self.assertEqual(len(pi.messages), 2)
|
|
302
303
|
|
|
304
|
+
def test_cursor_indexes_app_and_cli_without_splitting_source(self) -> None:
|
|
305
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
306
|
+
root = Path(directory)
|
|
307
|
+
cache_root = root / "cache"
|
|
308
|
+
chats_root = root / "chats"
|
|
309
|
+
projects_root = root / "projects"
|
|
310
|
+
session_id = "11111111-2222-3333-4444-555555555555"
|
|
311
|
+
composer_id = "aaaa-bbbb-cccc-dddd"
|
|
312
|
+
chat_dir = chats_root / "abcdef0123456789abcdef0123456789" / session_id
|
|
313
|
+
chat_dir.mkdir(parents=True)
|
|
314
|
+
(chat_dir / "meta.json").write_text(
|
|
315
|
+
json.dumps(
|
|
316
|
+
{
|
|
317
|
+
"schemaVersion": 1,
|
|
318
|
+
"createdAtMs": 1_700_000_000_000,
|
|
319
|
+
"updatedAtMs": 1_700_000_100_000,
|
|
320
|
+
"hasConversation": True,
|
|
321
|
+
"title": "CLI session about indexing",
|
|
322
|
+
"cwd": "/work",
|
|
323
|
+
}
|
|
324
|
+
),
|
|
325
|
+
encoding="utf-8",
|
|
326
|
+
)
|
|
327
|
+
transcript = (
|
|
328
|
+
projects_root
|
|
329
|
+
/ "Users-work"
|
|
330
|
+
/ "agent-transcripts"
|
|
331
|
+
/ session_id
|
|
332
|
+
/ f"{session_id}.jsonl"
|
|
333
|
+
)
|
|
334
|
+
write_jsonl(
|
|
335
|
+
transcript,
|
|
336
|
+
[
|
|
337
|
+
{
|
|
338
|
+
"role": "user",
|
|
339
|
+
"message": {
|
|
340
|
+
"content": [
|
|
341
|
+
{
|
|
342
|
+
"type": "text",
|
|
343
|
+
"text": (
|
|
344
|
+
"<timestamp>Sunday, Aug 9, 2026</timestamp>\n"
|
|
345
|
+
"<user_query>\n请支持 Cursor CLI 会话索引\n</user_query>"
|
|
346
|
+
),
|
|
347
|
+
}
|
|
348
|
+
]
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
"role": "assistant",
|
|
353
|
+
"message": {
|
|
354
|
+
"content": [
|
|
355
|
+
{"type": "text", "text": "已接入 Cursor CLI。"},
|
|
356
|
+
{"type": "tool_use", "name": "Shell", "input": {"command": "ls"}},
|
|
357
|
+
]
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
db_path = root / "state.vscdb"
|
|
364
|
+
connection = sqlite3.connect(db_path)
|
|
365
|
+
connection.execute(
|
|
366
|
+
"CREATE TABLE composerHeaders ("
|
|
367
|
+
"composerId TEXT PRIMARY KEY, lastUpdatedAt INTEGER, isSubagent INTEGER, value TEXT)"
|
|
368
|
+
)
|
|
369
|
+
connection.execute("CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)")
|
|
370
|
+
connection.execute(
|
|
371
|
+
"INSERT INTO composerHeaders(composerId, lastUpdatedAt, isSubagent, value) VALUES (?, ?, 0, ?)",
|
|
372
|
+
(
|
|
373
|
+
composer_id,
|
|
374
|
+
1_700_000_050_000,
|
|
375
|
+
json.dumps({"name": "App composer", "createdAt": 1_700_000_000_000, "unifiedMode": "agent"}),
|
|
376
|
+
),
|
|
377
|
+
)
|
|
378
|
+
connection.execute(
|
|
379
|
+
"INSERT INTO cursorDiskKV(key, value) VALUES (?, ?)",
|
|
380
|
+
(
|
|
381
|
+
f"bubbleId:{composer_id}:b1",
|
|
382
|
+
json.dumps({"type": 1, "text": "App 窗口里的问题", "createdAt": "2026-08-09T00:00:00Z"}),
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
connection.execute(
|
|
386
|
+
"INSERT INTO cursorDiskKV(key, value) VALUES (?, ?)",
|
|
387
|
+
(
|
|
388
|
+
f"bubbleId:{composer_id}:b2",
|
|
389
|
+
json.dumps({"type": 2, "text": "App 窗口里的回答", "createdAt": "2026-08-09T00:00:01Z"}),
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
connection.commit()
|
|
393
|
+
connection.close()
|
|
394
|
+
|
|
395
|
+
adapter = CursorAdapter(
|
|
396
|
+
cache_root,
|
|
397
|
+
chats_root=chats_root,
|
|
398
|
+
projects_root=projects_root,
|
|
399
|
+
state_dbs=[db_path],
|
|
400
|
+
)
|
|
401
|
+
discovered = list(adapter.discover())
|
|
402
|
+
self.assertEqual(
|
|
403
|
+
{path.name for path in discovered},
|
|
404
|
+
{f"composer-{composer_id}.json", f"cli-{session_id}.json"},
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
app_session = adapter.parse(cache_root / f"composer-{composer_id}.json")
|
|
408
|
+
cli_session = adapter.parse(cache_root / f"cli-{session_id}.json")
|
|
409
|
+
assert app_session is not None and cli_session is not None
|
|
410
|
+
self.assertEqual(app_session.id, f"cursor:{composer_id}")
|
|
411
|
+
self.assertEqual(cli_session.id, f"cursor:{session_id}")
|
|
412
|
+
self.assertEqual(app_session.metadata.get("cursor_surface"), "app")
|
|
413
|
+
self.assertEqual(cli_session.metadata.get("cursor_surface"), "cli")
|
|
414
|
+
self.assertEqual(
|
|
415
|
+
[message.text for message in app_session.messages],
|
|
416
|
+
["App 窗口里的问题", "App 窗口里的回答"],
|
|
417
|
+
)
|
|
418
|
+
self.assertEqual(
|
|
419
|
+
[message.text for message in cli_session.messages],
|
|
420
|
+
["请支持 Cursor CLI 会话索引", "已接入 Cursor CLI。"],
|
|
421
|
+
)
|
|
422
|
+
self.assertEqual(cli_session.cwd, "/work")
|
|
423
|
+
|
|
303
424
|
|
|
304
425
|
class IndexAndMcpTests(unittest.TestCase):
|
|
305
426
|
def test_count_search_reports_matches_beyond_result_limit(self) -> None:
|
|
@@ -696,7 +817,7 @@ class RefreshTests(unittest.TestCase):
|
|
|
696
817
|
self.assertIsNone(index.refresh(max_age_seconds=600))
|
|
697
818
|
self.assertIsNotNone(index.refresh(max_age_seconds=0))
|
|
698
819
|
|
|
699
|
-
def
|
|
820
|
+
def test_refresh_syncs_remote_adapters_with_throttle(self) -> None:
|
|
700
821
|
class StubRemote(CodexAdapter):
|
|
701
822
|
name = "citeanything"
|
|
702
823
|
is_remote = True
|
|
@@ -731,12 +852,15 @@ class RefreshTests(unittest.TestCase):
|
|
|
731
852
|
self.assertEqual(len(index.search("远程产品里的会话")), 1)
|
|
732
853
|
calls_after_full_index = remote.discover_calls
|
|
733
854
|
|
|
855
|
+
# force=True always syncs remote adapters.
|
|
734
856
|
index.refresh(force=True)
|
|
735
|
-
|
|
736
|
-
self.assertEqual(remote.discover_calls, calls_after_full_index)
|
|
737
|
-
# ...and its already-indexed sessions survive the local prune.
|
|
857
|
+
self.assertEqual(remote.discover_calls, calls_after_full_index + 1)
|
|
738
858
|
self.assertEqual(len(index.search("远程产品里的会话")), 1)
|
|
739
859
|
|
|
860
|
+
# A non-forced refresh within the interval skips the remote sync.
|
|
861
|
+
index.refresh(force=False)
|
|
862
|
+
self.assertEqual(remote.discover_calls, calls_after_full_index + 1)
|
|
863
|
+
|
|
740
864
|
def test_index_report_separates_unchanged_from_empty(self) -> None:
|
|
741
865
|
with tempfile.TemporaryDirectory() as directory:
|
|
742
866
|
root = Path(directory)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|