context-forge-cli 0.2.0__py3-none-any.whl → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: context-forge-cli
3
- Version: 0.2.0
3
+ Version: 0.2.2
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
@@ -37,7 +37,7 @@ or across different tools.
37
37
 
38
38
  ## What it does
39
39
 
40
- - **Discovers sessions** from Claude Code (`~/.claude/projects/`), Claude Desktop, Codex (`~/.codex/state_5.sqlite`), Gemini (`~/.gemini/sessions/`), and altimate-code (`~/.local/share/altimate-code/`)
40
+ - **Discovers sessions** from Claude Code (`~/.claude/projects/`), Claude Desktop, Codex (`~/.codex/state_5.sqlite`), Gemini (`~/.gemini/tmp/<project_hash>/chats/`), and altimate-code (`~/.local/share/altimate-code/`)
41
41
  - **Generates summaries** of what each session accomplished (with optional Claude API integration)
42
42
  - **Compacts context** intelligently, reducing multi-turn conversations to a token-efficient ContextBundle
43
43
  - **Transfers context** across tools and sessions (Claude Code → Codex, Codex → altimate-code, etc.)
@@ -167,7 +167,7 @@ The method is chosen automatically based on token count and whether a target ses
167
167
  | Claude Code (CLI) | `~/.claude/projects/` JSONL | `--system-prompt` / `--resume` |
168
168
  | Claude Desktop | `~/Library/Application Support/Claude/local-agent-mode-sessions/` | `--system-prompt` |
169
169
  | Codex | `~/.codex/state_5.sqlite` | `resume` / `fork` |
170
- | Gemini | `~/.gemini/sessions/` JSONL | `exec` / `resume` / `fork` |
170
+ | Gemini | `~/.gemini/tmp/<project_hash>/chats/` JSON | New session / `/resume` |
171
171
  | altimate-code | `~/.local/share/altimate-code/opencode.db` | `run -s` / `import` |
172
172
 
173
173
  ## Session Summaries
@@ -1,12 +1,12 @@
1
1
  contextforge/__init__.py,sha256=L5NnW7jpnDQrAhnqTWSzW-bk2Bp8TXpFK1-SmQXI2QE,104
2
2
  contextforge/cli.py,sha256=OIHjV_pL4fKCvnn-2bQwza8NOUplptSfASPg4yzQmjA,18685
3
3
  contextforge/adapters/__init__.py,sha256=HGIFdC_tNpX_QOPrePwU5cEVBhp3Hsh6Sihwb23HdTk,171
4
- contextforge/adapters/altimate_code.py,sha256=aFo3FvO6XdN8eORnU_F8PbEB7rTTB2quIyU381AHE1E,5967
4
+ contextforge/adapters/altimate_code.py,sha256=_buMKoFP8YSqtElPHwmsJicse85OydwoX1t94JC8BDI,6050
5
5
  contextforge/adapters/base.py,sha256=q3_pn0XoDV_r2GkuSzuRof0SSqynHqiaCAe4piq3tGs,1355
6
- contextforge/adapters/claude_code.py,sha256=RcFucjQGuDztU_BFVdm-O7mQ12FHFSD6y9AUrYJg4X4,15315
7
- contextforge/adapters/claude_desktop.py,sha256=uXJgMt2BdbhkwU9DMXhik--kb-RNKqxTdMc4Z9O-9lc,11957
8
- contextforge/adapters/codex.py,sha256=WhLHOyZZM4KI-G2hEvsqq9PYOjuCyI2Zo8S9ircZtf8,8005
9
- contextforge/adapters/gemini.py,sha256=maTuHg9XhHimBCvcaRQ2X7InqrEI3PIi2LVCFY3hcXQ,7007
6
+ contextforge/adapters/claude_code.py,sha256=Oes23vjRRv7Js0BaZvScZNDpyaItlwPEWKwsyL6Zj60,14453
7
+ contextforge/adapters/claude_desktop.py,sha256=P0fI3Tg00bX8MifV09I1iRPqk-6m5w1kpllCW7Bc2hg,12238
8
+ contextforge/adapters/codex.py,sha256=yj0Sdzo8yrkQHSqfCxq-NxXuaiaUOSlnZOTIAOQw-gU,8776
9
+ contextforge/adapters/gemini.py,sha256=757prqt5_AILxFm53CEfhlqjFKlxVs-YmLBImt8Aj1M,6844
10
10
  contextforge/adapters/registry.py,sha256=kbasRJE4WBJ8qw7VGENgjp8NDS3jyIHo8wpGxjk9srs,1177
