context-forge-cli 0.2.0__tar.gz → 0.2.1__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 (55) hide show
  1. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/PKG-INFO +3 -3
  2. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/README.md +2 -2
  3. context_forge_cli-0.2.1/contextforge/adapters/gemini.py +188 -0
  4. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/pyproject.toml +1 -1
  5. context_forge_cli-0.2.0/contextforge/adapters/gemini.py +0 -203
  6. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/.contextforge.toml +0 -0
  7. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/.github/workflows/publish.yml +0 -0
  8. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/.gitignore +0 -0
  9. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/.python-version +0 -0
  10. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/AGENTS.md +0 -0
  11. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/PLAN.md +0 -0
  12. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/__init__.py +0 -0
  13. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/adapters/__init__.py +0 -0
  14. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/adapters/altimate_code.py +0 -0
  15. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/adapters/base.py +0 -0
  16. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/adapters/claude_code.py +0 -0
  17. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/adapters/claude_desktop.py +0 -0
  18. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/adapters/codex.py +0 -0
  19. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/adapters/registry.py +0 -0
  20. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/cli.py +0 -0
  21. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/core/__init__.py +0 -0
  22. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/core/analytics.py +0 -0
  23. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/core/compactor.py +0 -0
  24. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/core/db.py +0 -0
  25. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/core/injector.py +0 -0
  26. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/core/scanner.py +0 -0
  27. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/core/summarizer.py +0 -0
  28. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/core/token_analyzer.py +0 -0
  29. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/models/__init__.py +0 -0
  30. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/models/config.py +0 -0
  31. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/models/session.py +0 -0
  32. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/tui/__init__.py +0 -0
  33. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/tui/app.py +0 -0
  34. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/tui/styles.tcss +0 -0
  35. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/tui/widgets/__init__.py +0 -0
  36. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/tui/widgets/session_detail.py +0 -0
  37. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/tui/widgets/session_table.py +0 -0
  38. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/tui/widgets/stats_panel.py +0 -0
  39. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/tui/widgets/status_bar.py +0 -0
  40. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/tui/widgets/tokens_panel.py +0 -0
  41. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/tui/widgets/transfer_panel.py +0 -0
  42. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/utils/__init__.py +0 -0
  43. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/utils/display.py +0 -0
  44. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/contextforge/utils/tokens.py +0 -0
  45. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/tests/__init__.py +0 -0
  46. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/tests/adapters/__init__.py +0 -0
  47. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/tests/adapters/test_claude_code.py +0 -0
  48. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/tests/core/__init__.py +0 -0
  49. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/tests/core/test_compactor.py +0 -0
  50. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/tests/core/test_db.py +0 -0
  51. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/tests/core/test_injector.py +0 -0
  52. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/tests/core/test_summarizer.py +0 -0
  53. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/tests/core/test_token_analyzer.py +0 -0
  54. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/tests/fixtures/claude_session_sample.jsonl +0 -0
  55. {context_forge_cli-0.2.0 → context_forge_cli-0.2.1}/uv.lock +0 -0
@@ -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.1
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
@@ -9,7 +9,7 @@ or across different tools.
9
9
 
10
10
  ## What it does
11
11
 
