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.
- context_forge_cli-0.1.0.dist-info/METADATA +340 -0
- context_forge_cli-0.1.0.dist-info/RECORD +37 -0
- context_forge_cli-0.1.0.dist-info/WHEEL +4 -0
- context_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- contextforge/__init__.py +3 -0
- contextforge/adapters/__init__.py +3 -0
- contextforge/adapters/altimate_code.py +179 -0
- contextforge/adapters/base.py +44 -0
- contextforge/adapters/claude_code.py +393 -0
- contextforge/adapters/claude_desktop.py +306 -0
- contextforge/adapters/codex.py +220 -0
- contextforge/adapters/registry.py +31 -0
- contextforge/cli.py +523 -0
- contextforge/core/__init__.py +0 -0
- contextforge/core/analytics.py +226 -0
- contextforge/core/compactor.py +182 -0
- contextforge/core/db.py +181 -0
- contextforge/core/injector.py +94 -0
- contextforge/core/scanner.py +72 -0
- contextforge/core/summarizer.py +155 -0
- contextforge/core/token_analyzer.py +91 -0
- contextforge/models/__init__.py +4 -0
- contextforge/models/config.py +34 -0
- contextforge/models/session.py +37 -0
- contextforge/tui/__init__.py +0 -0
- contextforge/tui/app.py +204 -0
- contextforge/tui/styles.tcss +50 -0
- contextforge/tui/widgets/__init__.py +0 -0
- contextforge/tui/widgets/session_detail.py +191 -0
- contextforge/tui/widgets/session_table.py +277 -0
- contextforge/tui/widgets/stats_panel.py +340 -0
- contextforge/tui/widgets/status_bar.py +102 -0
- contextforge/tui/widgets/tokens_panel.py +134 -0
- contextforge/tui/widgets/transfer_panel.py +119 -0
- contextforge/utils/__init__.py +0 -0
- contextforge/utils/display.py +105 -0
- 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
|