11
11
  contextforge/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
12
  contextforge/core/analytics.py,sha256=qffm7oqfmosU6XPUftbf68AOvDrylrxBR4rJyLhvoXU,7056
@@ -32,7 +32,7 @@ contextforge/tui/widgets/transfer_panel.py,sha256=Qrgkt1-MpzRHWuwNyu2ErM26IRl0ya
32
32
  contextforge/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
33
  contextforge/utils/display.py,sha256=bagZgpZ6WGki2THNHA8lfg9ITqM8W0q_M6Os-pgwFHk,3111
34
34
  contextforge/utils/tokens.py,sha256=UVGz-vvmCIxMgnRPn1T53v2ZdKMmxKhoJcc1qbmjpDQ,414
35
- context_forge_cli-0.2.0.dist-info/METADATA,sha256=XzPmavHPsItycsFQJ75Wu8UaxVsleh338N1_ukaJZMU,12803
36
- context_forge_cli-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
37
- context_forge_cli-0.2.0.dist-info/entry_points.txt,sha256=nPN0PWb85KCPXqFp4opmBsNejdCHyl7rK_cREsu4r7A,44
38
- context_forge_cli-0.2.0.dist-info/RECORD,,
35
+ context_forge_cli-0.2.2.dist-info/METADATA,sha256=PixJa-FTRNZSUIxXHuah7o_1Z2QlzaSNkUqGyoe-5-g,12831
36
+ context_forge_cli-0.2.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
37
+ context_forge_cli-0.2.2.dist-info/entry_points.txt,sha256=nPN0PWb85KCPXqFp4opmBsNejdCHyl7rK_cREsu4r7A,44
38
+ context_forge_cli-0.2.2.dist-info/RECORD,,
@@ -117,6 +117,7 @@ class AltimateCodeAdapter(ToolAdapter):
117
117
  role=current_role,
118
118
  content=content,
119
119
  timestamp=current_ts,
120
+ token_count=_count_tokens(content),
120
121
  )
121
122
  )
122
123
  current_role = None
@@ -157,7 +158,7 @@ class AltimateCodeAdapter(ToolAdapter):
157
158
  messages = self.load_messages(session_id)
158
159
  total = 0
159
160
  for msg in messages:
160
- total += _count_tokens(msg.content)
161
+ total += msg.token_count or _count_tokens(msg.content)
161
162
  return total
162
163
 
163
164
  def build_inject_command(
@@ -333,6 +333,7 @@ class ClaudeCodeAdapter(ToolAdapter):
333
333
  role="user" if role == "user" else "assistant",
334
334
  content=content,
335
335
  timestamp=ts,
336
+ token_count=_count_tokens(content),
336
337
  )
337
338
  )
338
339
 
@@ -340,35 +341,11 @@ class ClaudeCodeAdapter(ToolAdapter):
340
341
 
341
342
  def _count_session_tokens(self, jsonl_path: Path) -> int:
342
343
  """Count total tokens in a session JSONL file."""
344
+ session_id = jsonl_path.stem
345
+ messages = self.load_messages(session_id)
343
346
  total = 0
