context-forge-cli 0.4.10__tar.gz → 0.4.12__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 (62) hide show
  1. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/PKG-INFO +4 -2
  2. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/PLAN.md +2 -0
  3. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/README.md +2 -0
  4. context_forge_cli-0.4.12/contextforge/adapters/antigravity.py +233 -0
  5. context_forge_cli-0.4.12/contextforge/adapters/cursor.py +341 -0
  6. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/adapters/registry.py +4 -0
  7. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/widgets/session_detail.py +4 -0
  8. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/widgets/session_table.py +10 -0
  9. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/widgets/stats_panel.py +5 -1
  10. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/widgets/status_bar.py +3 -1
  11. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/widgets/tokens_panel.py +2 -0
  12. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/widgets/transfer_panel.py +2 -0
  13. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/pyproject.toml +1 -1
  14. context_forge_cli-0.4.12/tests/adapters/test_antigravity.py +195 -0
  15. context_forge_cli-0.4.12/tests/adapters/test_cursor.py +308 -0
  16. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/uv.lock +1 -1
  17. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/.contextforge.toml +0 -0
  18. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/.github/workflows/publish.yml +0 -0
  19. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/.gitignore +0 -0
  20. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/.python-version +0 -0
  21. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/AGENTS.md +0 -0
  22. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/__init__.py +0 -0
  23. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/adapters/__init__.py +0 -0
  24. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/adapters/altimate_code.py +0 -0
  25. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/adapters/base.py +0 -0
  26. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/adapters/claude_code.py +0 -0
  27. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/adapters/claude_desktop.py +0 -0
  28. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/adapters/codex.py +0 -0
  29. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/adapters/gemini.py +0 -0
  30. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/cli.py +0 -0
  31. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/core/__init__.py +0 -0
  32. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/core/analytics.py +0 -0
  33. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/core/compactor.py +0 -0
  34. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/core/db.py +0 -0
  35. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/core/injector.py +0 -0
  36. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/core/scanner.py +0 -0
  37. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/core/summarizer.py +0 -0
  38. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/core/token_analyzer.py +0 -0
  39. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/mcp_server.py +0 -0
  40. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/models/__init__.py +0 -0
  41. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/models/config.py +0 -0
  42. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/models/session.py +0 -0
  43. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/__init__.py +0 -0
  44. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/app.py +0 -0
  45. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/clipboard.py +0 -0
  46. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/styles.tcss +0 -0
  47. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/widgets/__init__.py +0 -0
  48. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/utils/__init__.py +0 -0
  49. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/utils/display.py +0 -0
  50. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/utils/tokens.py +0 -0
  51. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/__init__.py +0 -0
  52. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/adapters/__init__.py +0 -0
  53. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/adapters/test_claude_code.py +0 -0
  54. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/conftest.py +0 -0
  55. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/core/__init__.py +0 -0
  56. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/core/test_compactor.py +0 -0
  57. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/core/test_db.py +0 -0
  58. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/core/test_injector.py +0 -0
  59. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/core/test_summarizer.py +0 -0
  60. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/core/test_token_analyzer.py +0 -0
  61. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/fixtures/claude_session_sample.jsonl +0 -0
  62. {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/test_mcp_server.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: context-forge-cli
3
- Version: 0.4.10
3
+ Version: 0.4.12
4
4
  Summary: Session manager and context bridge for agentic CLI tools
5
5
  Project-URL: Homepage, https://github.com/emmver/contextforge
6
6
  Project-URL: Repository, https://github.com/emmver/contextforge
@@ -169,7 +169,9 @@ The method is chosen automatically based on token count and whether a target ses
169
169
  | Claude Desktop | `~/Library/Application Support/Claude/local-agent-mode-sessions/` | `--system-prompt` |
170
170
  | Codex | `~/.codex/state_5.sqlite` | `resume` / `fork` |
171
171
  | Gemini | `~/.gemini/tmp/<project_hash>/chats/` JSON | New session / `/resume` |
172
+ | Antigravity | `~/.gemini/antigravity-cli/history.jsonl` | `-i` / `--conversation` |
172
173
  | altimate-code | `~/.local/share/altimate-code/opencode.db` | `run -s` / `import` |
174
+ | Cursor | `~/.config/Cursor/User/globalStorage/state.vscdb` | `cursor-agent -p` / `--resume` |
173
175
 
174
176
  ## Session Summaries
175
177
 
@@ -87,6 +87,8 @@ Agents and contributors: update this file as you work.
87
87
  - [ ] Install docs: `uv tool install contextforge`
88
88
  - [ ] GitHub Actions CI: `uv run pytest --cov=contextforge`
89
89
  - [ ] `open-claw` adapter (if/when CLI interface is documented)
90
+ - [x] Antigravity adapter (`adapters/antigravity.py`)
91
+ - [x] Cursor adapter (`adapters/cursor.py`)
90
92
  - [ ] Session search: `cf ls --search <query>`
91
93
  - [ ] Session archiving: `cf archive <id>`
92
94
 
@@ -140,7 +140,9 @@ The method is chosen automatically based on token count and whether a target ses
140
140
  | Claude Desktop | `~/Library/Application Support/Claude/local-agent-mode-sessions/` | `--system-prompt` |
141
141
  | Codex | `~/.codex/state_5.sqlite` | `resume` / `fork` |
142
142
  | Gemini | `~/.gemini/tmp/<project_hash>/chats/` JSON | New session / `/resume` |
143
+ | Antigravity | `~/.gemini/antigravity-cli/history.jsonl` | `-i` / `--conversation` |
143
144
  | altimate-code | `~/.local/share/altimate-code/opencode.db` | `run -s` / `import` |
145
+ | Cursor | `~/.config/Cursor/User/globalStorage/state.vscdb` | `cursor-agent -p` / `--resume` |
144
146
 
145
147
  ## Session Summaries
146
148
 
@@ -0,0 +1,233 @@
1
+ """Adapter for Google Antigravity CLI sessions."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import shlex
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ import tiktoken
10
+
11
+ from contextforge.adapters.base import ToolAdapter
12
+ from contextforge.models.session import Message, Session
13
+
14
+ _AGY_HISTORY = Path.home() / ".gemini" / "antigravity-cli" / "history.jsonl"
15
+ _AGY_CONVERSATIONS = Path.home() / ".gemini" / "antigravity-cli" / "conversations"
16
+ _AGY_ALT_HISTORY = Path.home() / ".gemini" / "antigravity" / "history.jsonl"
17
+ _AGY_ALT_CONVERSATIONS = Path.home() / ".gemini" / "antigravity" / "conversations"
18
+
19
+
20
+ def _count_tokens(text: str) -> int:
21
+ """Count tokens in text using tiktoken for Claude models."""
22
+ try:
23
+ enc = tiktoken.encoding_for_model("claude-3-5-sonnet-20241022")
24
+ return len(enc.encode(text))
25
+ except Exception:
26
+ # Fallback: rough estimate (~4 chars per token)
27
+ return len(text) // 4
28
+
29
+
30
+ def _ms_to_dt(ts: int) -> datetime:
31
+ """Convert a millisecond timestamp to UTC datetime."""
32
+ return datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
33
+
34
+
35
+ class AntigravityAdapter(ToolAdapter):
36
+ tool_name = "antigravity"
37
+ default_paths = [_AGY_HISTORY, _AGY_ALT_HISTORY]
38
+
39
+ def _history_path(self) -> Path | None:
40
+ """Return the first existing history.jsonl path."""
41
+ for p in (_AGY_HISTORY, _AGY_ALT_HISTORY):
42
+ if p.exists():
43
+ return p
44
+ return None
45
+
46
+ def _conversations_dir(self) -> Path | None:
47
+ """Return the conversations directory that matches the history path."""
48
+ history = self._history_path()
49
+ if history is not None:
50
+ conversations = history.parent / "conversations"
51
+ if conversations.exists():
52
+ return conversations
53
+ for p in (_AGY_CONVERSATIONS, _AGY_ALT_CONVERSATIONS):
54
+ if p.exists():
55
+ return p
56
+ return None
57
+
58
+ def discover_sessions(self) -> list[Session]:
59
+ """Discover Antigravity sessions from history.jsonl.
60
+
61
+ history.jsonl contains one JSON object per user prompt. Prompts that
62
+ belong to the same saved conversation share a ``conversationId``; we
63
+ group those into a single ``Session``.
64
+ """
65
+ sessions: list[Session] = []
66
+ history_path = self._history_path()
67
+ if history_path is None:
68
+ return sessions
69
+
70
+ # conversationId -> list of history entries
71
+ groups: dict[str, list[dict]] = {}
72
+
73
+ with history_path.open() as f:
74
+ for line in f:
75
+ line = line.strip()
76
+ if not line:
77
+ continue
78
+ try:
79
+ entry = json.loads(line)
80
+ except json.JSONDecodeError:
81
+ continue
82
+
83
+ conversation_id = entry.get("conversationId")
84
+ if not conversation_id:
85
+ # Skip un-persisted one-off prompts.
86
+ continue
87
+ groups.setdefault(conversation_id, []).append(entry)
88
+
89
+ for conversation_id, entries in groups.items():
90
+ if not entries:
91
+ continue
92
+ # Use earliest timestamp as created_at, latest as updated_at.
93
+ timestamps = [e.get("timestamp", 0) for e in entries if e.get("timestamp")]
94
+ created_ts = min(timestamps) if timestamps else 0
95
+ updated_ts = max(timestamps) if timestamps else 0
96
+
97
+ created_at = _ms_to_dt(created_ts) if created_ts else datetime.now(timezone.utc)
98
+ updated_at = _ms_to_dt(updated_ts) if updated_ts else created_at
99
+
100
+ # Title from the first entry's display prompt.
101
+ first_display = entries[0].get("display", "")
102
+ title = first_display[:200] if first_display else None
103
+ cwd = entries[0].get("workspace") or None
104
+
105
+ sessions.append(
106
+ Session(
107
+ id=conversation_id,
108
+ tool=self.tool_name,
109
+ title=title,
110
+ cwd=cwd,
111
+ created_at=created_at,
112
+ updated_at=updated_at,
113
+ raw_path=str(history_path),
114
+ status="unknown",
115
+ )
116
+ )
117
+
118
+ return sessions
119
+
120
+ def load_messages(self, session_id: str) -> list[Message]:
121
+ """Load messages for a given Antigravity session ID.
122
+
123
+ Antigravity stores full conversations as encrypted protobuf (``.pb``)
124
+ files under ``conversations/``. ContextForge cannot currently parse
125
+ those encrypted files, so this method attempts the following, in order:
126
+
127
+ 1. Look for a JSON sidecar ``<session_id>.json`` (e.g. from a test
128
+ fixture or future export).
129
+ 2. Read the history JSONL entries with this ``conversationId`` and
130
+ synthesise minimal ``Message`` objects from the user prompts.
131
+ """
132
+ conv_dir = self._conversations_dir()
133
+ if conv_dir is not None:
134
+ json_path = conv_dir / f"{session_id}.json"
135
+ if json_path.exists():
136
+ return self._parse_json_conversation(json_path)
137
+
138
+ # Fallback: reconstruct user turns from history.jsonl.
139
+ history_path = self._history_path()
140
+ if history_path is None:
141
+ return []
142
+
143
+ messages: list[Message] = []
144
+ with history_path.open() as f:
145
+ for line in f:
146
+ line = line.strip()
147
+ if not line:
148
+ continue
149
+ try:
150
+ entry = json.loads(line)
151
+ except json.JSONDecodeError:
152
+ continue
153
+ if entry.get("conversationId") != session_id:
154
+ continue
155
+
156
+ text = entry.get("display", "")
157
+ if not text:
158
+ continue
159
+
160
+ ts = entry.get("timestamp")
161
+ timestamp = _ms_to_dt(ts) if ts else None
162
+ messages.append(
163
+ Message(
164
+ role="user",
165
+ content=text,
166
+ timestamp=timestamp,
167
+ token_count=_count_tokens(text),
168
+ )
169
+ )
170
+
171
+ return messages
172
+
173
+ def _parse_json_conversation(self, path: Path) -> list[Message]:
174
+ """Parse a JSON-sidecar conversation file into Messages.
175
+
176
+ Expected shape::
177
+
178
+ {
179
+ "messages": [
180
+ {"role": "user", "content": "...", "timestamp": 1234567890000},
181
+ {"role": "assistant", "content": "...", "timestamp": 1234567891000}
182
+ ]
183
+ }
184
+ """
185
+ messages: list[Message] = []
186
+ try:
187
+ with path.open() as f:
188
+ data = json.load(f)
189
+ except (json.JSONDecodeError, OSError):
190
+ return messages
191
+
192
+ for msg in data.get("messages", []):
193
+ role = msg.get("role", "")
194
+ if role not in ("user", "assistant"):
195
+ continue
196
+ content = str(msg.get("content", ""))
197
+ if not content:
198
+ continue
199
+ ts = msg.get("timestamp")
200
+ timestamp = _ms_to_dt(ts) if isinstance(ts, int) else None
201
+ messages.append(
202
+ Message(
203
+ role=role,
204
+ content=content,
205
+ timestamp=timestamp,
206
+ token_count=_count_tokens(content),
207
+ )
208
+ )
209
+
210
+ return messages
211
+
212
+ def build_inject_command(
213
+ self,
214
+ context: str,
215
+ target_session_id: str | None = None,
216
+ cwd: str | None = None,
217
+ method: str = "system_prompt",
218
+ ) -> str:
219
+ """Build a shell command to start/resume an Antigravity CLI session.
220
+
221
+ ``agy -i <prompt>`` starts an interactive session seeded with the given
222
+ prompt. ``agy --conversation <id>`` resumes an existing conversation.
223
+ """
224
+ safe_ctx = shlex.quote(context)
225
+
226
+ if method == "resume" and target_session_id:
227
+ return f"agy --conversation {target_session_id}"
228
+
229
+ # Default: new interactive session with the context as the opening prompt.
230
+ cmd = f"agy -i {safe_ctx}"
231
+ if cwd:
232
+ cmd = f"cd {shlex.quote(cwd)} && {cmd}"
233
+ return cmd
@@ -0,0 +1,341 @@
1
+ """Adapter for Cursor IDE chat/composer sessions.
2
+
3
+ Cursor stores conversation data in SQLite ("state.vscdb") files under its
4
+ user data directory:
5
+
6
+ - ``globalStorage/state.vscdb`` — authoritative store for full conversation
7
+ content, shared across all workspaces. Conversations ("composers") live
8
+ in the ``cursorDiskKV`` table as ``composerData:<composerId>`` rows, and
9
+ individual chat turns ("bubbles") as ``bubbleId:<composerId>:<bubbleId>``
10
+ rows.
11
+ - ``workspaceStorage/<hash>/state.vscdb`` + ``workspace.json`` — per-workspace
12
+ metadata used here only to recover the project cwd for a composer (older
13
+ Cursor versions also kept a workspace-local composer index here).
14
+
15
+ Cursor's storage schema has drifted across versions (schema version is
16
+ tracked via a ``_v`` field on composer objects), so this adapter reads
17
+ fields defensively with ``.get()`` and falls back to scanning bubbles
18
+ directly when the ``fullConversationHeadersOnly`` index is absent.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import shlex
25
+ import sqlite3
26
+ import sys
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ from urllib.parse import unquote, urlparse
30
+
31
+ import tiktoken
32
+
33
+ from contextforge.adapters.base import ToolAdapter
34
+ from contextforge.models.session import Message, Session
35
+
36
+
37
+ def _cursor_user_dir() -> Path:
38
+ """Return Cursor's per-OS "User" data directory."""
39
+ if sys.platform == "darwin":
40
+ return Path.home() / "Library" / "Application Support" / "Cursor" / "User"
41
+ if sys.platform.startswith("win"):
42
+ appdata = os.environ.get("APPDATA")
43
+ base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
44
+ return base / "Cursor" / "User"
45
+ return Path.home() / ".config" / "Cursor" / "User"
46
+
47
+
48
+ _CURSOR_USER_DIR = _cursor_user_dir()
49
+ _GLOBAL_DB = _CURSOR_USER_DIR / "globalStorage" / "state.vscdb"
50
+ _WORKSPACE_STORAGE_DIR = _CURSOR_USER_DIR / "workspaceStorage"
51
+
52
+
53
+ def _count_tokens(text: str) -> int:
54
+ """Count tokens in text using tiktoken for Claude models."""
55
+ try:
56
+ enc = tiktoken.encoding_for_model("claude-3-5-sonnet-20241022")
57
+ return len(enc.encode(text))
58
+ except Exception:
59
+ # Fallback: rough estimate (~4 chars per token)
60
+ return len(text) // 4
61
+
62
+
63
+ def _ms_to_dt(ts) -> datetime | None:
64
+ """Convert a millisecond timestamp to UTC datetime, or None if unusable."""
65
+ if not ts:
66
+ return None
67
+ try:
68
+ return datetime.fromtimestamp(int(ts) / 1000, tz=timezone.utc)
69
+ except (TypeError, ValueError, OSError, OverflowError):
70
+ return None
71
+
72
+
73
+ def _uri_to_path(uri: str) -> str | None:
74
+ """Convert a workspace.json ``file://`` URI to a plain filesystem path."""
75
+ if not uri:
76
+ return None
77
+ try:
78
+ parsed = urlparse(uri)
79
+ except ValueError:
80
+ return None
81
+ if parsed.scheme == "file":
82
+ return unquote(parsed.path)
83
+ return uri
84
+
85
+
86
+ def _load_json_blob(value) -> dict | list | None:
87
+ """Decode a BLOB/TEXT value from an ItemTable/cursorDiskKV row into JSON."""
88
+ if value is None:
89
+ return None
90
+ if isinstance(value, bytes):
91
+ try:
92
+ value = value.decode("utf-8")
93
+ except UnicodeDecodeError:
94
+ return None
95
+ try:
96
+ return json.loads(value)
97
+ except (json.JSONDecodeError, TypeError):
98
+ return None
99
+
100
+
101
+ class CursorAdapter(ToolAdapter):
102
+ tool_name = "cursor"
103
+ default_paths = [_GLOBAL_DB]
104
+
105
+ def _workspace_paths(self) -> dict[str, str]:
106
+ """Map workspaceStorage hash-dir names to their project path."""
107
+ mapping: dict[str, str] = {}
108
+ if not _WORKSPACE_STORAGE_DIR.exists():
109
+ return mapping
110
+ for ws_dir in _WORKSPACE_STORAGE_DIR.iterdir():
111
+ if not ws_dir.is_dir():
112
+ continue
113
+ meta_file = ws_dir / "workspace.json"
114
+ if not meta_file.exists():
115
+ continue
116
+ try:
117
+ with meta_file.open() as f:
118
+ meta = json.load(f)
119
+ except (json.JSONDecodeError, OSError):
120
+ continue
121
+ uri = meta.get("folder") or meta.get("workspace")
122
+ path = _uri_to_path(uri) if isinstance(uri, str) else None
123
+ if path:
124
+ mapping[ws_dir.name] = path
125
+ return mapping
126
+
127
+ def _composer_workspace_map(self) -> dict[str, str]:
128
+ """Map composerId -> project cwd via each workspace's local state.vscdb.
129
+
130
+ Only populated on Cursor <=2.6, which kept a per-workspace composer
131
+ index (``composer.composerData`` in ``ItemTable``) before the 3.0+
132
+ global-index refactor. Newer versions simply won't match here, and
133
+ callers should treat a missing entry as "cwd unknown".
134
+ """
135
+ composer_to_ws: dict[str, str] = {}
136
+ if not _WORKSPACE_STORAGE_DIR.exists():
137
+ return composer_to_ws
138
+ ws_paths = self._workspace_paths()
139
+ for ws_dir in _WORKSPACE_STORAGE_DIR.iterdir():
140
+ cwd = ws_paths.get(ws_dir.name)
141
+ db_path = ws_dir / "state.vscdb"
142
+ if not cwd or not db_path.exists():
143
+ continue
144
+ try:
145
+ conn = sqlite3.connect(str(db_path))
146
+ cur = conn.cursor()
147
+ cur.execute(
148
+ "SELECT value FROM ItemTable WHERE key = 'composer.composerData'"
149
+ )
150
+ row = cur.fetchone()
151
+ conn.close()
152
+ except sqlite3.Error:
153
+ continue
154
+ data = _load_json_blob(row[0]) if row else None
155
+ if not isinstance(data, dict):
156
+ continue
157
+ for entry in data.get("allComposers") or []:
158
+ cid = entry.get("composerId") if isinstance(entry, dict) else None
159
+ if cid:
160
+ composer_to_ws[cid] = cwd
161
+ return composer_to_ws
162
+
163
+ def discover_sessions(self) -> list[Session]:
164
+ if not _GLOBAL_DB.exists():
165
+ return []
166
+
167
+ try:
168
+ conn = sqlite3.connect(str(_GLOBAL_DB))
169
+ conn.row_factory = sqlite3.Row
170
+ cur = conn.cursor()
171
+ cur.execute(
172
+ "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'"
173
+ )
174
+ rows = cur.fetchall()
175
+ except sqlite3.Error:
176
+ return []
177
+
178
+ composer_to_ws = self._composer_workspace_map()
179
+
180
+ sessions: list[Session] = []
181
+ for row in rows:
182
+ key = row["key"]
183
+ composer_id = key.split(":", 1)[1] if ":" in key else key
184
+ data = _load_json_blob(row["value"])
185
+ if not isinstance(data, dict):
186
+ continue
187
+
188
+ headers = data.get("fullConversationHeadersOnly") or []
189
+ created_at = _ms_to_dt(data.get("createdAt"))
190
+ updated_at = _ms_to_dt(data.get("lastUpdatedAt")) or created_at
191
+
192
+ title = data.get("name") or None
193
+ if not title:
194
+ title = self._first_bubble_text(cur, composer_id, headers)
195
+
196
+ if created_at is None and updated_at is None and not headers and not title:
197
+ # Nothing usable — likely an empty/never-started composer.
198
+ continue
199
+
200
+ now = datetime.now(timezone.utc)
201
+ sessions.append(
202
+ Session(
203
+ id=composer_id,
204
+ tool=self.tool_name,
205
+ title=title[:200] if title else None,
206
+ cwd=composer_to_ws.get(composer_id),
207
+ created_at=created_at or now,
208
+ updated_at=updated_at or created_at or now,
209
+ raw_path=str(_GLOBAL_DB),
210
+ status="unknown",
211
+ )
212
+ )
213
+
214
+ conn.close()
215
+ return sessions
216
+
217
+ def _first_bubble_text(
218
+ self, cur: sqlite3.Cursor, composer_id: str, headers: list
219
+ ) -> str | None:
220
+ """Best-effort title fallback: text of the first bubble in the conversation."""
221
+ first_id = None
222
+ for h in headers:
223
+ if isinstance(h, dict) and h.get("bubbleId"):
224
+ first_id = h["bubbleId"]
225
+ break
226
+ if first_id is None:
227
+ return None
228
+ try:
229
+ cur.execute(
230
+ "SELECT value FROM cursorDiskKV WHERE key = ?",
231
+ (f"bubbleId:{composer_id}:{first_id}",),
232
+ )
233
+ row = cur.fetchone()
234
+ except sqlite3.Error:
235
+ return None
236
+ bubble = _load_json_blob(row[0]) if row else None
237
+ if isinstance(bubble, dict):
238
+ text = bubble.get("text")
239
+ if isinstance(text, str) and text.strip():
240
+ return text.strip()
241
+ return None
242
+
243
+ def load_messages(self, session_id: str) -> list[Message]:
244
+ if not _GLOBAL_DB.exists():
245
+ return []
246
+
247
+ try:
248
+ conn = sqlite3.connect(str(_GLOBAL_DB))
249
+ cur = conn.cursor()
250
+ cur.execute(
251
+ "SELECT value FROM cursorDiskKV WHERE key = ?",
252
+ (f"composerData:{session_id}",),
253
+ )
254
+ row = cur.fetchone()
255
+ composer = _load_json_blob(row[0]) if row else None
256
+ headers = (
257
+ composer.get("fullConversationHeadersOnly")
258
+ if isinstance(composer, dict)
259
+ else None
260
+ )
261
+
262
+ if headers:
263
+ ordered_ids = [
264
+ h.get("bubbleId")
265
+ for h in headers
266
+ if isinstance(h, dict) and h.get("bubbleId")
267
+ ]
268
+ keys = [f"bubbleId:{session_id}:{bid}" for bid in ordered_ids]
269
+ value_by_key: dict[str, object] = {}
270
+ if keys:
271
+ placeholders = ",".join("?" for _ in keys)
272
+ cur.execute(
273
+ f"SELECT key, value FROM cursorDiskKV WHERE key IN ({placeholders})",
274
+ keys,
275
+ )
276
+ value_by_key = dict(cur.fetchall())
277
+ ordered_bubbles = []
278
+ for key in keys:
279
+ parsed = _load_json_blob(value_by_key.get(key))
280
+ if isinstance(parsed, dict):
281
+ ordered_bubbles.append(parsed)
282
+ else:
283
+ # No header index (older/partial data) — scan bubbles directly
284
+ # and fall back to sorting by creation time.
285
+ cur.execute(
286
+ "SELECT value FROM cursorDiskKV WHERE key LIKE ?",
287
+ (f"bubbleId:{session_id}:%",),
288
+ )
289
+ ordered_bubbles = sorted(
290
+ (
291
+ parsed
292
+ for (v,) in cur.fetchall()
293
+ if isinstance(parsed := _load_json_blob(v), dict)
294
+ ),
295
+ key=lambda b: b.get("createdAt") or 0,
296
+ )
297
+
298
+ conn.close()
299
+ except sqlite3.Error:
300
+ return []
301
+
302
+ messages: list[Message] = []
303
+ for bubble in ordered_bubbles:
304
+ text = bubble.get("text")
305
+ if not isinstance(text, str) or not text.strip():
306
+ continue
307
+ # type: 1 = user, 2 = assistant.
308
+ role = "user" if bubble.get("type") == 1 else "assistant"
309
+ messages.append(
310
+ Message(
311
+ role=role,
312
+ content=text,
313
+ timestamp=_ms_to_dt(bubble.get("createdAt")),
314
+ token_count=_count_tokens(text),
315
+ )
316
+ )
317
+
318
+ return messages
319
+
320
+ def build_inject_command(
321
+ self,
322
+ context: str,
323
+ target_session_id: str | None = None,
324
+ cwd: str | None = None,
325
+ method: str = "system_prompt",
326
+ ) -> str:
327
+ """Build a shell command to start/resume a Cursor CLI (``cursor-agent``) session.
328
+
329
+ ``cursor-agent -p <prompt>`` runs a one-shot non-interactive turn;
330
+ ``cursor-agent --resume <id> -p <prompt>`` continues an existing chat.
331
+ """
332
+ safe_ctx = shlex.quote(context)
333
+
334
+ if method == "resume" and target_session_id:
335
+ cmd = f"cursor-agent --resume {shlex.quote(target_session_id)} -p {safe_ctx}"
336
+ else:
337
+ cmd = f"cursor-agent -p {safe_ctx}"
338
+
339
+ if cwd:
340
+ cmd = f"cd {shlex.quote(cwd)} && {cmd}"
341
+ return cmd
@@ -6,6 +6,8 @@ from contextforge.adapters.base import ToolAdapter
6
6
  from contextforge.adapters.claude_code import ClaudeCodeAdapter
