context-forge-cli 0.1.0__py3-none-any.whl
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.1.0.dist-info/METADATA +340 -0
- context_forge_cli-0.1.0.dist-info/RECORD +37 -0
- context_forge_cli-0.1.0.dist-info/WHEEL +4 -0
- context_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- contextforge/__init__.py +3 -0
- contextforge/adapters/__init__.py +3 -0
- contextforge/adapters/altimate_code.py +179 -0
- contextforge/adapters/base.py +44 -0
- contextforge/adapters/claude_code.py +393 -0
- contextforge/adapters/claude_desktop.py +306 -0
- contextforge/adapters/codex.py +220 -0
- contextforge/adapters/registry.py +31 -0
- contextforge/cli.py +523 -0
- contextforge/core/__init__.py +0 -0
- contextforge/core/analytics.py +226 -0
- contextforge/core/compactor.py +182 -0
- contextforge/core/db.py +181 -0
- contextforge/core/injector.py +94 -0
- contextforge/core/scanner.py +72 -0
- contextforge/core/summarizer.py +155 -0
- contextforge/core/token_analyzer.py +91 -0
- contextforge/models/__init__.py +4 -0
- contextforge/models/config.py +34 -0
- contextforge/models/session.py +37 -0
- contextforge/tui/__init__.py +0 -0
- contextforge/tui/app.py +204 -0
- contextforge/tui/styles.tcss +50 -0
- contextforge/tui/widgets/__init__.py +0 -0
- contextforge/tui/widgets/session_detail.py +191 -0
- contextforge/tui/widgets/session_table.py +277 -0
- contextforge/tui/widgets/stats_panel.py +340 -0
- contextforge/tui/widgets/status_bar.py +102 -0
- contextforge/tui/widgets/tokens_panel.py +134 -0
- contextforge/tui/widgets/transfer_panel.py +119 -0
- contextforge/utils/__init__.py +0 -0
- contextforge/utils/display.py +105 -0
- contextforge/utils/tokens.py +17 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Adapter for OpenAI Codex CLI sessions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import shlex
|
|
6
|
+
import sqlite3
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from contextforge.adapters.base import ToolAdapter
|
|
11
|
+
from contextforge.models.session import Message, Session
|
|
12
|
+
|
|
13
|
+
_CODEX_DB = Path.home() / ".codex" / "state_5.sqlite"
|
|
14
|
+
|
|
15
|
+
# Timestamps above this threshold are milliseconds; below are seconds.
|
|
16
|
+
# 1e11 ms ≈ year 1973, so any value > 1e11 must be ms-since-epoch.
|
|
17
|
+
_MS_THRESHOLD = 100_000_000_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _ts_to_dt(ts) -> datetime:
|
|
21
|
+
"""Convert a Codex timestamp (seconds or milliseconds) to a UTC datetime."""
|
|
22
|
+
if ts is None:
|
|
23
|
+
return datetime.now(timezone.utc)
|
|
24
|
+
ts = int(ts)
|
|
25
|
+
if ts > _MS_THRESHOLD:
|
|
26
|
+
ts = ts // 1000 # milliseconds → seconds
|
|
27
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
28
|
+
_CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions"
|
|
29
|
+
_SESSION_INDEX = Path.home() / ".codex" / "session_index.jsonl"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CodexAdapter(ToolAdapter):
|
|
33
|
+
tool_name = "codex"
|
|
34
|
+
default_paths = [_CODEX_DB, _CODEX_SESSIONS_DIR]
|
|
35
|
+
|
|
36
|
+
def discover_sessions(self) -> list[Session]:
|
|
37
|
+
if _CODEX_DB.exists():
|
|
38
|
+
return self._from_sqlite()
|
|
39
|
+
if _SESSION_INDEX.exists():
|
|
40
|
+
return self._from_index()
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
def _from_sqlite(self) -> list[Session]:
|
|
44
|
+
sessions: list[Session] = []
|
|
45
|
+
try:
|
|
46
|
+
conn = sqlite3.connect(str(_CODEX_DB))
|
|
47
|
+
conn.row_factory = sqlite3.Row
|
|
48
|
+
cur = conn.cursor()
|
|
49
|
+
cur.execute(
|
|
50
|
+
"""
|
|
51
|
+
SELECT id, title, cwd, tokens_used, first_user_message,
|
|
52
|
+
created_at, updated_at, rollout_path
|
|
53
|
+
FROM threads
|
|
54
|
+
ORDER BY updated_at DESC
|
|
55
|
+
"""
|
|
56
|
+
)
|
|
57
|
+
rows = cur.fetchall()
|
|
58
|
+
conn.close()
|
|
59
|
+
except (sqlite3.Error, Exception):
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
for row in rows:
|
|
63
|
+
try:
|
|
64
|
+
created = _ts_to_dt(row["created_at"])
|
|
65
|
+
updated = _ts_to_dt(row["updated_at"])
|
|
66
|
+
except (TypeError, ValueError):
|
|
67
|
+
now = datetime.now(timezone.utc)
|
|
68
|
+
created = updated = now
|
|
69
|
+
|
|
70
|
+
sessions.append(
|
|
71
|
+
Session(
|
|
72
|
+
id=str(row["id"]),
|
|
73
|
+
tool=self.tool_name,
|
|
74
|
+
title=row["title"] or None,
|
|
75
|
+
cwd=row["cwd"] or None,
|
|
76
|
+
created_at=created,
|
|
77
|
+
updated_at=updated,
|
|
78
|
+
token_count=row["tokens_used"],
|
|
79
|
+
raw_path=row["rollout_path"] or None,
|
|
80
|
+
status="unknown",
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return sessions
|
|
84
|
+
|
|
85
|
+
def _from_index(self) -> list[Session]:
|
|
86
|
+
sessions: list[Session] = []
|
|
87
|
+
with _SESSION_INDEX.open() as f:
|
|
88
|
+
for line in f:
|
|
89
|
+
line = line.strip()
|
|
90
|
+
if not line:
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
entry = json.loads(line)
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
ts = entry.get("timestamp") or entry.get("created_at")
|
|
98
|
+
try:
|
|
99
|
+
dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
|
|
100
|
+
except (ValueError, AttributeError):
|
|
101
|
+
dt = datetime.now(timezone.utc)
|
|
102
|
+
|
|
103
|
+
sessions.append(
|
|
104
|
+
Session(
|
|
105
|
+
id=entry.get("id", ""),
|
|
106
|
+
tool=self.tool_name,
|
|
107
|
+
title=entry.get("title"),
|
|
108
|
+
cwd=entry.get("cwd"),
|
|
109
|
+
created_at=dt,
|
|
110
|
+
updated_at=dt,
|
|
111
|
+
status="unknown",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
return sessions
|
|
115
|
+
|
|
116
|
+
def get_rollup_summary(self, session_id: str) -> str | None:
|
|
117
|
+
"""Return Codex's pre-computed rollup summary if available (zero LLM cost)."""
|
|
118
|
+
if not _CODEX_DB.exists():
|
|
119
|
+
return None
|
|
120
|
+
try:
|
|
121
|
+
conn = sqlite3.connect(str(_CODEX_DB))
|
|
122
|
+
conn.row_factory = sqlite3.Row
|
|
123
|
+
cur = conn.cursor()
|
|
124
|
+
# Check if stage1_outputs table exists
|
|
125
|
+
cur.execute(
|
|
126
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='stage1_outputs'"
|
|
127
|
+
)
|
|
128
|
+
if not cur.fetchone():
|
|
129
|
+
conn.close()
|
|
130
|
+
return None
|
|
131
|
+
cur.execute(
|
|
132
|
+
"SELECT rollout_summary FROM stage1_outputs WHERE thread_id = ? "
|
|
133
|
+
"ORDER BY id DESC LIMIT 1",
|
|
134
|
+
(session_id,),
|
|
135
|
+
)
|
|
136
|
+
row = cur.fetchone()
|
|
137
|
+
conn.close()
|
|
138
|
+
if row and row["rollout_summary"]:
|
|
139
|
+
return str(row["rollout_summary"]).strip() or None
|
|
140
|
+
except sqlite3.Error:
|
|
141
|
+
pass
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def load_messages(self, session_id: str) -> list[Message]:
|
|
145
|
+
# Try to get rollout path from SQLite
|
|
146
|
+
rollout_path: Path | None = None
|
|
147
|
+
if _CODEX_DB.exists():
|
|
148
|
+
try:
|
|
149
|
+
conn = sqlite3.connect(str(_CODEX_DB))
|
|
150
|
+
conn.row_factory = sqlite3.Row
|
|
151
|
+
cur = conn.cursor()
|
|
152
|
+
cur.execute("SELECT rollout_path FROM threads WHERE id = ?", (session_id,))
|
|
153
|
+
row = cur.fetchone()
|
|
154
|
+
conn.close()
|
|
155
|
+
if row and row["rollout_path"]:
|
|
156
|
+
rollout_path = Path(row["rollout_path"])
|
|
157
|
+
except sqlite3.Error:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
if rollout_path and rollout_path.exists():
|
|
161
|
+
return self._parse_rollout(rollout_path)
|
|
162
|
+
|
|
163
|
+
# Fallback: scan sessions directory
|
|
164
|
+
for year_dir in _CODEX_SESSIONS_DIR.glob("*"):
|
|
165
|
+
for month_dir in year_dir.glob("*"):
|
|
166
|
+
session_dir = month_dir / session_id
|
|
167
|
+
if session_dir.exists():
|
|
168
|
+
for jsonl in sorted(session_dir.glob("rollout-*.jsonl")):
|
|
169
|
+
return self._parse_rollout(jsonl)
|
|
170
|
+
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
def _parse_rollout(self, path: Path) -> list[Message]:
|
|
174
|
+
messages: list[Message] = []
|
|
175
|
+
with path.open() as f:
|
|
176
|
+
for line in f:
|
|
177
|
+
line = line.strip()
|
|
178
|
+
if not line:
|
|
179
|
+
continue
|
|
180
|
+
try:
|
|
181
|
+
entry = json.loads(line)
|
|
182
|
+
except json.JSONDecodeError:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
entry_type = entry.get("type", "")
|
|
186
|
+
payload = entry.get("payload", {})
|
|
187
|
+
|
|
188
|
+
if entry_type == "event_msg":
|
|
189
|
+
msg_type = payload.get("type", "")
|
|
190
|
+
# user_message / agent_message are the clean human-facing turns.
|
|
191
|
+
# response_item entries are skipped — they contain system context
|
|
192
|
+
# injections (AGENTS.md, environment_context) and duplicate content.
|
|
193
|
+
if msg_type == "user_message":
|
|
194
|
+
text = payload.get("message", "")
|
|
195
|
+
if text:
|
|
196
|
+
messages.append(Message(role="user", content=str(text)))
|
|
197
|
+
elif msg_type == "agent_message":
|
|
198
|
+
text = payload.get("message", "")
|
|
199
|
+
if text:
|
|
200
|
+
messages.append(Message(role="assistant", content=str(text)))
|
|
201
|
+
|
|
202
|
+
return messages
|
|
203
|
+
|
|
204
|
+
def build_inject_command(
|
|
205
|
+
self,
|
|
206
|
+
context: str,
|
|
207
|
+
target_session_id: str | None = None,
|
|
208
|
+
cwd: str | None = None,
|
|
209
|
+
method: str = "system_prompt",
|
|
210
|
+
) -> str:
|
|
211
|
+
safe_ctx = shlex.quote(context)
|
|
212
|
+
if method == "resume" and target_session_id:
|
|
213
|
+
return f"codex resume {target_session_id} {safe_ctx}"
|
|
214
|
+
if method == "fork" and target_session_id:
|
|
215
|
+
return f"codex fork {target_session_id} {safe_ctx}"
|
|
216
|
+
# New session
|
|
217
|
+
cmd = f"codex exec {safe_ctx}"
|
|
218
|
+
if cwd:
|
|
219
|
+
cmd = f"cd {shlex.quote(cwd)} && {cmd}"
|
|
220
|
+
return cmd
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Adapter registry — maps tool names to adapter instances."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from contextforge.adapters.altimate_code import AltimateCodeAdapter
|
|
5
|
+
from contextforge.adapters.base import ToolAdapter
|
|
6
|
+
from contextforge.adapters.claude_code import ClaudeCodeAdapter
|
|
7
|
+
from contextforge.adapters.claude_desktop import ClaudeDesktopAdapter
|
|
8
|
+
from contextforge.adapters.codex import CodexAdapter
|
|
9
|
+
|
|
10
|
+
ADAPTERS: dict[str, type[ToolAdapter]] = {
|
|
11
|
+
"claude_code": ClaudeCodeAdapter,
|
|
12
|
+
"claude_desktop": ClaudeDesktopAdapter,
|
|
13
|
+
"codex": CodexAdapter,
|
|
14
|
+
"altimate_code": AltimateCodeAdapter,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_adapter(tool_name: str) -> ToolAdapter:
|
|
19
|
+
cls = ADAPTERS.get(tool_name)
|
|
20
|
+
if cls is None:
|
|
21
|
+
raise ValueError(f"Unknown tool: {tool_name!r}. Available: {list(ADAPTERS)}")
|
|
22
|
+
return cls()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_all_adapters() -> list[ToolAdapter]:
|
|
26
|
+
return [cls() for cls in ADAPTERS.values()]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_available_adapters() -> list[ToolAdapter]:
|
|
30
|
+
"""Return only adapters whose data paths exist on this machine."""
|
|
31
|
+
return [a for a in get_all_adapters() if a.is_available()]
|