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.
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/PKG-INFO +17 -2
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/PLAN.md +3 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/README.md +15 -0
- context_forge_cli-0.4.13/contextforge/adapters/cursor.py +341 -0
- context_forge_cli-0.4.13/contextforge/adapters/datatzis.py +353 -0
- context_forge_cli-0.4.13/contextforge/adapters/pi.py +450 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/registry.py +6 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/widgets/session_detail.py +2 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/widgets/session_table.py +5 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/widgets/stats_panel.py +3 -1
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/widgets/status_bar.py +2 -1
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/widgets/tokens_panel.py +1 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/widgets/transfer_panel.py +1 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/pyproject.toml +1 -1
- context_forge_cli-0.4.13/tests/adapters/test_cursor.py +308 -0
- context_forge_cli-0.4.13/tests/adapters/test_datatzis.py +152 -0
- context_forge_cli-0.4.13/tests/adapters/test_pi.py +195 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/conftest.py +2 -1
- context_forge_cli-0.4.13/tests/fixtures/datatzis/my-repo/.datatzis/tasks/22222222222222222222222222222222.json +41 -0
- context_forge_cli-0.4.13/tests/fixtures/datatzis/my-repo/.datatzis/tasks/33333333333333333333333333333333.json +31 -0
- context_forge_cli-0.4.13/tests/fixtures/datatzis/my-repo/.datatzis/workspaces/11111111111111111111111111111111.json +20 -0
- context_forge_cli-0.4.13/tests/fixtures/datatzis/my-repo/.datatzis/workspaces/99999999999999999999999999999999.json +1 -0
- 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
- 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
- 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
- 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
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/test_mcp_server.py +2 -2
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/uv.lock +2 -2
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/.contextforge.toml +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/.github/workflows/publish.yml +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/.gitignore +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/.python-version +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/AGENTS.md +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/altimate_code.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/antigravity.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/base.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/claude_code.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/claude_desktop.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/codex.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/adapters/gemini.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/cli.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/core/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/core/analytics.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/core/compactor.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/core/db.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/core/injector.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/core/scanner.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/core/summarizer.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/core/token_analyzer.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/mcp_server.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/models/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/models/config.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/models/session.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/app.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/clipboard.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/styles.tcss +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/tui/widgets/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/utils/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/utils/display.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/contextforge/utils/tokens.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/adapters/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/adapters/test_antigravity.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/adapters/test_claude_code.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/core/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/core/test_compactor.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/core/test_db.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/core/test_injector.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/core/test_summarizer.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.13}/tests/core/test_token_analyzer.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: context-forge-cli
|
|
3
|
-
Version: 0.4.
|
|
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
|