7
7
  from contextforge.adapters.claude_desktop import ClaudeDesktopAdapter
8
8
  from contextforge.adapters.codex import CodexAdapter
9
+ from contextforge.adapters.antigravity import AntigravityAdapter
10
+ from contextforge.adapters.cursor import CursorAdapter
9
11
  from contextforge.adapters.gemini import GeminiAdapter
10
12
 
11
13
  ADAPTERS: dict[str, type[ToolAdapter]] = {
@@ -14,6 +16,8 @@ ADAPTERS: dict[str, type[ToolAdapter]] = {
14
16
  "codex": CodexAdapter,
15
17
  "altimate_code": AltimateCodeAdapter,
16
18
  "gemini": GeminiAdapter,
19
+ "antigravity": AntigravityAdapter,
20
+ "cursor": CursorAdapter,
17
21
  }
18
22
 
19
23
 
@@ -16,6 +16,8 @@ TOOL_COLORS = {
16
16
  "altimate_code": "magenta",
17
17
  "claude_desktop": "yellow",
18
18
  "gemini": "blue",
19
+ "antigravity": "white",
20
+ "cursor": "bright_white",
19
21
  }
20
22
  TOOL_LABELS = {
21
23
  "claude_code": "◆ Claude Code",
@@ -23,6 +25,8 @@ TOOL_LABELS = {
23
25
  "altimate_code": "⚡ Altimate",
24
26
  "claude_desktop": "◇ Claude Desktop",
25
27
  "gemini": "✦ Gemini",
28
+ "antigravity": "◉ Antigravity",
29
+ "cursor": "▲ Cursor",
26
30
  }
27
31
 
28
32