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,393 @@
1
+ """Adapter for Claude Code (claude CLI) sessions."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import shlex
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ import tiktoken
11
+
12
+ from contextforge.adapters.base import ToolAdapter
13
+ from contextforge.models.session import Message, Session
14
+
15
+ _HISTORY_PATH = Path.home() / ".claude" / "history.jsonl"
16
+ _PROJECTS_DIR = Path.home() / ".claude" / "projects"
17
+ _DESKTOP_SESSIONS_DIR = (
18
+ Path.home() / "Library" / "Application Support" / "Claude" / "claude-code-sessions"
19
+ )
20
+
21
+
22
+ def _decode_path(encoded: str) -> str:
23
+ """Convert Claude Code encoded project path back to a real path.
24
+
25
+ Claude Code encodes the project directory as the path with '/' replaced by '-',
26
+ with a leading '-' for the root '/'. e.g. '-Users-alice-myproject'.
27
+ """
28
+ if encoded.startswith("-"):
29
+ return "/" + encoded[1:].replace("-", "/")
30
+ return encoded.replace("-", "/")
31
+
32
+
33
+ def _parse_content(content) -> str:
34
+ """Extract text from a content field that may be a string or list of blocks."""
35
+ if isinstance(content, str):
36
+ return content
37
+ if isinstance(content, list):
38
+ parts = []
39
+ for block in content:
40
+ if isinstance(block, dict):
41
+ if block.get("type") == "text":
42
+ parts.append(block.get("text", ""))
43
+ elif block.get("type") == "tool_use":
44
+ parts.append(f"[tool: {block.get('name', '?')}]")
45
+ elif block.get("type") == "tool_result":
46
+ inner = block.get("content", "")
47
+ if isinstance(inner, list):
48
+ for item in inner:
49
+ if isinstance(item, dict) and item.get("type") == "text":
50
+ parts.append(item.get("text", ""))
51
+ else:
52
+ parts.append(str(inner))
53
+ return "\n".join(p for p in parts if p)
54
+ return str(content)
55
+
56
+
57
+ def _count_tokens(text: str) -> int:
58
+ """Count tokens in text using tiktoken for Claude models."""
59
+ try:
60
+ enc = tiktoken.encoding_for_model("claude-3-5-sonnet-20241022")
61
+ return len(enc.encode(text))
62
+ except Exception:
63
+ # Fallback: rough estimate (~4 chars per token)
64
+ return len(text) // 4
65
+
66
+
67
+ class ClaudeCodeAdapter(ToolAdapter):
68
+ tool_name = "claude_code"
69
+ default_paths = [_HISTORY_PATH, _PROJECTS_DIR]
70
+
71
+ def discover_sessions(self) -> list[Session]:
72
+ """Discover sessions from both history.jsonl and projects directory.
73
+
74
+ history.jsonl only contains CLI-initiated sessions (not Desktop App sessions),
75
+ so we always scan the projects directory and use history.jsonl for metadata
76
+ enrichment only.
77
+ """
78
+ # Build metadata map from history.jsonl (display name, timestamp, cwd)
79
+ history_meta: dict[str, dict] = {}
80
+ if _HISTORY_PATH.exists():
81
+ history_meta = self._load_history_meta()
82
+
83
+ # Always scan the full projects dir as the source of truth
84
+ sessions: list[Session] = []
85
+ if _PROJECTS_DIR.exists():
86
+ sessions = self._scan_projects_dir_with_meta(history_meta)
87
+ elif history_meta:
88
+ # No projects dir — fall back to history-only (edge case)
89
+ sessions = self._from_history()
90
+
91
+ return sessions
92
+
93
+ def _load_history_meta(self) -> dict[str, dict]:
94
+ """Return {session_id: entry} from history.jsonl + Desktop App JSON files."""
95
+ meta: dict[str, dict] = {}
96
+
97
+ # CLI history
98
+ if _HISTORY_PATH.exists():
99
+ with _HISTORY_PATH.open() as f:
100
+ for line in f:
101
+ line = line.strip()
102
+ if not line:
103
+ continue
104
+ try:
105
+ entry = json.loads(line)
106
+ except json.JSONDecodeError:
107
+ continue
108
+ sid = entry.get("sessionId") or entry.get("session_id")
109
+ if sid and sid not in meta:
110
+ meta[sid] = entry
111
+
112
+ # Desktop App session JSON files (macOS only)
113
+ # Structure: <_DESKTOP_SESSIONS_DIR>/<profile-uuid>/<session-uuid>/local_<cli-id>.json
114
+ if _DESKTOP_SESSIONS_DIR.exists():
115
+ for profile_dir in _DESKTOP_SESSIONS_DIR.iterdir():
116
+ if not profile_dir.is_dir():
117
+ continue
118
+ for session_dir in profile_dir.iterdir():
119
+ if not session_dir.is_dir():
120
+ continue
121
+ for json_file in session_dir.glob("local_*.json"):
122
+ try:
123
+ with json_file.open() as f:
124
+ entry = json.load(f)
125
+ except (json.JSONDecodeError, OSError):
126
+ continue
127
+ cli_id = entry.get("cliSessionId")
128
+ if cli_id and cli_id not in meta:
129
+ # Normalize to the same shape history.jsonl uses
130
+ ts_ms = entry.get("createdAt") or entry.get("lastActivityAt")
131
+ ts_str = None
132
+ if ts_ms:
133
+ try:
134
+ ts_str = datetime.fromtimestamp(
135
+ ts_ms / 1000, tz=timezone.utc
136
+ ).isoformat()
137
+ except (TypeError, ValueError):
138
+ pass
139
+ meta[cli_id] = {
140
+ "sessionId": cli_id,
141
+ "display": entry.get("title"),
142
+ "project": entry.get("cwd") or entry.get("originCwd"),
143
+ "timestamp": ts_str,
144
+ "_source": "desktop",
145
+ }
146
+
147
+ return meta
148
+
149
+ def _scan_projects_dir_with_meta(self, history_meta: dict[str, dict]) -> list[Session]:
150
+ """Scan all top-level JSONL files in projects dir, enriching with history metadata."""
151
+ sessions: list[Session] = []
152
+ for project_dir in sorted(_PROJECTS_DIR.iterdir()):
153
+ if not project_dir.is_dir():
154
+ continue
155
+ cwd = _decode_path(project_dir.name)
156
+ for jsonl_file in sorted(project_dir.glob("*.jsonl")):
157
+ session_id = jsonl_file.stem
158
+ meta = history_meta.get(session_id, {})
159
+
160
+ # Prefer history metadata for title/cwd/timestamp; fall back to file mtime
161
+ display = meta.get("display") or meta.get("title") or None
162
+ project = meta.get("project") or meta.get("cwd") or cwd
163
+
164
+ ts_str = meta.get("timestamp")
165
+ if ts_str:
166
+ try:
167
+ dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
168
+ except (ValueError, AttributeError):
169
+ dt = None
170
+ else:
171
+ dt = None
172
+
173
+ if dt is None:
174
+ mtime = jsonl_file.stat().st_mtime
175
+ dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
176
+
177
+ # Calculate token count from session content
178
+ token_count = self._count_session_tokens(jsonl_file)
179
+
180
+ sessions.append(
181
+ Session(
182
+ id=session_id,
183
+ tool=self.tool_name,
184
+ title=display,
185
+ cwd=project or None,
186
+ created_at=dt,
187
+ updated_at=dt,
188
+ raw_path=str(jsonl_file),
189
+ status="unknown",
190
+ token_count=token_count,
191
+ )
192
+ )
193
+ return sessions
194
+
195
+ def _from_history(self) -> list[Session]:
196
+ sessions: list[Session] = []
197
+ seen: set[str] = set()
198
+
199
+ with _HISTORY_PATH.open() as f:
200
+ for line in f:
201
+ line = line.strip()
202
+ if not line:
203
+ continue
204
+ try:
205
+ entry = json.loads(line)
206
+ except json.JSONDecodeError:
207
+ continue
208
+
209
+ session_id = entry.get("sessionId") or entry.get("session_id")
210
+ if not session_id or session_id in seen:
211
+ continue
212
+ seen.add(session_id)
213
+
214
+ display = entry.get("display") or entry.get("title") or ""
215
+ project = entry.get("project") or entry.get("cwd") or ""
216
+ ts = entry.get("timestamp")
217
+
218
+ if ts:
219
+ try:
220
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
221
+ except (ValueError, AttributeError):
222
+ dt = datetime.now(timezone.utc)
223
+ else:
224
+ dt = datetime.now(timezone.utc)
225
+
226
+ # Find the raw JSONL path for this session
227
+ raw_path = self._find_session_path(project, session_id)
228
+
229
+ sessions.append(
230
+ Session(
231
+ id=session_id,
232
+ tool=self.tool_name,
233
+ title=display or None,
234
+ cwd=project or None,
235
+ created_at=dt,
236
+ updated_at=dt,
237
+ raw_path=str(raw_path) if raw_path else None,
238
+ status="unknown",
239
+ )
240
+ )
241
+
242
+ return sessions
243
+
244
+ def _find_session_path(self, cwd: str, session_id: str) -> Path | None:
245
+ if not _PROJECTS_DIR.exists():
246
+ return None
247
+ # Try to match encoded dir by cwd
248
+ for project_dir in _PROJECTS_DIR.iterdir():
249
+ if not project_dir.is_dir():
250
+ continue
251
+ candidate = project_dir / f"{session_id}.jsonl"
252
+ if candidate.exists():
253
+ return candidate
254
+ return None
255
+
256
+ def _scan_projects_dir(self) -> list[Session]:
257
+ sessions: list[Session] = []
258
+ for project_dir in _PROJECTS_DIR.iterdir():
259
+ if not project_dir.is_dir():
260
+ continue
261
+ cwd = _decode_path(project_dir.name)
262
+ for jsonl_file in project_dir.glob("*.jsonl"):
263
+ session_id = jsonl_file.stem
264
+ mtime = jsonl_file.stat().st_mtime
265
+ dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
266
+ sessions.append(
267
+ Session(
268
+ id=session_id,
269
+ tool=self.tool_name,
270
+ title=None,
271
+ cwd=cwd,
272
+ created_at=dt,
273
+ updated_at=dt,
274
+ raw_path=str(jsonl_file),
275
+ status="unknown",
276
+ )
277
+ )
278
+ return sessions
279
+
280
+ def load_messages(self, session_id: str) -> list[Message]:
281
+ path = self._find_session_path("", session_id)
282
+ if path is None:
283
+ # Brute-force search
284
+ for project_dir in _PROJECTS_DIR.iterdir():
285
+ candidate = project_dir / f"{session_id}.jsonl"
286
+ if candidate.exists():
287
+ path = candidate
288
+ break
289
+
290
+ if path is None or not path.exists():
291
+ return []
292
+
293
+ messages: list[Message] = []
294
+ with path.open() as f:
295
+ for line in f:
296
+ line = line.strip()
297
+ if not line:
298
+ continue
299
+ try:
300
+ entry = json.loads(line)
301
+ except json.JSONDecodeError:
302
+ continue
303
+
304
+ entry_type = entry.get("type", "")
305
+ if entry_type not in ("user", "assistant"):
306
+ continue
307
+
308
+ msg_data = entry.get("message", {})
309
+ role = msg_data.get("role", entry_type)
310
+ content_raw = msg_data.get("content", "")
311
+
312
+ # Skip entries where content is an array of tool results/tool use
313
+ # (these are Claude's internal messages, not user input or assistant responses)
314
+ if isinstance(content_raw, list):
315
+ # Only include arrays that contain text blocks (actual assistant content)
316
+ if not any(block.get("type") == "text" for block in content_raw if isinstance(block, dict)):
317
+ continue
318
+
319
+ content = _parse_content(content_raw)
320
+ if not content:
321
+ continue
322
+
323
+ ts_str = entry.get("timestamp")
324
+ ts = None
325
+ if ts_str:
326
+ try:
327
+ ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
328
+ except (ValueError, AttributeError):
329
+ pass
330
+
331
+ messages.append(
332
+ Message(
333
+ role="user" if role == "user" else "assistant",
334
+ content=content,
335
+ timestamp=ts,
336
+ )
337
+ )
338
+
339
+ return messages
340
+
341
+ def _count_session_tokens(self, jsonl_path: Path) -> int:
342
+ """Count total tokens in a session JSONL file."""
343
+ 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
372
+ return total
373
+
374
+ def build_inject_command(
375
+ self,
376
+ context: str,
377
+ target_session_id: str | None = None,
378
+ cwd: str | None = None,
379
+ method: str = "system_prompt",
380
+ ) -> str:
381
+ safe_ctx = shlex.quote(context)
382
+ if method == "resume" and target_session_id:
383
+ return f"claude --resume {target_session_id} --append-system-prompt {safe_ctx}"
384
+ if method == "new_with_prompt":
385
+ cmd = f"claude -p {safe_ctx}"
386
+ if cwd:
387
+ cmd = f"cd {shlex.quote(cwd)} && {cmd}"
388
+ return cmd
389
+ # Default: system_prompt for a new session
390
+ cmd = f"claude --system-prompt {safe_ctx}"
391
+ if cwd:
392
+ cmd = f"cd {shlex.quote(cwd)} && {cmd}"
393
+ return cmd
@@ -0,0 +1,306 @@
1
+ """Adapter for Claude Desktop App local-agent-mode sessions.
2
+
3
+ These sessions are created by the Desktop App's built-in agent mode. Each session
4
+ is stored under:
5
+ ~/Library/Application Support/Claude/local-agent-mode-sessions/
6
+ <profile-uuid>/<workspace-uuid>/local_<desktop-session-id>/
7
+
8
+ Inside each session directory:
9
+ - A sibling JSON file (local_<id>.json) holds metadata (title, cwd, cliSessionId)
10
+ - An embedded .claude/projects/<encoded-cwd>/<cliSessionId>.jsonl holds the full
11
+ conversation in the same JSONL format as regular Claude Code CLI sessions
12
+ - An audit.jsonl holds a sparse high-level audit log (used as fallback only)
13
+
14
+ We use cliSessionId as the canonical session ID so the JSONL path matches.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import shlex
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+
23
+ import tiktoken
24
+
25
+ from contextforge.adapters.base import ToolAdapter
26
+ from contextforge.models.session import Message, Session
27
+
28
+ _AGENT_SESSIONS_DIR = (
29
+ Path.home()
30
+ / "Library"
31
+ / "Application Support"
32
+ / "Claude"
33
+ / "local-agent-mode-sessions"
34
+ )
35
+
36
+
37
+ def _parse_content(content) -> str:
38
+ if isinstance(content, str):
39
+ return content
40
+ if isinstance(content, list):
41
+ parts = []
42
+ for block in content:
43
+ if isinstance(block, dict):
44
+ if block.get("type") == "text":
45
+ parts.append(block.get("text", ""))
46
+ elif block.get("type") == "tool_use":
47
+ parts.append(f"[tool: {block.get('name', '?')}]")
48
+ elif block.get("type") == "tool_result":
49
+ inner = block.get("content", "")
50
+ if isinstance(inner, list):
51
+ for item in inner:
52
+ if isinstance(item, dict) and item.get("type") == "text":
53
+ parts.append(item.get("text", ""))
54
+ else:
55
+ parts.append(str(inner))
56
+ return "\n".join(p for p in parts if p)
57
+ return str(content)
58
+
59
+
60
+ def _count_tokens(text: str) -> int:
61
+ """Count tokens in text using tiktoken for Claude models."""
62
+ try:
63
+ enc = tiktoken.encoding_for_model("claude-3-5-sonnet-20241022")
64
+ return len(enc.encode(text))
65
+ except Exception:
66
+ # Fallback: rough estimate (~4 chars per token)
67
+ return len(text) // 4
68
+
69
+
70
+ class ClaudeDesktopAdapter(ToolAdapter):
71
+ """Adapter for Claude Desktop App local agent-mode sessions."""
72
+
73
+ tool_name = "claude_desktop"
74
+ default_paths = [_AGENT_SESSIONS_DIR]
75
+
76
+ def discover_sessions(self) -> list[Session]:
77
+ if not _AGENT_SESSIONS_DIR.exists():
78
+ return []
79
+
80
+ sessions: list[Session] = []
81
+ for profile_dir in sorted(_AGENT_SESSIONS_DIR.iterdir()):
82
+ if not profile_dir.is_dir():
83
+ continue
84
+ for workspace_dir in sorted(profile_dir.iterdir()):
85
+ if not workspace_dir.is_dir():
86
+ continue
87
+ for session_dir in sorted(workspace_dir.iterdir()):
88
+ if not session_dir.is_dir():
89
+ continue
90
+
91
+ dir_name = session_dir.name
92
+ meta_file = workspace_dir / f"{dir_name}.json"
93
+ if not meta_file.exists():
94
+ continue
95
+
96
+ try:
97
+ with meta_file.open() as f:
98
+ meta = json.load(f)
99
+ except (json.JSONDecodeError, OSError):
100
+ continue
101
+
102
+ # Use cliSessionId as the canonical ID — it matches the embedded JSONL
103
+ cli_id = meta.get("cliSessionId")
104
+ if not cli_id:
105
+ continue
106
+
107
+ title = meta.get("title")
108
+ raw_cwd = meta.get("cwd") or meta.get("originCwd")
109
+ cwd = raw_cwd if raw_cwd and not raw_cwd.startswith("/sessions/") else None
110
+
111
+ ts_ms = meta.get("createdAt") or meta.get("lastActivityAt")
112
+ dt: datetime | None = None
113
+ if ts_ms:
114
+ try:
115
+ dt = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc)
116
+ except (TypeError, ValueError):
117
+ pass
118
+
119
+ # Find the embedded JSONL (preferred) or fall back to audit.jsonl
120
+ raw_path = self._find_embedded_jsonl(session_dir, cli_id)
121
+ if raw_path is None:
122
+ audit = session_dir / "audit.jsonl"
123
+ raw_path = audit if audit.exists() else None
124
+
125
+ if raw_path is None:
126
+ continue
127
+
128
+ if dt is None:
129
+ dt = datetime.fromtimestamp(raw_path.stat().st_mtime, tz=timezone.utc)
130
+
131
+ # Calculate token count from session content
132
+ token_count = self._count_session_tokens(cli_id)
133
+
134
+ sessions.append(
135
+ Session(
136
+ id=cli_id,
137
+ tool=self.tool_name,
138
+ title=title,
139
+ cwd=cwd,
140
+ created_at=dt,
141
+ updated_at=dt,
142
+ raw_path=str(raw_path),
143
+ status="unknown",
144
+ token_count=token_count,
145
+ )
146
+ )
147
+ return sessions
148
+
149
+ def _find_embedded_jsonl(self, session_dir: Path, cli_id: str) -> Path | None:
150
+ """Find the embedded .claude/projects/*/<cli_id>.jsonl inside a session dir."""
151
+ projects_dir = session_dir / ".claude" / "projects"
152
+ if not projects_dir.exists():
153
+ return None
154
+ for project_dir in projects_dir.iterdir():
155
+ candidate = project_dir / f"{cli_id}.jsonl"
156
+ if candidate.exists():
157
+ return candidate
158
+ return None
159
+
160
+ def load_messages(self, session_id: str) -> list[Message]:
161
+ # Locate the session directory and its raw_path
162
+ raw_path = self._find_raw_path(session_id)
163
+ if raw_path is None or not raw_path.exists():
164
+ return []
165
+
166
+ # Embedded JSONL uses the same format as Claude Code CLI sessions
167
+ if "audit.jsonl" not in raw_path.name:
168
+ return self._parse_cli_jsonl(raw_path)
169
+
170
+ # Fallback: parse audit.jsonl
171
+ return self._parse_audit_jsonl(raw_path)
172
+
173
+ def _find_raw_path(self, session_id: str) -> Path | None:
174
+ """Find the raw_path for a session given its cliSessionId."""
175
+ if not _AGENT_SESSIONS_DIR.exists():
176
+ return None
177
+ for profile_dir in _AGENT_SESSIONS_DIR.iterdir():
178
+ if not profile_dir.is_dir():
179
+ continue
180
+ for workspace_dir in profile_dir.iterdir():
181
+ if not workspace_dir.is_dir():
182
+ continue
183
+ for session_dir in workspace_dir.iterdir():
184
+ if not session_dir.is_dir():
185
+ continue
186
+ meta_file = workspace_dir / f"{session_dir.name}.json"
187
+ if not meta_file.exists():
188
+ continue
189
+ try:
190
+ with meta_file.open() as f:
191
+ meta = json.load(f)
192
+ except (json.JSONDecodeError, OSError):
193
+ continue
194
+ if meta.get("cliSessionId") == session_id:
195
+ embedded = self._find_embedded_jsonl(session_dir, session_id)
196
+ if embedded:
197
+ return embedded
198
+ audit = session_dir / "audit.jsonl"
199
+ return audit if audit.exists() else None
200
+ return None
201
+
202
+ def _parse_cli_jsonl(self, path: Path) -> list[Message]:
203
+ """Parse the embedded JSONL using the same format as Claude Code CLI sessions."""
204
+ messages: list[Message] = []
205
+ with path.open() as f:
206
+ for line in f:
207
+ line = line.strip()
208
+ if not line:
209
+ continue
210
+ try:
211
+ entry = json.loads(line)
212
+ except json.JSONDecodeError:
213
+ continue
214
+
215
+ entry_type = entry.get("type", "")
216
+ if entry_type not in ("user", "assistant"):
217
+ continue
218
+
219
+ msg_data = entry.get("message", {})
220
+ role = msg_data.get("role", entry_type)
221
+ content_raw = msg_data.get("content", "")
222
+
223
+ # Skip entries where content is an array of tool results/tool use
224
+ # (these are Claude's internal messages, not user input or assistant responses)
225
+ if isinstance(content_raw, list):
226
+ # Only include arrays that contain text blocks (actual assistant content)
227
+ if not any(block.get("type") == "text" for block in content_raw if isinstance(block, dict)):
228
+ continue
229
+
230
+ content = _parse_content(content_raw)
231
+ if not content:
232
+ continue
233
+
234
+ ts: datetime | None = None
235
+ ts_str = entry.get("timestamp")
236
+ if ts_str:
237
+ try:
238
+ ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
239
+ except (ValueError, AttributeError):
240
+ pass
241
+
242
+ messages.append(
243
+ Message(role="user" if role == "user" else "assistant",
244
+ content=content, timestamp=ts)
245
+ )
246
+ return messages
247
+
248
+ def _parse_audit_jsonl(self, path: Path) -> list[Message]:
249
+ """Fallback: parse the sparse audit.jsonl format."""
250
+ messages: list[Message] = []
251
+ with path.open() as f:
252
+ for line in f:
253
+ line = line.strip()
254
+ if not line:
255
+ continue
256
+ try:
257
+ entry = json.loads(line)
258
+ except json.JSONDecodeError:
259
+ continue
260
+
261
+ entry_type = entry.get("type", "")
262
+ if entry_type not in ("user", "assistant"):
263
+ continue
264
+
265
+ msg = entry.get("message", {})
266
+ role = msg.get("role", entry_type)
267
+ if role not in ("user", "assistant"):
268
+ continue
269
+
270
+ content = _parse_content(msg.get("content", ""))
271
+ if not content:
272
+ continue
273
+
274
+ ts: datetime | None = None
275
+ ts_str = entry.get("_audit_timestamp")
276
+ if ts_str:
277
+ try:
278
+ ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
279
+ except (ValueError, AttributeError):
280
+ pass
281
+
282
+ messages.append(Message(role=role, content=content, timestamp=ts))
283
+ return messages
284
+
285
+ def _count_session_tokens(self, session_id: str) -> int:
286
+ """Count total tokens in a session."""
287
+ messages = self.load_messages(session_id)
288
+ total = 0
289
+ for msg in messages:
290
+ total += _count_tokens(msg.content)
291
+ return total
292
+
293
+ def build_inject_command(
294
+ self,
295
+ context: str,
296
+ target_session_id: str | None = None,
297
+ cwd: str | None = None,
298
+ method: str = "system_prompt",
299
+ ) -> str:
300
+ safe_ctx = shlex.quote(context)
301
+ if method == "resume" and target_session_id:
302
+ return f"claude --resume {target_session_id} --append-system-prompt {safe_ctx}"
303
+ cmd = f"claude --system-prompt {safe_ctx}"
304
+ if cwd:
305
+ cmd = f"cd {shlex.quote(cwd)} && {cmd}"
306
+ return cmd