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.
Files changed (34) hide show
  1. {syncanything-0.3.0/src/syncanything.egg-info → syncanything-0.3.2}/PKG-INFO +35 -14
  2. {syncanything-0.3.0 → syncanything-0.3.2}/README.md +34 -13
  3. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/__init__.py +1 -1
  4. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/cli.py +2 -2
  5. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/connections.py +1 -1
  6. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/index.py +31 -13
  7. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/mcp.py +1 -1
  8. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/sources/__init__.py +3 -1
  9. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/sources/citeanything.py +2 -2
  10. syncanything-0.3.2/src/syncanything/sources/cursor.py +397 -0
  11. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/static/app.js +5 -5
  12. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/static/index.html +1 -1
  13. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/static/styles.css +21 -7
  14. {syncanything-0.3.0 → syncanything-0.3.2/src/syncanything.egg-info}/PKG-INFO +35 -14
  15. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything.egg-info/SOURCES.txt +1 -0
  16. {syncanything-0.3.0 → syncanything-0.3.2}/tests/test_syncanything.py +128 -4
  17. {syncanything-0.3.0 → syncanything-0.3.2}/LICENSE +0 -0
  18. {syncanything-0.3.0 → syncanything-0.3.2}/pyproject.toml +0 -0
  19. {syncanything-0.3.0 → syncanything-0.3.2}/setup.cfg +0 -0
  20. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/__main__.py +0 -0
  21. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/metrics.py +0 -0
  22. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/models.py +0 -0
  23. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/service.py +0 -0
  24. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/sources/base.py +0 -0
  25. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/sources/claude.py +0 -0
  26. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/sources/codex.py +0 -0
  27. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/sources/kimi.py +0 -0
  28. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/sources/pi.py +0 -0
  29. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/static/__init__.py +0 -0
  30. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/static/logo.svg +0 -0
  31. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything/web.py +0 -0
  32. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything.egg-info/dependency_links.txt +0 -0
  33. {syncanything-0.3.0 → syncanything-0.3.2}/src/syncanything.egg-info/entry_points.txt +0 -0
  34. {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.0
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.veri-glow.com"
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
- Example MCP client configuration:
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
- | Kimi Code (legacy) | `~/.kimi/sessions/*/*/context.jsonl` | Verified locally |
292
- | Kimi Code (current) | `~/.kimi-code/sessions/*/*/agents/main/wire.jsonl` | Adapter included |
293
- | Pi | `~/.pi/agent/sessions/**/*.jsonl` | Official format implemented |
294
- | CiteAnything | Authenticated Conversation API | Product-level adapter included |
295
- | OpenCode | SQLite session store | Next adapter |
296
- | Grok Build | Pending stable local export contract | Next adapter |
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.veri-glow.com"
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
- Example MCP client configuration:
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
- | Kimi Code (legacy) | `~/.kimi/sessions/*/*/context.jsonl` | Verified locally |
263
- | Kimi Code (current) | `~/.kimi-code/sessions/*/*/agents/main/wire.jsonl` | Adapter included |
264
- | Pi | `~/.pi/agent/sessions/**/*.jsonl` | Official format implemented |
265
- | CiteAnything | Authenticated Conversation API | Product-level adapter included |
266
- | OpenCode | SQLite session store | Next adapter |
267
- | Grok Build | Pending stable local export contract | Next adapter |
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
 
@@ -1,3 +1,3 @@
1
1
  """SyncAnything: one local index for conversations across AI coding tools."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.2"
@@ -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")
@@ -16,7 +16,7 @@ from typing import Any
16
16
 
17
17
  KEYCHAIN_SERVICE = "SyncAnything.CiteAnything"
18
18
  SITE_URLS = {
19
- "international": "https://citeanything.veri-glow.com",
19
+ "international": "https://citeanything.app",
20
20
  "china": "https://citeanything.cn",
21
21
  }
22
22
 
@@ -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 local sources before a read.
167
+ """Incrementally re-index sources before a read.
165
168
 
166
- Scanning every local session file costs about 15ms, so this runs inline on
167
- search, list, show, reference, and status. Remote sources are excluded: their
168
- discover() performs HTTP requests and belongs to explicit `index` or `serve`.
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
- if not force and (time.time() - self.last_refresh("local")) < max_age_seconds:
172
- return None
173
- sources = self.adapters if self.adapters is not None else local_adapters()
174
- local = [adapter for adapter in sources if not adapter.is_remote]
175
- if not local:
176
- return None
177
- report = self.index_all(adapters=local)
178
- self._set_meta("last_refresh:local", str(time.time()))
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.veri-glow.com"
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 == "citeanything.veri-glow.com":
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 字、拉丁 4 字符每 Token 估算",
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.veri-glow.com",
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 characters and 4 Latin characters per token",
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.veri-glow.com",
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.veri-glow.com</option>
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
- .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
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: 25px; font-weight: 680; line-height: 1.15; letter-spacing: -.035em; overflow-wrap: anywhere; }
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
- .results { display: grid; gap: 12px; }
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
- .result-card h3 { margin: 12px 0 8px; font-size: 18px; letter-spacing: -.015em; }
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(auto-fit, minmax(132px, 1fr)); }
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.0
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.veri-glow.com"
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
- Example MCP client configuration:
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
- | Kimi Code (legacy) | `~/.kimi/sessions/*/*/context.jsonl` | Verified locally |
292
- | Kimi Code (current) | `~/.kimi-code/sessions/*/*/agents/main/wire.jsonl` | Adapter included |
293
- | Pi | `~/.pi/agent/sessions/**/*.jsonl` | Official format implemented |
294
- | CiteAnything | Authenticated Conversation API | Product-level adapter included |
295
- | OpenCode | SQLite session store | Next adapter |
296
- | Grok Build | Pending stable local export contract | Next adapter |
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 test_refresh_skips_remote_adapters_and_keeps_their_sessions(self) -> None:
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
- # The remote source is never contacted by a read-path refresh...
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