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.
Files changed (37) hide show
  1. context_forge_cli-0.1.0.dist-info/METADATA +340 -0
  2. context_forge_cli-0.1.0.dist-info/RECORD +37 -0
  3. context_forge_cli-0.1.0.dist-info/WHEEL +4 -0
  4. context_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. contextforge/__init__.py +3 -0
  6. contextforge/adapters/__init__.py +3 -0
  7. contextforge/adapters/altimate_code.py +179 -0
  8. contextforge/adapters/base.py +44 -0
  9. contextforge/adapters/claude_code.py +393 -0
  10. contextforge/adapters/claude_desktop.py +306 -0
  11. contextforge/adapters/codex.py +220 -0
  12. contextforge/adapters/registry.py +31 -0
  13. contextforge/cli.py +523 -0
  14. contextforge/core/__init__.py +0 -0
  15. contextforge/core/analytics.py +226 -0
  16. contextforge/core/compactor.py +182 -0
  17. contextforge/core/db.py +181 -0
  18. contextforge/core/injector.py +94 -0
  19. contextforge/core/scanner.py +72 -0
  20. contextforge/core/summarizer.py +155 -0
  21. contextforge/core/token_analyzer.py +91 -0
  22. contextforge/models/__init__.py +4 -0
  23. contextforge/models/config.py +34 -0
  24. contextforge/models/session.py +37 -0
  25. contextforge/tui/__init__.py +0 -0
  26. contextforge/tui/app.py +204 -0
  27. contextforge/tui/styles.tcss +50 -0
  28. contextforge/tui/widgets/__init__.py +0 -0
  29. contextforge/tui/widgets/session_detail.py +191 -0
  30. contextforge/tui/widgets/session_table.py +277 -0
  31. contextforge/tui/widgets/stats_panel.py +340 -0
  32. contextforge/tui/widgets/status_bar.py +102 -0
  33. contextforge/tui/widgets/tokens_panel.py +134 -0
  34. contextforge/tui/widgets/transfer_panel.py +119 -0
  35. contextforge/utils/__init__.py +0 -0
  36. contextforge/utils/display.py +105 -0
  37. 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()]