12
- - **Discovers sessions** from Claude Code (`~/.claude/projects/`), Claude Desktop, Codex (`~/.codex/state_5.sqlite`), Gemini (`~/.gemini/sessions/`), and altimate-code (`~/.local/share/altimate-code/`)
12
+ - **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/`)
13
13
  - **Generates summaries** of what each session accomplished (with optional Claude API integration)
14
14
  - **Compacts context** intelligently, reducing multi-turn conversations to a token-efficient ContextBundle
15
15
  - **Transfers context** across tools and sessions (Claude Code → Codex, Codex → altimate-code, etc.)
@@ -139,7 +139,7 @@ The method is chosen automatically based on token count and whether a target ses
139
139
  | Claude Code (CLI) | `~/.claude/projects/` JSONL | `--system-prompt` / `--resume` |
140
140
  | Claude Desktop | `~/Library/Application Support/Claude/local-agent-mode-sessions/` | `--system-prompt` |
141
141
  | Codex | `~/.codex/state_5.sqlite` | `resume` / `fork` |
142
- | Gemini | `~/.gemini/sessions/` JSONL | `exec` / `resume` / `fork` |
142
+ | Gemini | `~/.gemini/tmp/<project_hash>/chats/` JSON | New session / `/resume` |
143
143
  | altimate-code | `~/.local/share/altimate-code/opencode.db` | `run -s` / `import` |
144
144
 
145
145
  ## Session Summaries
@@ -0,0 +1,188 @@
1
+ """Adapter for Google Gemini CLI sessions."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import shlex
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from contextforge.adapters.base import ToolAdapter
10
+ from contextforge.models.session import Message, Session
11
+
12
+ _GEMINI_TMP_BASE = Path.home() / ".gemini" / "tmp"
13
+
14
+
15
+ class GeminiAdapter(ToolAdapter):
16
+ tool_name = "gemini"
17
+ default_paths = [_GEMINI_TMP_BASE]
18
+
19
+ def discover_sessions(self) -> list[Session]:
20
+ """Discover Gemini sessions from ~/.gemini/tmp/<project_hash>/chats/."""
21
+ sessions: list[Session] = []
22
+
23
+ if not _GEMINI_TMP_BASE.exists():
24
+ return sessions
25
+
26
+ # Scan all project hash directories
27
+ for project_dir in _GEMINI_TMP_BASE.iterdir():
28
+ if not project_dir.is_dir():
29
+ continue
30
+
31
+ chats_dir = project_dir / "chats"
32
+ if not chats_dir.exists():
33
+ continue
34
+
35
+ # Scan for session JSON files in format: session-<timestamp>-<hash>.json
36
+ for session_file in sorted(chats_dir.glob("session-*.json")):
37
+ try:
38
+ with session_file.open() as f:
39
+ data = json.load(f)
40
+ except (json.JSONDecodeError, OSError):
41
+ continue
42
+
43
+ session_id = data.get("sessionId")
44
+ if not session_id:
45
+ continue
46
+
47
+ # Extract timestamps
48
+ start_time_str = data.get("startTime")
49
+ last_updated_str = data.get("lastUpdated")
50
+
51
+ created_at = None
52
+ updated_at = None
53
+
54
+ if start_time_str:
55
+ try:
56
+ created_at = datetime.fromisoformat(
57
+ start_time_str.replace("Z", "+00:00")
58
+ )
59
+ except (ValueError, AttributeError):
60
+ pass
61
+
62
+ if last_updated_str:
63
+ try:
64
+ updated_at = datetime.fromisoformat(
65
+ last_updated_str.replace("Z", "+00:00")
66
+ )
67
+ except (ValueError, AttributeError):
68
+ pass
69
+
70
+ if created_at is None:
71
+ created_at = datetime.now(timezone.utc)
72
+ if updated_at is None:
73
+ updated_at = created_at
74
+
75
+ # Extract title from first user message
76
+ title = None
77
+ messages = data.get("messages", [])
78
+ for msg in messages:
79
+ if msg.get("type") == "user":
80
+ content = msg.get("content", [])
81
+ if isinstance(content, list) and content:
82
+ text = content[0].get("text", "")
83
+ if text:
84
+ title = text[:100]
85
+ break
86
+
87
+ sessions.append(
88
+ Session(
89
+ id=session_id,
90
+ tool=self.tool_name,
91
+ title=title,
92
+ cwd=None,
93
+ created_at=created_at,
94
+ updated_at=updated_at,
95
+ raw_path=str(session_file),
96
+ status="unknown",
97
+ )
98
+ )
99
+
100
+ return sessions
101
+
102
+ def load_messages(self, session_id: str) -> list[Message]:
103
+ """Load messages for a given session ID by scanning all projects."""
104
+ if not _GEMINI_TMP_BASE.exists():
105
+ return []
106
+
107
+ # Brute-force search across all projects to find the session
108
+ for project_dir in _GEMINI_TMP_BASE.iterdir():
109
+ if not project_dir.is_dir():
110
+ continue
111
+
112
+ chats_dir = project_dir / "chats"
113
+ if not chats_dir.exists():
114
+ continue
115
+
116
+ for session_file in chats_dir.glob("session-*.json"):
117
+ try:
118
+ with session_file.open() as f:
119
+ data = json.load(f)
120
+ except (json.JSONDecodeError, OSError):
121
+ continue
122
+
123
+ if data.get("sessionId") == session_id:
124
+ return self._parse_session_data(data)
125
+
126
+ return []
127
+
128
+ def _parse_session_data(self, data: dict) -> list[Message]:
129
+ """Parse Gemini session JSON object into Message list."""
130
+ messages: list[Message] = []
131
+
132
+ for msg in data.get("messages", []):
133
+ msg_type = msg.get("type", "")
134
+ if msg_type not in ("user", "gemini"):
135
+ continue
136
+
137
+ # Extract content
138
+ content_raw = msg.get("content", "")
139
+ if isinstance(content_raw, list):
140
+ # User messages: content is list of {text: ...}
141
+ parts = []
142
+ for item in content_raw:
143
+ if isinstance(item, dict) and "text" in item:
144
+ parts.append(item["text"])
145
+ content = " ".join(parts)
146
+ else:
147
+ # Gemini messages: content is a string
148
+ content = str(content_raw)
149
+
150
+ if not content or not content.strip():
151
+ continue
152
+
153
+ ts_str = msg.get("timestamp")
154
+ ts = None
155
+ if ts_str:
156
+ try:
157
+ ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
158
+ except (ValueError, AttributeError):
159
+ pass
160
+
161
+ messages.append(
162
+ Message(
163
+ role="user" if msg_type == "user" else "assistant",
164
+ content=content,
165
+ timestamp=ts,
166
+ )
167
+ )
168
+
169
+ return messages
170
+
171
+ def build_inject_command(
172
+ self,
173
+ context: str,
174
+ target_session_id: str | None = None,
175
+ cwd: str | None = None,
176
+ method: str = "system_prompt",
177
+ ) -> str:
178
+ """Build a shell command to start/resume a Gemini session with context."""
179
+ safe_ctx = shlex.quote(context)
180
+
181
+ if method == "resume" and target_session_id:
182
+ return f"gemini /resume {target_session_id}"
183
+
184
+ # Default: new session with context injected via system prompt
185
+ cmd = f"gemini {safe_ctx}"
186
+ if cwd:
187
+ cmd = f"cd {shlex.quote(cwd)} && {cmd}"
188
+ return cmd
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "context-forge-cli"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Session manager and context bridge for agentic CLI tools"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -1,203 +0,0 @@
1
- """Adapter for Google Gemini CLI sessions."""
2
- from __future__ import annotations
3
-
4
- import json
5
- import shlex
6
- from datetime import datetime, timezone
7
- from pathlib import Path
8
-
9
- from contextforge.adapters.base import ToolAdapter
10
- from contextforge.models.session import Message, Session
11
-
12
- _GEMINI_HISTORY_PATH = Path.home() / ".gemini" / "history.jsonl"
13
- _GEMINI_SESSIONS_DIR = Path.home() / ".gemini" / "sessions"
14
-
15
-
16
- class GeminiAdapter(ToolAdapter):
17
- tool_name = "gemini"
18
- default_paths = [_GEMINI_HISTORY_PATH, _GEMINI_SESSIONS_DIR]
19
-
20
- 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."""
36
- sessions: list[Session] = []
37
-
38
- if not _GEMINI_SESSIONS_DIR.exists():
39
- return sessions
40
-
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()
82
-
83
- if not _GEMINI_HISTORY_PATH.exists():
84
- return sessions
85
-
86
- with _GEMINI_HISTORY_PATH.open() as f:
87
- for line in f:
88
- line = line.strip()
89
- if not line:
90
- continue
91
- try:
92
- entry = json.loads(line)
93
- except json.JSONDecodeError:
94
- continue
95
-
96
- session_id = entry.get("session_id") or entry.get("sessionId")
97
- if not session_id or session_id in seen:
98
- continue
99
- seen.add(session_id)
100
-
101
- title = entry.get("title") or entry.get("display")
102
- ts = entry.get("timestamp")
103
-
104
- try:
105
- dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
106
- except (ValueError, AttributeError, TypeError):
107
- dt = datetime.now(timezone.utc)
108
-
109
- sessions.append(
110
- Session(
111
- id=session_id,
112
- tool=self.tool_name,
113
- title=title,
114
- cwd=None,
115
- created_at=dt,
116
- updated_at=dt,
117
- raw_path=None,
118
- status="unknown",
119
- )
120
- )
121
-
122
- return sessions
123
-
124
- 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)
131
-
132
- return []
133
-
134
- def _parse_session_file(self, path: Path) -> list[Message]:
135
- """Parse a Gemini session JSONL file."""
136
- messages: list[Message] = []
137
-
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
180
-
181
- return messages
182
-
183
- def build_inject_command(
184
- self,
185
- context: str,
186
- target_session_id: str | None = None,
187
- cwd: str | None = None,
188
- method: str = "system_prompt",
189
- ) -> str:
190
- """Build a shell command to start/resume a Gemini session with context."""
191
- safe_ctx = shlex.quote(context)
192
-
193
- 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}"
198
-
199
- # Default: new session
200
- cmd = f"gemini exec {safe_ctx}"
201
- if cwd:
202
- cmd = f"cd {shlex.quote(cwd)} && {cmd}"
203
- return cmd