344
- try:
345
- with jsonl_path.open() as f:
346
- for line in f:
347
- line = line.strip()
348
- if not line:
349
- continue
350
- try:
351
- entry = json.loads(line)
352
- except json.JSONDecodeError:
353
- continue
354
-
355
- entry_type = entry.get("type", "")
356
- if entry_type not in ("user", "assistant"):
357
- continue
358
-
359
- msg_data = entry.get("message", {})
360
- content_raw = msg_data.get("content", "")
361
-
362
- # Skip tool-result-only arrays (not actual user/assistant content)
363
- if isinstance(content_raw, list):
364
- if not any(block.get("type") == "text" for block in content_raw if isinstance(block, dict)):
365
- continue
366
-
367
- content = _parse_content(content_raw)
368
- if content:
369
- total += _count_tokens(content)
370
- except Exception:
371
- pass
347
+ for msg in messages:
348
+ total += msg.token_count or _count_tokens(msg.content)
372
349
  return total
373
350
 
374
351
  def build_inject_command(
@@ -240,8 +240,12 @@ class ClaudeDesktopAdapter(ToolAdapter):
240
240
  pass
241
241
 
242
242
  messages.append(
243
- Message(role="user" if role == "user" else "assistant",
244
- content=content, timestamp=ts)
243
+ Message(
244
+ role="user" if role == "user" else "assistant",
245
+ content=content,
246
+ timestamp=ts,
247
+ token_count=_count_tokens(content),
248
+ )
245
249
  )
246
250
  return messages
247
251
 
@@ -279,7 +283,12 @@ class ClaudeDesktopAdapter(ToolAdapter):
279
283
  except (ValueError, AttributeError):
280
284
  pass
281
285
 
282
- messages.append(Message(role=role, content=content, timestamp=ts))
286
+ messages.append(Message(
287
+ role=role,
288
+ content=content,
289
+ timestamp=ts,
290
+ token_count=_count_tokens(content),
291
+ ))
283
292
  return messages
284
293
 
285
294
  def _count_session_tokens(self, session_id: str) -> int:
@@ -287,7 +296,7 @@ class ClaudeDesktopAdapter(ToolAdapter):
287
296
  messages = self.load_messages(session_id)
288
297
  total = 0
289
298
  for msg in messages:
290
- total += _count_tokens(msg.content)
299
+ total += msg.token_count or _count_tokens(msg.content)
291
300
  return total
292
301
 
293
302
  def build_inject_command(
@@ -7,6 +7,8 @@ import sqlite3
7
7
  from datetime import datetime, timezone
8
8
  from pathlib import Path
9
9
 
10
+ import tiktoken
11
+
10
12
  from contextforge.adapters.base import ToolAdapter
11
13
  from contextforge.models.session import Message, Session
12
14
 
@@ -29,6 +31,16 @@ _CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions"
29
31
  _SESSION_INDEX = Path.home() / ".codex" / "session_index.jsonl"
30
32
 
31
33
 
34
+ def _count_tokens(text: str) -> int:
35
+ """Count tokens in text using tiktoken for Claude models."""
36
+ try:
37
+ enc = tiktoken.encoding_for_model("claude-3-5-sonnet-20241022")
38
+ return len(enc.encode(text))
39
+ except Exception:
40
+ # Fallback: rough estimate (~4 chars per token)
41
+ return len(text) // 4
42
+
43
+
32
44
  class CodexAdapter(ToolAdapter):
33
45
  tool_name = "codex"
34
46
  default_paths = [_CODEX_DB, _CODEX_SESSIONS_DIR]
@@ -193,11 +205,21 @@ class CodexAdapter(ToolAdapter):
193
205
  if msg_type == "user_message":
194
206
  text = payload.get("message", "")
195
207
  if text:
196
- messages.append(Message(role="user", content=str(text)))
208
+ text_str = str(text)
209
+ messages.append(Message(
210
+ role="user",
211
+ content=text_str,
212
+ token_count=_count_tokens(text_str),
213
+ ))
197
214
  elif msg_type == "agent_message":
198
215
  text = payload.get("message", "")
199
216
  if text:
200
- messages.append(Message(role="assistant", content=str(text)))
217
+ text_str = str(text)
218
+ messages.append(Message(
219
+ role="assistant",
220
+ content=text_str,
221
+ token_count=_count_tokens(text_str),
222
+ ))
201
223
 
202
224
  return messages
203
225
 
@@ -6,105 +6,95 @@ import shlex
6
6
  from datetime import datetime, timezone
7
7
  from pathlib import Path
8
8
 
9
+ import tiktoken
10
+
9
11
  from contextforge.adapters.base import ToolAdapter
10
12
  from contextforge.models.session import Message, Session
11
13
 
12
- _GEMINI_HISTORY_PATH = Path.home() / ".gemini" / "history.jsonl"
13
- _GEMINI_SESSIONS_DIR = Path.home() / ".gemini" / "sessions"
14
+ _GEMINI_TMP_BASE = Path.home() / ".gemini" / "tmp"
15
+
16
+
17
+ def _count_tokens(text: str) -> int:
18
+ """Count tokens in text using tiktoken for Claude models."""
19
+ try:
20
+ enc = tiktoken.encoding_for_model("claude-3-5-sonnet-20241022")
21
+ return len(enc.encode(text))
22
+ except Exception:
23
+ # Fallback: rough estimate (~4 chars per token)
24
+ return len(text) // 4
14
25
 
15
26
 
16
27
  class GeminiAdapter(ToolAdapter):
17
28
  tool_name = "gemini"
18
- default_paths = [_GEMINI_HISTORY_PATH, _GEMINI_SESSIONS_DIR]
29
+ default_paths = [_GEMINI_TMP_BASE]
19
30
 
20
31
  def discover_sessions(self) -> list[Session]:
21
- """Discover Gemini sessions from history and sessions directory."""
22
- sessions: list[Session] = []
23
-
24
- # Try to load from sessions directory first
25
- if _GEMINI_SESSIONS_DIR.exists():
26
- sessions = self._scan_sessions_dir()
27
-
28
- # Fall back to history.jsonl if no sessions directory
29
- if not sessions and _GEMINI_HISTORY_PATH.exists():
30
- sessions = self._from_history()
31
-
32
- return sessions
33
-
34
- def _scan_sessions_dir(self) -> list[Session]:
35
- """Scan the Gemini sessions directory for session files."""
32
+ """Discover Gemini sessions from ~/.gemini/tmp/<project_hash>/chats/."""
36
33
  sessions: list[Session] = []
37
34
 
38
- if not _GEMINI_SESSIONS_DIR.exists():
35
+ if not _GEMINI_TMP_BASE.exists():
39
36
  return sessions
40
37
 
41
- for session_file in sorted(_GEMINI_SESSIONS_DIR.glob("*.jsonl")):
42
- session_id = session_file.stem
43
- mtime = session_file.stat().st_mtime
44
- dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
45
-
46
- # Try to extract title from first few lines
47
- title = None
48
- try:
49
- with session_file.open() as f:
50
- for line in f:
51
- try:
52
- entry = json.loads(line)
53
- if entry.get("type") == "user" and not title:
54
- content = entry.get("message", {}).get("content", "")
55
- if isinstance(content, str):
56
- title = content[:100] # First 100 chars as title
57
- break
58
- except json.JSONDecodeError:
59
- pass
60
- except Exception:
61
- pass
62
-
63
- sessions.append(
64
- Session(
65
- id=session_id,
66
- tool=self.tool_name,
67
- title=title,
68
- cwd=None,
69
- created_at=dt,
70
- updated_at=dt,
71
- raw_path=str(session_file),
72
- status="unknown",
73
- )
74
- )
75
-
76
- return sessions
77
-
78
- def _from_history(self) -> list[Session]:
79
- """Load sessions from history.jsonl file."""
80
- sessions: list[Session] = []
81
- seen: set[str] = set()
38
+ # Scan all project hash directories
39
+ for project_dir in _GEMINI_TMP_BASE.iterdir():
40
+ if not project_dir.is_dir():
41
+ continue
82
42
 
83
- if not _GEMINI_HISTORY_PATH.exists():
84
- return sessions
43
+ chats_dir = project_dir / "chats"
44
+ if not chats_dir.exists():
45
+ continue
85
46
 
86
- with _GEMINI_HISTORY_PATH.open() as f:
87
- for line in f:
88
- line = line.strip()
89
- if not line:
90
- continue
47
+ # Scan for session JSON files in format: session-<timestamp>-<hash>.json
48
+ for session_file in sorted(chats_dir.glob("session-*.json")):
91
49
  try:
92
- entry = json.loads(line)
93
- except json.JSONDecodeError:
50
+ with session_file.open() as f:
51
+ data = json.load(f)
52
+ except (json.JSONDecodeError, OSError):
94
53
  continue
95
54
 
96
- session_id = entry.get("session_id") or entry.get("sessionId")
97
- if not session_id or session_id in seen:
55
+ session_id = data.get("sessionId")
56
+ if not session_id:
98
57
  continue
99
- seen.add(session_id)
100
58
 
101
- title = entry.get("title") or entry.get("display")
102
- ts = entry.get("timestamp")
59
+ # Extract timestamps
60
+ start_time_str = data.get("startTime")
61
+ last_updated_str = data.get("lastUpdated")
103
62
 
104
- try:
105
- dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
106
- except (ValueError, AttributeError, TypeError):
107
- dt = datetime.now(timezone.utc)
63
+ created_at = None
64
+ updated_at = None
65
+
66
+ if start_time_str:
67
+ try:
68
+ created_at = datetime.fromisoformat(
69
+ start_time_str.replace("Z", "+00:00")
70
+ )
71
+ except (ValueError, AttributeError):
72
+ pass
73
+
74
+ if last_updated_str:
75
+ try:
76
+ updated_at = datetime.fromisoformat(
77
+ last_updated_str.replace("Z", "+00:00")
78
+ )
79
+ except (ValueError, AttributeError):
80
+ pass
81
+
82
+ if created_at is None:
83
+ created_at = datetime.now(timezone.utc)
84
+ if updated_at is None:
85
+ updated_at = created_at
86
+
87
+ # Extract title from first user message
88
+ title = None
89
+ messages = data.get("messages", [])
90
+ for msg in messages:
91
+ if msg.get("type") == "user":
92
+ content = msg.get("content", [])
93
+ if isinstance(content, list) and content:
94
+ text = content[0].get("text", "")
95
+ if text:
96
+ title = text[:100]
97
+ break
108
98
 
109
99
  sessions.append(
110
100
  Session(
@@ -112,9 +102,9 @@ class GeminiAdapter(ToolAdapter):
112
102
  tool=self.tool_name,
113
103
  title=title,
114
104
  cwd=None,
115
- created_at=dt,
116
- updated_at=dt,
117
- raw_path=None,
105
+ created_at=created_at,
106
+ updated_at=updated_at,
107
+ raw_path=str(session_file),
118
108
  status="unknown",
119
109
  )
120
110
  )
@@ -122,61 +112,72 @@ class GeminiAdapter(ToolAdapter):
122
112
  return sessions
123
113
 
124
114
  def load_messages(self, session_id: str) -> list[Message]:
125
- """Load messages for a given session ID."""
126
- # Try to find session file
127
- if _GEMINI_SESSIONS_DIR.exists():
128
- session_file = _GEMINI_SESSIONS_DIR / f"{session_id}.jsonl"
129
- if session_file.exists():
130
- return self._parse_session_file(session_file)
115
+ """Load messages for a given session ID by scanning all projects."""
116
+ if not _GEMINI_TMP_BASE.exists():
117
+ return []
118
+
119
+ # Brute-force search across all projects to find the session
120
+ for project_dir in _GEMINI_TMP_BASE.iterdir():
121
+ if not project_dir.is_dir():
122
+ continue
123
+
124
+ chats_dir = project_dir / "chats"
125
+ if not chats_dir.exists():
126
+ continue
127
+
128
+ for session_file in chats_dir.glob("session-*.json"):
129
+ try:
130
+ with session_file.open() as f:
131
+ data = json.load(f)
132
+ except (json.JSONDecodeError, OSError):
133
+ continue
134
+
135
+ if data.get("sessionId") == session_id:
136
+ return self._parse_session_data(data)
131
137
 
132
138
  return []
133
139
 
134
- def _parse_session_file(self, path: Path) -> list[Message]:
135
- """Parse a Gemini session JSONL file."""
140
+ def _parse_session_data(self, data: dict) -> list[Message]:
141
+ """Parse Gemini session JSON object into Message list."""
136
142
  messages: list[Message] = []
137
143
 
138
- try:
139
- with path.open() as f:
140
- for line in f:
141
- line = line.strip()
142
- if not line:
143
- continue
144
- try:
145
- entry = json.loads(line)
146
- except json.JSONDecodeError:
147
- continue
148
-
149
- entry_type = entry.get("type", "")
150
- if entry_type not in ("user", "assistant"):
151
- continue
152
-
153
- msg_data = entry.get("message", {})
154
- content = msg_data.get("content", "")
155
-
156
- if not content:
157
- continue
158
-
159
- # Handle both string and list content formats
160
- if isinstance(content, list):
161
- content = " ".join(str(item) for item in content if item)
162
-
163
- ts_str = entry.get("timestamp")
164
- ts = None
165
- if ts_str:
166
- try:
167
- ts = datetime.fromisoformat(str(ts_str).replace("Z", "+00:00"))
168
- except (ValueError, AttributeError, TypeError):
169
- pass
170
-
171
- messages.append(
172
- Message(
173
- role="user" if entry_type == "user" else "assistant",
174
- content=str(content),
175
- timestamp=ts,
176
- )
177
- )
178
- except Exception:
179
- pass
144
+ for msg in data.get("messages", []):
145
+ msg_type = msg.get("type", "")
146
+ if msg_type not in ("user", "gemini"):
147
+ continue
148
+
149
+ # Extract content
150
+ content_raw = msg.get("content", "")
151
+ if isinstance(content_raw, list):
152
+ # User messages: content is list of {text: ...}
153
+ parts = []
154
+ for item in content_raw:
155
+ if isinstance(item, dict) and "text" in item:
156
+ parts.append(item["text"])
157
+ content = " ".join(parts)
158
+ else:
159
+ # Gemini messages: content is a string
160
+ content = str(content_raw)
161
+
162
+ if not content or not content.strip():
163
+ continue
164
+
165
+ ts_str = msg.get("timestamp")
166
+ ts = None
167
+ if ts_str:
168
+ try:
169
+ ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
170
+ except (ValueError, AttributeError):
171
+ pass
172
+
173
+ messages.append(
174
+ Message(
175
+ role="user" if msg_type == "user" else "assistant",
176
+ content=content,
177
+ timestamp=ts,
178
+ token_count=_count_tokens(content),
179
+ )
180
+ )
180
181
 
181
182
  return messages
182
183
 
@@ -191,13 +192,10 @@ class GeminiAdapter(ToolAdapter):
191
192
  safe_ctx = shlex.quote(context)
192
193
 
193
194
  if method == "resume" and target_session_id:
194
- return f"gemini resume {target_session_id} {safe_ctx}"
195
-
196
- if method == "fork" and target_session_id:
197
- return f"gemini fork {target_session_id} {safe_ctx}"
195
+ return f"gemini /resume {target_session_id}"
198
196
 
199
- # Default: new session
200
- cmd = f"gemini exec {safe_ctx}"
197
+ # Default: new session with context injected via system prompt
198
+ cmd = f"gemini {safe_ctx}"
201
199
  if cwd:
202
200
  cmd = f"cd {shlex.quote(cwd)} && {cmd}"
203
201
  return cmd