context-forge-cli 0.2.0__tar.gz → 0.2.2__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.2}/PKG-INFO +3 -3
  2. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/README.md +2 -2
  3. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/adapters/altimate_code.py +2 -1
  4. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/adapters/claude_code.py +5 -28
  5. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/adapters/claude_desktop.py +13 -4
  6. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/adapters/codex.py +24 -2
  7. context_forge_cli-0.2.2/contextforge/adapters/gemini.py +201 -0
  8. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/pyproject.toml +1 -1
  9. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/uv.lock +2 -2
  10. context_forge_cli-0.2.0/contextforge/adapters/gemini.py +0 -203
  11. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/.contextforge.toml +0 -0
  12. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/.github/workflows/publish.yml +0 -0
  13. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/.gitignore +0 -0
  14. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/.python-version +0 -0
  15. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/AGENTS.md +0 -0
  16. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/PLAN.md +0 -0
  17. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/__init__.py +0 -0
  18. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/adapters/__init__.py +0 -0
  19. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/adapters/base.py +0 -0
  20. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/adapters/registry.py +0 -0
  21. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/cli.py +0 -0
  22. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/core/__init__.py +0 -0
  23. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/core/analytics.py +0 -0
  24. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/core/compactor.py +0 -0
  25. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/core/db.py +0 -0
  26. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/core/injector.py +0 -0
  27. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/core/scanner.py +0 -0
  28. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/core/summarizer.py +0 -0
  29. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/core/token_analyzer.py +0 -0
  30. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/models/__init__.py +0 -0
  31. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/models/config.py +0 -0
  32. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/models/session.py +0 -0
  33. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/tui/__init__.py +0 -0
  34. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/tui/app.py +0 -0
  35. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/tui/styles.tcss +0 -0
  36. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/tui/widgets/__init__.py +0 -0
  37. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/tui/widgets/session_detail.py +0 -0
  38. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/tui/widgets/session_table.py +0 -0
  39. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/tui/widgets/stats_panel.py +0 -0
  40. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/tui/widgets/status_bar.py +0 -0
  41. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/tui/widgets/tokens_panel.py +0 -0
  42. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/tui/widgets/transfer_panel.py +0 -0
  43. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/utils/__init__.py +0 -0
  44. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/utils/display.py +0 -0
  45. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/contextforge/utils/tokens.py +0 -0
  46. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/tests/__init__.py +0 -0
  47. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/tests/adapters/__init__.py +0 -0
  48. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/tests/adapters/test_claude_code.py +0 -0
  49. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/tests/core/__init__.py +0 -0
  50. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/tests/core/test_compactor.py +0 -0
  51. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/tests/core/test_db.py +0 -0
  52. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/tests/core/test_injector.py +0 -0
  53. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/tests/core/test_summarizer.py +0 -0
  54. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/tests/core/test_token_analyzer.py +0 -0
  55. {context_forge_cli-0.2.0 → context_forge_cli-0.2.2}/tests/fixtures/claude_session_sample.jsonl +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.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
@@ -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
@@ -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
 
@@ -0,0 +1,201 @@
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
+ import tiktoken
10
+
11
+ from contextforge.adapters.base import ToolAdapter
12
+ from contextforge.models.session import Message, Session
13
+
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
25
+
26
+
27
+ class GeminiAdapter(ToolAdapter):
28
+ tool_name = "gemini"
29
+ default_paths = [_GEMINI_TMP_BASE]
30
+
31
+ def discover_sessions(self) -> list[Session]:
32
+ """Discover Gemini sessions from ~/.gemini/tmp/<project_hash>/chats/."""
33
+ sessions: list[Session] = []
34
+
35
+ if not _GEMINI_TMP_BASE.exists():
36
+ return sessions
37
+
38
+ # Scan all project hash directories
39
+ for project_dir in _GEMINI_TMP_BASE.iterdir():
40
+ if not project_dir.is_dir():
41
+ continue
42
+
43
+ chats_dir = project_dir / "chats"
44
+ if not chats_dir.exists():
45
+ continue
46
+
47
+ # Scan for session JSON files in format: session-<timestamp>-<hash>.json
48
+ for session_file in sorted(chats_dir.glob("session-*.json")):
49
+ try:
50
+ with session_file.open() as f:
51
+ data = json.load(f)
52
+ except (json.JSONDecodeError, OSError):
53
+ continue
54
+
55
+ session_id = data.get("sessionId")
56
+ if not session_id:
57
+ continue
58
+
59
+ # Extract timestamps
60
+ start_time_str = data.get("startTime")
61
+ last_updated_str = data.get("lastUpdated")
62
+
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
98
+
99
+ sessions.append(
100
+ Session(
101
+ id=session_id,
102
+ tool=self.tool_name,
103
+ title=title,
104
+ cwd=None,
105
+ created_at=created_at,
106
+ updated_at=updated_at,
107
+ raw_path=str(session_file),
108
+ status="unknown",
109
+ )
110
+ )
111
+
112
+ return sessions
113
+
114
+ def load_messages(self, session_id: str) -> list[Message]:
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)
137
+
138
+ return []
139
+
140
+ def _parse_session_data(self, data: dict) -> list[Message]:
141
+ """Parse Gemini session JSON object into Message list."""
142
+ messages: list[Message] = []
143
+
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
+ )
181
+
182
+ return messages
183
+
184
+ def build_inject_command(
185
+ self,
186
+ context: str,
187
+ target_session_id: str | None = None,
188
+ cwd: str | None = None,
189
+ method: str = "system_prompt",
190
+ ) -> str:
191
+ """Build a shell command to start/resume a Gemini session with context."""
192
+ safe_ctx = shlex.quote(context)
193
+
194
+ if method == "resume" and target_session_id:
195
+ return f"gemini /resume {target_session_id}"
196
+
197
+ # Default: new session with context injected via system prompt
198
+ cmd = f"gemini {safe_ctx}"
199
+ if cwd:
200
+ cmd = f"cd {shlex.quote(cwd)} && {cmd}"
201
+ return cmd
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "context-forge-cli"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Session manager and context bridge for agentic CLI tools"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -151,8 +151,8 @@ wheels = [
151
151
  ]
152
152
 
153
153
  [[package]]
154
- name = "context-forge"
155
- version = "0.1.0"
154
+ name = "context-forge-cli"
155
+ version = "0.2.1"
156
156
  source = { editable = "." }
157
157
  dependencies = [
158
158
  { name = "anthropic" },
@@ -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