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.
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/PKG-INFO +4 -2
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/PLAN.md +2 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/README.md +2 -0
- context_forge_cli-0.4.12/contextforge/adapters/antigravity.py +233 -0
- context_forge_cli-0.4.12/contextforge/adapters/cursor.py +341 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/adapters/registry.py +4 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/widgets/session_detail.py +4 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/widgets/session_table.py +10 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/widgets/stats_panel.py +5 -1
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/widgets/status_bar.py +3 -1
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/widgets/tokens_panel.py +2 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/widgets/transfer_panel.py +2 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/pyproject.toml +1 -1
- context_forge_cli-0.4.12/tests/adapters/test_antigravity.py +195 -0
- context_forge_cli-0.4.12/tests/adapters/test_cursor.py +308 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/uv.lock +1 -1
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/.contextforge.toml +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/.github/workflows/publish.yml +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/.gitignore +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/.python-version +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/AGENTS.md +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/__init__.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/adapters/__init__.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/adapters/altimate_code.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/adapters/base.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/adapters/claude_code.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/adapters/claude_desktop.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/adapters/codex.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/adapters/gemini.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/cli.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/core/__init__.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/core/analytics.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/core/compactor.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/core/db.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/core/injector.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/core/scanner.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/core/summarizer.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/core/token_analyzer.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/mcp_server.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/models/__init__.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/models/config.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/models/session.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/__init__.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/app.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/clipboard.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/styles.tcss +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/widgets/__init__.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/utils/__init__.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/utils/display.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/utils/tokens.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/__init__.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/adapters/__init__.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/adapters/test_claude_code.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/conftest.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/core/__init__.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/core/test_compactor.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/core/test_db.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/core/test_injector.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/core/test_summarizer.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/core/test_token_analyzer.py +0 -0
- {context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/tests/fixtures/claude_session_sample.jsonl +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: context-forge-cli
|
|
3
|
-
Version: 0.4.
|
|
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
|
|
{context_forge_cli-0.4.10 → context_forge_cli-0.4.12}/contextforge/tui/widgets/session_detail.py
RENAMED
|
@@ -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
|
|