context-forge-cli 0.4.11__tar.gz → 0.4.13__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 (74) hide show
  1. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/PKG-INFO +17 -2
  2. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/PLAN.md +3 -0
  3. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/README.md +15 -0
  4. context_forge_cli-0.4.13/contextforge/adapters/cursor.py +341 -0
  5. context_forge_cli-0.4.13/contextforge/adapters/datatzis.py +353 -0
  6. context_forge_cli-0.4.13/contextforge/adapters/pi.py +450 -0
  7. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/registry.py +6 -0
  8. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/widgets/session_detail.py +2 -0
  9. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/widgets/session_table.py +5 -0
  10. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/widgets/stats_panel.py +3 -1
  11. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/widgets/status_bar.py +2 -1
  12. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/widgets/tokens_panel.py +1 -0
  13. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/widgets/transfer_panel.py +1 -0
  14. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/pyproject.toml +1 -1
  15. context_forge_cli-0.4.13/tests/adapters/test_cursor.py +308 -0
  16. context_forge_cli-0.4.13/tests/adapters/test_datatzis.py +152 -0
  17. context_forge_cli-0.4.13/tests/adapters/test_pi.py +195 -0
  18. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/conftest.py +2 -1
  19. context_forge_cli-0.4.13/tests/fixtures/datatzis/my-repo/.datatzis/tasks/22222222222222222222222222222222.json +41 -0
  20. context_forge_cli-0.4.13/tests/fixtures/datatzis/my-repo/.datatzis/tasks/33333333333333333333333333333333.json +31 -0
  21. context_forge_cli-0.4.13/tests/fixtures/datatzis/my-repo/.datatzis/workspaces/11111111111111111111111111111111.json +20 -0
  22. context_forge_cli-0.4.13/tests/fixtures/datatzis/my-repo/.datatzis/workspaces/99999999999999999999999999999999.json +1 -0
  23. context_forge_cli-0.4.13/tests/fixtures/pi/--home-dev-project-a-/2026-01-15T10-30-00-000Z_a1b2c3d4-0000-4000-8000-000000000001/e5f61122-3333-4444-5555-666677778888/run-0/session.jsonl +5 -0
  24. context_forge_cli-0.4.13/tests/fixtures/pi/--home-dev-project-a-/2026-01-15T10-30-00-000Z_a1b2c3d4-0000-4000-8000-000000000001/e5f61122-3333-4444-5555-666677778888/run-1/session.jsonl +5 -0
  25. context_forge_cli-0.4.13/tests/fixtures/pi/--home-dev-project-a-/2026-01-15T10-30-00-000Z_a1b2c3d4-0000-4000-8000-000000000001.jsonl +9 -0
  26. context_forge_cli-0.4.13/tests/fixtures/pi/--home-dev-project-a-/2026-01-15T11-20-00-000Z_c9d8e7f6-0000-4000-8000-000000000003.jsonl +9 -0
  27. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/test_mcp_server.py +2 -2
  28. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/uv.lock +2 -2
  29. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/.contextforge.toml +0 -0
  30. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/.github/workflows/publish.yml +0 -0
  31. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/.gitignore +0 -0
  32. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/.python-version +0 -0
  33. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/AGENTS.md +0 -0
  34. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/__init__.py +0 -0
  35. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/__init__.py +0 -0
  36. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/altimate_code.py +0 -0
  37. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/antigravity.py +0 -0
  38. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/base.py +0 -0
  39. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/claude_code.py +0 -0
  40. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/claude_desktop.py +0 -0
  41. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/codex.py +0 -0
  42. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/gemini.py +0 -0
  43. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/cli.py +0 -0
  44. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/core/__init__.py +0 -0
  45. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/core/analytics.py +0 -0
  46. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/core/compactor.py +0 -0
  47. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/core/db.py +0 -0
  48. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/core/injector.py +0 -0
  49. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/core/scanner.py +0 -0
  50. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/core/summarizer.py +0 -0
  51. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/core/token_analyzer.py +0 -0
  52. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/mcp_server.py +0 -0
  53. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/models/__init__.py +0 -0
  54. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/models/config.py +0 -0
  55. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/models/session.py +0 -0
  56. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/__init__.py +0 -0
  57. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/app.py +0 -0
  58. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/clipboard.py +0 -0
  59. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/styles.tcss +0 -0
  60. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/widgets/__init__.py +0 -0
  61. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/utils/__init__.py +0 -0
  62. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/utils/display.py +0 -0
  63. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/utils/tokens.py +0 -0
  64. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/__init__.py +0 -0
  65. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/adapters/__init__.py +0 -0
  66. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/adapters/test_antigravity.py +0 -0
  67. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/adapters/test_claude_code.py +0 -0
  68. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/core/__init__.py +0 -0
  69. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/core/test_compactor.py +0 -0
  70. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/core/test_db.py +0 -0
  71. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/core/test_injector.py +0 -0
  72. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/core/test_summarizer.py +0 -0
  73. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/core/test_token_analyzer.py +0 -0
  74. {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/fixtures/claude_session_sample.jsonl +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.11
3
+ Version: 0.4.13
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
@@ -171,6 +171,21 @@ The method is chosen automatically based on token count and whether a target ses
171
171
  | Gemini | `~/.gemini/tmp/<project_hash>/chats/` JSON | New session / `/resume` |
172
172
  | Antigravity | `~/.gemini/antigravity-cli/history.jsonl` | `-i` / `--conversation` |
173
173
  | altimate-code | `~/.local/share/altimate-code/opencode.db` | `run -s` / `import` |
174
+ | Cursor | `~/.config/Cursor/User/globalStorage/state.vscdb` | `cursor-agent -p` / `--resume` |
175
+ | Pi agent | `~/.pi/agent/sessions/` JSONL | `--system-prompt` / `--session` + `--append-system-prompt` |
176
+ | datatzis harness | `<repo>/.datatzis/` JSON (per-repo) | launch/resume only: `datatzis chat --resume` (delivered interactively) |
177
+
178
+ Notes:
179
+
180
+ - **Sub-agent tracking** — sessions spawned by sub-agents are tracked and cross-linked: Pi
181
+ (`pi-subagents`) child transcripts get `subagent` + `parent:<session-id>` tags naming the
182
+ parent session; the datatzis harness's orchestrator tasks (one per user prompt) get
183
+ `subagent` + `workspace:<session-id>` tags.
184
+ - **datatzis discovery** — per-repo storage: ContextForge scans repository roots for
185
+ `*/.datatzis/workspaces/` (up to two levels deep). Configure roots via the
186
+ `DATATZIS_SEARCH_ROOTS` environment variable (colon-separated; default `~/Documents/Github`).
187
+ The datatzis CLI is interactive-only, so inject commands open or resume the session rather
188
+ than passing context as a flag.
174
189
 
175
190
  ## Session Summaries
176
191
 
@@ -88,6 +88,9 @@ Agents and contributors: update this file as you work.
88
88
  - [ ] GitHub Actions CI: `uv run pytest --cov=contextforge`
89
89
  - [ ] `open-claw` adapter (if/when CLI interface is documented)
90
90
  - [x] Antigravity adapter (`adapters/antigravity.py`)
91
+ - [x] Cursor adapter (`adapters/cursor.py`)
92
+ - [x] Pi agent adapter (`adapters/pi.py`) — including sub-agent session tracking (`pi-subagents` runs nested under the parent session stem)
93
+ - [x] datatzis harness adapter (`adapters/datatzis.py`) — per-repo `.datatzis/` workspaces + orchestrator-task sub-agent tracking (consultation/dossier runs intentionally out of scope)
91
94
  - [ ] Session search: `cf ls --search <query>`
92
95
  - [ ] Session archiving: `cf archive <id>`
93
96
 
@@ -142,6 +142,21 @@ The method is chosen automatically based on token count and whether a target ses
142
142
  | Gemini | `~/.gemini/tmp/<project_hash>/chats/` JSON | New session / `/resume` |
143
143
  | Antigravity | `~/.gemini/antigravity-cli/history.jsonl` | `-i` / `--conversation` |
144
144
  | altimate-code | `~/.local/share/altimate-code/opencode.db` | `run -s` / `import` |
145
+ | Cursor | `~/.config/Cursor/User/globalStorage/state.vscdb` | `cursor-agent -p` / `--resume` |
146
+ | Pi agent | `~/.pi/agent/sessions/` JSONL | `--system-prompt` / `--session` + `--append-system-prompt` |
147
+ | datatzis harness | `<repo>/.datatzis/` JSON (per-repo) | launch/resume only: `datatzis chat --resume` (delivered interactively) |
148
+
149
+ Notes:
150
+
151
+ - **Sub-agent tracking** — sessions spawned by sub-agents are tracked and cross-linked: Pi
152
+ (`pi-subagents`) child transcripts get `subagent` + `parent:<session-id>` tags naming the
153
+ parent session; the datatzis harness's orchestrator tasks (one per user prompt) get
154
+ `subagent` + `workspace:<session-id>` tags.
155
+ - **datatzis discovery** — per-repo storage: ContextForge scans repository roots for
156
+ `*/.datatzis/workspaces/` (up to two levels deep). Configure roots via the
157
+ `DATATZIS_SEARCH_ROOTS` environment variable (colon-separated; default `~/Documents/Github`).
158
+ The datatzis CLI is interactive-only, so inject commands open or resume the session rather
159
+ than passing context as a flag.
145
160
 
146
161
  ## Session Summaries
147
162
 
@@ -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