claudexer 0.4.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.
- claudexer/__init__.py +3 -0
- claudexer/__main__.py +3 -0
- claudexer/claude.py +125 -0
- claudexer/cli.py +155 -0
- claudexer/controls.js +15 -0
- claudexer/discovery.py +179 -0
- claudexer/privacy.py +136 -0
- claudexer/render.py +243 -0
- claudexer/session.py +332 -0
- claudexer/style.css +38 -0
- claudexer/tui.py +246 -0
- claudexer-0.4.0.dist-info/METADATA +272 -0
- claudexer-0.4.0.dist-info/RECORD +17 -0
- claudexer-0.4.0.dist-info/WHEEL +5 -0
- claudexer-0.4.0.dist-info/entry_points.txt +2 -0
- claudexer-0.4.0.dist-info/licenses/LICENSE +21 -0
- claudexer-0.4.0.dist-info/top_level.txt +1 -0
claudexer/__init__.py
ADDED
claudexer/__main__.py
ADDED
claudexer/claude.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Normalize Claude Code's local JSONL records into the shared transcript."""
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .session import Entry, Session, SessionError, fallback_title, iter_records, pretty
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parts(content) -> tuple[str, list[str]]:
|
|
10
|
+
if isinstance(content, str):
|
|
11
|
+
return content, []
|
|
12
|
+
text, images = [], []
|
|
13
|
+
for block in content if isinstance(content, list) else []:
|
|
14
|
+
if not isinstance(block, dict):
|
|
15
|
+
continue
|
|
16
|
+
kind = block.get("type")
|
|
17
|
+
if kind == "text":
|
|
18
|
+
text.append(str(block.get("text", "")))
|
|
19
|
+
elif kind == "image":
|
|
20
|
+
source = block.get("source", {})
|
|
21
|
+
if isinstance(source, dict):
|
|
22
|
+
images.append(f"data:{source.get('media_type', '')};base64,{source.get('data', '')}" if source.get("type") == "base64" else str(source.get("url", "")))
|
|
23
|
+
elif kind not in {"thinking", "redacted_thinking", "tool_use", "tool_result"}:
|
|
24
|
+
text.append(f"[Unsupported content: {kind or 'unknown'}]")
|
|
25
|
+
return "\n\n".join(text), images
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_claude_session(path: Path) -> Session:
|
|
29
|
+
session = Session(source=path, id=path.stem, provider="claude")
|
|
30
|
+
seen, calls, results = set(), {}, {}
|
|
31
|
+
ignored = Counter()
|
|
32
|
+
metadata = {"attachment", "file-history-snapshot", "file-history-delta", "queue-operation", "mode", "permission-mode", "bridge-session", "agent-name", "last-prompt", "atis-latch", "pr-link", "cost-state", "progress"}
|
|
33
|
+
count, custom_title = 0, ""
|
|
34
|
+
for position, record in enumerate(iter_records(path, session.warnings, require_payload=False)):
|
|
35
|
+
if "payload" in record:
|
|
36
|
+
raise SessionError("This file contains Codex records. Omit --source claude to auto-detect its format.")
|
|
37
|
+
count += 1
|
|
38
|
+
kind = record.get("type")
|
|
39
|
+
session.id = str(record.get("sessionId") or session.id)
|
|
40
|
+
session.cwd = str(record.get("cwd") or session.cwd)
|
|
41
|
+
timestamp = str(record.get("timestamp") or "")
|
|
42
|
+
if timestamp and not session.timestamp:
|
|
43
|
+
session.timestamp = timestamp
|
|
44
|
+
if kind in {"ai-title", "custom-title", "summary"}:
|
|
45
|
+
title = record.get("customTitle") or record.get("aiTitle") or record.get("summary")
|
|
46
|
+
if title:
|
|
47
|
+
session.title = str(title)
|
|
48
|
+
if kind == "custom-title":
|
|
49
|
+
custom_title = str(title)
|
|
50
|
+
continue
|
|
51
|
+
identity = str(record.get("uuid") or "")
|
|
52
|
+
if identity:
|
|
53
|
+
if identity in seen:
|
|
54
|
+
continue
|
|
55
|
+
seen.add(identity)
|
|
56
|
+
common = dict(timestamp=timestamp, position=position, id=identity)
|
|
57
|
+
if kind == "system":
|
|
58
|
+
if record.get("subtype") == "compact_boundary":
|
|
59
|
+
session.entries.append(Entry(kind="marker", text="Context compacted", **common))
|
|
60
|
+
continue
|
|
61
|
+
if kind == "continued-in":
|
|
62
|
+
session.entries.append(Entry(kind="marker", text=f"Conversation continued in session {record.get('continuedInSessionId', 'unknown')} (separate export).", **common))
|
|
63
|
+
continue
|
|
64
|
+
if kind in metadata:
|
|
65
|
+
continue
|
|
66
|
+
if kind not in {"user", "assistant"}:
|
|
67
|
+
ignored[str(kind)] += 1
|
|
68
|
+
continue
|
|
69
|
+
message = record.get("message")
|
|
70
|
+
if not isinstance(message, dict):
|
|
71
|
+
session.warnings.append(f"Skipped Claude record {position + 1}: message must be an object.")
|
|
72
|
+
continue
|
|
73
|
+
if record.get("isMeta") or record.get("isCompactSummary"):
|
|
74
|
+
continue
|
|
75
|
+
content = message.get("content", [])
|
|
76
|
+
blocks = content if isinstance(content, list) else [{"type": "text", "text": str(content)}]
|
|
77
|
+
# A Claude message ID may span multiple records/blocks. Only record UUIDs
|
|
78
|
+
# identify replayed records; deduplicating message IDs loses content.
|
|
79
|
+
phase = "final_answer" if message.get("stop_reason") in {"end_turn", "stop_sequence"} else "commentary"
|
|
80
|
+
for block in blocks:
|
|
81
|
+
if not isinstance(block, dict):
|
|
82
|
+
continue
|
|
83
|
+
block_type = block.get("type")
|
|
84
|
+
if block_type in {"thinking", "redacted_thinking"}:
|
|
85
|
+
continue
|
|
86
|
+
if block_type == "tool_use":
|
|
87
|
+
call_id = str(block.get("id") or "")
|
|
88
|
+
entry = Entry(kind="tool", name=str(block.get("name") or "Tool"), text=pretty(block.get("input", {})), call_id=call_id, **common)
|
|
89
|
+
if call_id and call_id in calls:
|
|
90
|
+
continue
|
|
91
|
+
if call_id:
|
|
92
|
+
calls[call_id] = entry
|
|
93
|
+
session.entries.append(entry)
|
|
94
|
+
elif block_type == "tool_result":
|
|
95
|
+
text, images = parts(block.get("content", ""))
|
|
96
|
+
if block.get("is_error"):
|
|
97
|
+
text = "Tool error\n" + text
|
|
98
|
+
entry = Entry(kind="tool", name="Unmatched tool result", result=text, images=images, call_id=str(block.get("tool_use_id") or ""), **common)
|
|
99
|
+
if entry.call_id:
|
|
100
|
+
results[entry.call_id] = entry
|
|
101
|
+
else:
|
|
102
|
+
session.entries.append(entry)
|
|
103
|
+
else:
|
|
104
|
+
text, images = parts([block])
|
|
105
|
+
if text or images:
|
|
106
|
+
# Adjacent content blocks in the same record form one message.
|
|
107
|
+
previous = session.entries[-1] if session.entries else None
|
|
108
|
+
if previous and previous.kind == kind and previous.position == position:
|
|
109
|
+
previous.text = "\n\n".join(filter(None, (previous.text, text)))
|
|
110
|
+
previous.images.extend(images)
|
|
111
|
+
else:
|
|
112
|
+
session.entries.append(Entry(kind=kind, text=text, images=images, phase=phase if kind == "assistant" else "", **common))
|
|
113
|
+
if not count:
|
|
114
|
+
raise SessionError("No valid Claude Code session records found.")
|
|
115
|
+
for call_id, result in results.items():
|
|
116
|
+
if call_id in calls:
|
|
117
|
+
calls[call_id].result = result.result
|
|
118
|
+
calls[call_id].images.extend(result.images)
|
|
119
|
+
else:
|
|
120
|
+
session.entries.append(result)
|
|
121
|
+
session.entries.sort(key=lambda e: e.position)
|
|
122
|
+
session.title = custom_title or session.title or fallback_title(session)
|
|
123
|
+
for kind, number in sorted(ignored.items()):
|
|
124
|
+
session.warnings.append(f"Skipped {number} unsupported Claude {kind} record(s).")
|
|
125
|
+
return session
|
claudexer/cli.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Command-line interface for Claudexer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import unicodedata
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .discovery import claude_home, codex_home, discover_all, pick, read_index, terminal_text
|
|
15
|
+
from .render import render_html, render_markdown
|
|
16
|
+
from .session import Session, SessionError, read_session
|
|
17
|
+
from .privacy import prepare_export
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parser() -> argparse.ArgumentParser:
|
|
21
|
+
result = argparse.ArgumentParser(description="Export a local Codex or Claude Code session to offline HTML or Markdown. Omit SESSION_FILE to browse local sessions.")
|
|
22
|
+
result.add_argument("session_file", nargs="?", type=Path, metavar="SESSION_FILE")
|
|
23
|
+
result.add_argument("--format", choices=("html", "md", "both"), default="html", help="output format (default: html)")
|
|
24
|
+
result.add_argument("-o", "--output", type=Path, help="output file; for --format both, an output directory")
|
|
25
|
+
result.add_argument("--full", action="store_true", help="include identifying metadata, original messages, tools, and images (default: sanitized conversation)")
|
|
26
|
+
result.add_argument("--redact", action="append", default=[], metavar="TEXT", help="also redact this name or literal text in sanitized mode; repeatable")
|
|
27
|
+
result.add_argument("--no-tools", action="store_true", help="omit tool calls and results even with --full")
|
|
28
|
+
result.add_argument("--codex-home", type=Path, metavar="DIRECTORY", help="override CODEX_HOME (default: ~/.codex)")
|
|
29
|
+
result.add_argument("--claude-home", type=Path, metavar="DIRECTORY", help="override CLAUDE_CONFIG_DIR (default: ~/.claude)")
|
|
30
|
+
result.add_argument("--source", choices=("all", "codex", "claude"), default="all", help="filter picker or specify input format (default: all / auto-detect)")
|
|
31
|
+
result.add_argument("--plain-picker", action="store_true", help="use the original numbered picker instead of Textual")
|
|
32
|
+
result.add_argument("--include-archived", action="store_true", help="include archived sessions in the picker")
|
|
33
|
+
result.add_argument("--force", action="store_true", help="replace existing output files")
|
|
34
|
+
result.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def slug(text: str, limit: int) -> str:
|
|
39
|
+
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode().lower()
|
|
40
|
+
return re.sub(r"[^a-z0-9]+", "-", text).strip("-")[:limit].rstrip("-") or "session"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def destinations(session: Session, format: str, output: Path | None, *, full: bool = False, redact=()) -> list[tuple[Path, str]]:
|
|
44
|
+
public = prepare_export(session, full=full, redact=redact)
|
|
45
|
+
name = f"{slug(public.title, 70)}-{slug(public.id, 80)}" if full else (f"{public.timestamp}-" if public.timestamp else "") + slug(public.title, 70)
|
|
46
|
+
formats = ("html", "md") if format == "both" else (format,)
|
|
47
|
+
if output and format != "both":
|
|
48
|
+
return [(output.expanduser(), format)]
|
|
49
|
+
directory = output.expanduser() if output else Path.cwd()
|
|
50
|
+
if directory.exists() and not directory.is_dir():
|
|
51
|
+
raise SessionError(f"Expected an output directory: {directory}")
|
|
52
|
+
return [(directory / f"{name}.{extension}", extension) for extension in formats]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def export_selection(candidate, home: Path, format: str, output: Path | None, no_tools: bool, force: bool, *, full: bool = False, redact=()) -> tuple[list[Path], list[str]]:
|
|
56
|
+
"""Export a picker-discovered candidate. Its provider is already known, so
|
|
57
|
+
unlike the explicit-SESSION_FILE path there is no format to auto-detect."""
|
|
58
|
+
session = read_session(candidate.path.expanduser(), candidate.provider)
|
|
59
|
+
indexed = read_index(home).get(session.id, {}).get("thread_name") if session.provider == "codex" else None
|
|
60
|
+
session.title = str(indexed or candidate.title)
|
|
61
|
+
targets = destinations(session, format, output, full=full, redact=redact)
|
|
62
|
+
paths = write_exports(session, targets, not no_tools, force, full=full, redact=redact)
|
|
63
|
+
return paths, session.warnings
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_exports(session: Session, targets: list[tuple[Path, str]], include_tools: bool, force: bool, *, full: bool = False, redact=()) -> list[Path]:
|
|
67
|
+
# Check every destination before writing either format.
|
|
68
|
+
for path, _ in targets:
|
|
69
|
+
if path.resolve() == session.source.resolve() or (path.exists() and os.path.samefile(path, session.source)):
|
|
70
|
+
raise SessionError("Output cannot replace the source session, even with --force.")
|
|
71
|
+
if path.is_symlink():
|
|
72
|
+
raise SessionError(f"Refusing to replace a symbolic link: {path}")
|
|
73
|
+
if path.exists() and (not force or not path.is_file()):
|
|
74
|
+
raise SessionError(f"Output already exists: {path}. Use --force to replace a file.")
|
|
75
|
+
rendered = [(path, render_html(session, include_tools, full=full, redact=redact) if format == "html" else render_markdown(session, include_tools, full=full, redact=redact)) for path, format in targets]
|
|
76
|
+
written = []
|
|
77
|
+
for path, content in rendered:
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
# Stage on the destination filesystem. Link provides exclusive creation
|
|
80
|
+
# without a check/write race; replace handles explicit overwrites.
|
|
81
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".claudexer-", dir=path.parent)
|
|
82
|
+
try:
|
|
83
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
|
|
84
|
+
stream.write(content)
|
|
85
|
+
if force:
|
|
86
|
+
os.replace(temporary, path)
|
|
87
|
+
else:
|
|
88
|
+
os.link(temporary, path)
|
|
89
|
+
written.append(path.resolve())
|
|
90
|
+
finally:
|
|
91
|
+
Path(temporary).unlink(missing_ok=True)
|
|
92
|
+
return written
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def resume_command(candidate) -> list[str]:
|
|
96
|
+
if candidate.provider == "claude":
|
|
97
|
+
return ["claude", "--resume", candidate.id]
|
|
98
|
+
return ["codex", "resume", candidate.id]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resume_session(candidate) -> None:
|
|
102
|
+
"""Replace this process with the provider CLI resuming the given session.
|
|
103
|
+
Never returns on success; a missing binary or bad cwd raises OSError,
|
|
104
|
+
which the caller's existing error handling already reports."""
|
|
105
|
+
if candidate.cwd and os.path.isdir(candidate.cwd):
|
|
106
|
+
os.chdir(candidate.cwd)
|
|
107
|
+
argv = resume_command(candidate)
|
|
108
|
+
os.execvp(argv[0], argv)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main(argv: list[str] | None = None) -> int:
|
|
112
|
+
args = parser().parse_args(argv)
|
|
113
|
+
home = codex_home(args.codex_home)
|
|
114
|
+
try:
|
|
115
|
+
source = args.session_file
|
|
116
|
+
if source is not None:
|
|
117
|
+
session = read_session(source.expanduser(), "auto" if args.source == "all" else args.source)
|
|
118
|
+
targets = destinations(session, args.format, args.output, full=args.full, redact=args.redact)
|
|
119
|
+
paths = write_exports(session, targets, not args.no_tools, args.force, full=args.full, redact=args.redact)
|
|
120
|
+
for warning in session.warnings:
|
|
121
|
+
print(f"Warning: {terminal_text(warning)}", file=sys.stderr)
|
|
122
|
+
for path in paths:
|
|
123
|
+
print(f"Exported: {terminal_text(str(path))}")
|
|
124
|
+
return 0
|
|
125
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
126
|
+
raise SessionError("No session file supplied. In a noninteractive terminal, pass a .jsonl or .json path: claudexer SESSION_FILE")
|
|
127
|
+
candidates, warnings = discover_all(home, claude_home(args.claude_home), args.source, args.include_archived)
|
|
128
|
+
for warning in warnings:
|
|
129
|
+
print(f"Warning: {terminal_text(warning)}", file=sys.stderr)
|
|
130
|
+
if not candidates:
|
|
131
|
+
raise SessionError("No local sessions found. Supply a session file or use --codex-home / --claude-home.")
|
|
132
|
+
if args.plain_picker:
|
|
133
|
+
selected = pick(candidates)
|
|
134
|
+
if selected is None:
|
|
135
|
+
print("Cancelled; no export written.")
|
|
136
|
+
return 0
|
|
137
|
+
paths, session_warnings = export_selection(selected, home, args.format, args.output, args.no_tools, args.force, full=args.full, redact=args.redact)
|
|
138
|
+
for warning in session_warnings:
|
|
139
|
+
print(f"Warning: {terminal_text(warning)}", file=sys.stderr)
|
|
140
|
+
for path in paths:
|
|
141
|
+
print(f"Exported: {terminal_text(str(path))}")
|
|
142
|
+
return 0
|
|
143
|
+
from .tui import pick_tui
|
|
144
|
+
selected, args.full = pick_tui(candidates, full=args.full, redact=args.redact, home=home, format=args.format, output=args.output, no_tools=args.no_tools, force=args.force)
|
|
145
|
+
if selected is None:
|
|
146
|
+
print("Cancelled.")
|
|
147
|
+
return 0
|
|
148
|
+
resume_session(selected)
|
|
149
|
+
return 0 # pragma: no cover - unreachable once execvp succeeds
|
|
150
|
+
except KeyboardInterrupt:
|
|
151
|
+
print("\nCancelled.", file=sys.stderr)
|
|
152
|
+
return 130
|
|
153
|
+
except (OSError, UnicodeError, SessionError) as exc:
|
|
154
|
+
print(f"Error: {terminal_text(str(exc))}", file=sys.stderr)
|
|
155
|
+
return 1
|
claudexer/controls.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
document.getElementById('expand')?.addEventListener('click', () => {
|
|
2
|
+
document.querySelectorAll('details.tool').forEach(item => { item.open = true; });
|
|
3
|
+
});
|
|
4
|
+
document.getElementById('collapse')?.addEventListener('click', () => {
|
|
5
|
+
document.querySelectorAll('details.tool').forEach(item => { item.open = false; });
|
|
6
|
+
});
|
|
7
|
+
document.getElementById('print')?.addEventListener('click', () => { window.print(); });
|
|
8
|
+
let previouslyOpen = [];
|
|
9
|
+
window.addEventListener('beforeprint', () => {
|
|
10
|
+
previouslyOpen = [...document.querySelectorAll('details.tool')].map(item => item.open);
|
|
11
|
+
document.querySelectorAll('details.tool').forEach(item => { item.open = true; });
|
|
12
|
+
});
|
|
13
|
+
window.addEventListener('afterprint', () => {
|
|
14
|
+
document.querySelectorAll('details.tool').forEach((item, i) => { item.open = previouslyOpen[i]; });
|
|
15
|
+
});
|
claudexer/discovery.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Read session metadata and provide a dependency-free terminal picker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from typing import TextIO
|
|
13
|
+
|
|
14
|
+
from .session import content_parts
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Candidate:
|
|
19
|
+
path: Path
|
|
20
|
+
id: str
|
|
21
|
+
title: str
|
|
22
|
+
cwd: str
|
|
23
|
+
updated: float
|
|
24
|
+
archived: bool = False
|
|
25
|
+
provider: str = "codex"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def codex_home(override: Path | None = None) -> Path:
|
|
29
|
+
return (override or Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")).expanduser()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_index(home: Path) -> dict[str, dict]:
|
|
33
|
+
index = {}
|
|
34
|
+
try:
|
|
35
|
+
with (home / "session_index.jsonl").open(encoding="utf-8") as stream:
|
|
36
|
+
for line in stream:
|
|
37
|
+
try:
|
|
38
|
+
item = json.loads(line)
|
|
39
|
+
if isinstance(item, dict) and item.get("id"):
|
|
40
|
+
index[str(item["id"])] = item
|
|
41
|
+
except ValueError:
|
|
42
|
+
continue
|
|
43
|
+
except OSError:
|
|
44
|
+
pass
|
|
45
|
+
return index
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def discover(home: Path, include_archived: bool = False) -> tuple[list[Candidate], list[str]]:
|
|
49
|
+
index, found, warnings = read_index(home), [], []
|
|
50
|
+
roots = [(home / "sessions", False)]
|
|
51
|
+
if include_archived:
|
|
52
|
+
roots.append((home / "archived_sessions", True))
|
|
53
|
+
for root, archived in roots:
|
|
54
|
+
for path in root.rglob("*.jsonl"):
|
|
55
|
+
try:
|
|
56
|
+
identity, cwd, title = path.stem, "", ""
|
|
57
|
+
# Metadata and the first prompt normally occur near the start.
|
|
58
|
+
# Bound discovery work; full parsing happens after selection.
|
|
59
|
+
with path.open(encoding="utf-8-sig") as stream:
|
|
60
|
+
for number, line in enumerate(stream):
|
|
61
|
+
if number >= 200:
|
|
62
|
+
break
|
|
63
|
+
try:
|
|
64
|
+
record = json.loads(line)
|
|
65
|
+
except ValueError:
|
|
66
|
+
continue
|
|
67
|
+
if not isinstance(record, dict):
|
|
68
|
+
continue
|
|
69
|
+
p = record.get("payload", {})
|
|
70
|
+
if not isinstance(p, dict):
|
|
71
|
+
continue
|
|
72
|
+
if record.get("type") == "session_meta":
|
|
73
|
+
identity = str(p.get("id") or p.get("session_id") or identity)
|
|
74
|
+
cwd = str(p.get("cwd") or "")
|
|
75
|
+
title = str(p.get("thread_name") or p.get("title") or "")
|
|
76
|
+
if record.get("type") == "response_item" and p.get("role") == "user":
|
|
77
|
+
text, _ = content_parts(p.get("content", []))
|
|
78
|
+
if text and not text.lstrip().startswith(("# AGENTS.md instructions", "<environment_context>", "<INSTRUCTIONS>")):
|
|
79
|
+
title = title or " ".join(text.split())[:100]
|
|
80
|
+
break
|
|
81
|
+
item = index.get(identity, {})
|
|
82
|
+
title = str(item.get("thread_name") or title or identity)
|
|
83
|
+
updated = path.stat().st_mtime
|
|
84
|
+
try:
|
|
85
|
+
updated = max(updated, datetime.fromisoformat(item["updated_at"].replace("Z", "+00:00")).timestamp())
|
|
86
|
+
except (KeyError, ValueError, TypeError, AttributeError):
|
|
87
|
+
pass
|
|
88
|
+
found.append(Candidate(path, identity, title, cwd, updated, archived))
|
|
89
|
+
except (OSError, UnicodeError) as exc:
|
|
90
|
+
warnings.append(f"Could not inspect {path.name}: {exc}")
|
|
91
|
+
found.sort(key=lambda c: (c.updated, str(c.path)), reverse=True)
|
|
92
|
+
# Prefer the most recently updated copy if a rollout was moved/duplicated.
|
|
93
|
+
unique = {}
|
|
94
|
+
for candidate in found:
|
|
95
|
+
unique.setdefault(candidate.id, candidate)
|
|
96
|
+
return list(unique.values()), warnings
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def terminal_text(text: str) -> str:
|
|
100
|
+
# Session titles are untrusted text; strip terminal control sequences.
|
|
101
|
+
text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", text)
|
|
102
|
+
return " ".join("".join(c for c in text if c.isprintable() or c.isspace()).split())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def claude_home(override: Path | None = None) -> Path:
|
|
106
|
+
return (override or Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")).expanduser()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def discover_claude(home: Path) -> tuple[list[Candidate], list[str]]:
|
|
110
|
+
from .claude import parts
|
|
111
|
+
from .session import iter_records
|
|
112
|
+
|
|
113
|
+
found, warnings = [], []
|
|
114
|
+
# Nested subagent logs and legacy agent-*.jsonl files are separate sessions.
|
|
115
|
+
for path in (home / "projects").glob("*/*.jsonl"):
|
|
116
|
+
if path.name.startswith("agent-"):
|
|
117
|
+
continue
|
|
118
|
+
try:
|
|
119
|
+
identity, cwd, title, custom, prompt = path.stem, "", "", "", ""
|
|
120
|
+
for record in iter_records(path, [], require_payload=False):
|
|
121
|
+
identity = str(record.get("sessionId") or identity)
|
|
122
|
+
cwd = str(record.get("cwd") or cwd)
|
|
123
|
+
kind = record.get("type")
|
|
124
|
+
if kind in {"ai-title", "summary"}:
|
|
125
|
+
title = str(record.get("aiTitle") or record.get("summary") or title)
|
|
126
|
+
elif kind == "custom-title":
|
|
127
|
+
custom = str(record.get("customTitle") or custom)
|
|
128
|
+
elif kind == "user" and not prompt and not record.get("isMeta") and not record.get("isCompactSummary"):
|
|
129
|
+
message = record.get("message", {})
|
|
130
|
+
if isinstance(message, dict):
|
|
131
|
+
text, _ = parts(message.get("content", []))
|
|
132
|
+
if not text.lstrip().startswith(("<local-command-caveat>", "<local-command-stdout>", "[Request interrupted")):
|
|
133
|
+
prompt = " ".join(text.split())[:100]
|
|
134
|
+
found.append(Candidate(path, identity, custom or title or prompt or identity, cwd, path.stat().st_mtime, provider="claude"))
|
|
135
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
136
|
+
warnings.append(f"Could not inspect {path.name}: {exc}")
|
|
137
|
+
return sorted(found, key=lambda c: c.updated, reverse=True), warnings
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def discover_all(codex: Path, claude: Path, source: str = "all", include_archived: bool = False) -> tuple[list[Candidate], list[str]]:
|
|
141
|
+
candidates, warnings = [], []
|
|
142
|
+
if source in {"all", "codex"}:
|
|
143
|
+
found, notes = discover(codex, include_archived)
|
|
144
|
+
candidates.extend(found)
|
|
145
|
+
warnings.extend(notes)
|
|
146
|
+
if source in {"all", "claude"}:
|
|
147
|
+
found, notes = discover_claude(claude)
|
|
148
|
+
candidates.extend(found)
|
|
149
|
+
warnings.extend(notes)
|
|
150
|
+
return sorted(candidates, key=lambda c: c.updated, reverse=True), warnings
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def pick(candidates: list[Candidate], stdin: TextIO | None = None, stdout: TextIO | None = None, page_size: int = 10) -> Candidate | None:
|
|
154
|
+
stdin, stdout = stdin or sys.stdin, stdout or sys.stdout
|
|
155
|
+
query, page = "", 0
|
|
156
|
+
while True:
|
|
157
|
+
matches = [c for c in candidates if query.casefold() in f"{c.title} {c.cwd} {c.id}".casefold()]
|
|
158
|
+
pages = max(1, (len(matches) + page_size - 1) // page_size)
|
|
159
|
+
page = min(page, pages - 1)
|
|
160
|
+
print(f"\nSessions · {len(matches)} match(es) · page {page + 1}/{pages}", file=stdout)
|
|
161
|
+
for i, candidate in enumerate(matches[page * page_size:(page + 1) * page_size], page * page_size + 1):
|
|
162
|
+
date = datetime.fromtimestamp(candidate.updated).strftime("%Y-%m-%d %H:%M")
|
|
163
|
+
archive = " · archived" if candidate.archived else ""
|
|
164
|
+
print(f" {i:>3}. [{candidate.provider}] {terminal_text(candidate.title)[:90]}{archive}\n {date} · {terminal_text(candidate.cwd) or 'unknown directory'} · {terminal_text(candidate.id)[:8]}", file=stdout)
|
|
165
|
+
print("Number to export · /text to search · / to clear · n/p pages · q to cancel\n> ", end="", file=stdout, flush=True)
|
|
166
|
+
answer = stdin.readline()
|
|
167
|
+
if not answer or answer.strip().lower() in {"q", "quit"}:
|
|
168
|
+
return None
|
|
169
|
+
answer = answer.strip()
|
|
170
|
+
if answer.startswith("/"):
|
|
171
|
+
query, page = answer[1:], 0
|
|
172
|
+
elif answer.lower() == "n":
|
|
173
|
+
page = min(page + 1, pages - 1)
|
|
174
|
+
elif answer.lower() == "p":
|
|
175
|
+
page = max(0, page - 1)
|
|
176
|
+
elif answer.isdigit() and 1 <= int(answer) <= len(matches):
|
|
177
|
+
return matches[int(answer) - 1]
|
|
178
|
+
else:
|
|
179
|
+
print("Enter a listed number, /search, n, p, or q.", file=stdout)
|
claudexer/privacy.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Deterministic, local redaction for conversation-only exports.
|
|
2
|
+
|
|
3
|
+
This is best-effort identifier removal, not semantic anonymization of prose.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
import getpass
|
|
9
|
+
import html
|
|
10
|
+
import ipaddress
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import re
|
|
13
|
+
import socket
|
|
14
|
+
from urllib.parse import unquote
|
|
15
|
+
|
|
16
|
+
from .session import Session, iter_records, user_prompts
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
HOME_USER = re.compile(r"(?:/(?:Users|home)/|[A-Za-z]:[\\/]+Users[\\/]+)([^\\/\s\"'`<>]+)", re.I)
|
|
20
|
+
IDENTITY_KEYS = {"username", "user_name", "hostname", "host_name", "computer_name", "email", "user_email", "full_name", "home", "home_dir", "cwd"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def timestamp(value):
|
|
24
|
+
try:
|
|
25
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
26
|
+
return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed
|
|
27
|
+
except (TypeError, ValueError):
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def collect_metadata(session: Session) -> None:
|
|
32
|
+
"""Read only structured identifiers and timestamp bounds; retain no raw log."""
|
|
33
|
+
moments = []
|
|
34
|
+
values = set()
|
|
35
|
+
|
|
36
|
+
def visit(value):
|
|
37
|
+
if isinstance(value, dict):
|
|
38
|
+
for key, item in value.items():
|
|
39
|
+
if key.lower() in IDENTITY_KEYS and isinstance(item, str) and item:
|
|
40
|
+
values.add(item)
|
|
41
|
+
elif isinstance(item, (dict, list)):
|
|
42
|
+
visit(item)
|
|
43
|
+
elif isinstance(value, list):
|
|
44
|
+
for item in value:
|
|
45
|
+
if isinstance(item, (dict, list)):
|
|
46
|
+
visit(item)
|
|
47
|
+
|
|
48
|
+
for record in iter_records(session.source, [], require_payload=False):
|
|
49
|
+
visit(record)
|
|
50
|
+
if moment := timestamp(record.get("timestamp")):
|
|
51
|
+
moments.append(moment)
|
|
52
|
+
if started := timestamp(session.timestamp):
|
|
53
|
+
moments.append(started)
|
|
54
|
+
if moments:
|
|
55
|
+
if not session.timestamp:
|
|
56
|
+
session.timestamp = min(moments).isoformat()
|
|
57
|
+
if len(set(moments)) > 1:
|
|
58
|
+
session.duration_seconds = (max(moments) - min(moments)).total_seconds()
|
|
59
|
+
session.identifiers = sorted(values)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def duration_text(seconds: float | None) -> str:
|
|
63
|
+
if seconds is None:
|
|
64
|
+
return ""
|
|
65
|
+
minutes, seconds = divmod(max(0, int(seconds)), 60)
|
|
66
|
+
hours, minutes = divmod(minutes, 60)
|
|
67
|
+
return (f"{hours}h " if hours else "") + (f"{minutes}m " if minutes or hours else "") + f"{seconds}s"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Redactor:
|
|
71
|
+
def __init__(self, session: Session, extra=()):
|
|
72
|
+
values = {session.id, str(session.source), session.cwd, *session.identifiers}
|
|
73
|
+
# Infer usernames from imported session paths as well as the local host.
|
|
74
|
+
for text in [session.title, *values, *(e.text for e in session.entries)]:
|
|
75
|
+
values.update(HOME_USER.findall(text))
|
|
76
|
+
values.update({str(Path.home()), Path.home().name, socket.gethostname()})
|
|
77
|
+
try:
|
|
78
|
+
values.add(getpass.getuser())
|
|
79
|
+
import pwd
|
|
80
|
+
import os
|
|
81
|
+
values.add(pwd.getpwuid(os.getuid()).pw_gecos.split(",")[0])
|
|
82
|
+
except (ImportError, KeyError, OSError):
|
|
83
|
+
pass
|
|
84
|
+
common = {"user", "root", "admin", "home", "codex", "claude", "assistant"}
|
|
85
|
+
self.values = sorted((v for v in values if len(v) >= 3 and v.casefold() not in common), key=len, reverse=True)
|
|
86
|
+
self.extra = [v for v in extra if v]
|
|
87
|
+
|
|
88
|
+
def __call__(self, text: str) -> str:
|
|
89
|
+
for _ in range(2):
|
|
90
|
+
text = html.unescape(unquote(text))
|
|
91
|
+
text = re.sub(r"[\w.+%\-]+@[\w.\-]+", "[identity]", text)
|
|
92
|
+
for value in self.values:
|
|
93
|
+
text = re.sub(r"(?<!\w)" + re.escape(value) + r"(?!\w)", "[identity]", text, flags=re.I)
|
|
94
|
+
for value in self.extra:
|
|
95
|
+
text = re.sub(re.escape(value), "[redacted]", text, flags=re.I)
|
|
96
|
+
# Omit all image payloads and destinations, including those in code.
|
|
97
|
+
text = re.sub(r"data:[^\s<>\"'`]+", "[image omitted]", text, flags=re.I)
|
|
98
|
+
text = re.sub(r"(?:https?|ftp|ssh|file)://[^\s<>\"'`]+", "[link removed]", text, flags=re.I)
|
|
99
|
+
text = re.sub(r"[\w.+%\-]+@[\w.\-]+", "[identity]", text)
|
|
100
|
+
text = re.sub(r"\b[\w-]+\.(?:local|lan|internal)\b", "[host]", text, flags=re.I)
|
|
101
|
+
text = re.sub(r'''(?P<q>["'`])(?:[A-Za-z]:[\\/]|~[/\\]|/(?!\s)|\\\\)[^\n]*?(?P=q)''', lambda m: m['q'] + '[path]' + m['q'], text)
|
|
102
|
+
text = re.sub(r'''(?<![\w/<])(?:[A-Za-z]:[\\/]|~[/\\]|\\\\|/(?![/\s]))[^\s<>"'`()\[\]{};,]+''', "[path]", text)
|
|
103
|
+
text = re.sub(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", "[id]", text, flags=re.I)
|
|
104
|
+
text = re.sub(r"\b(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b", "[device]", text, flags=re.I)
|
|
105
|
+
|
|
106
|
+
def ip(match):
|
|
107
|
+
try:
|
|
108
|
+
ipaddress.ip_address(match[0])
|
|
109
|
+
return "[address]"
|
|
110
|
+
except ValueError:
|
|
111
|
+
return match[0]
|
|
112
|
+
|
|
113
|
+
text = re.sub(r"(?<![\w:])(?:\d{1,3}\.){3}\d{1,3}(?![\w.])|(?<![\w:])[0-9a-fA-F]*:[0-9a-fA-F:]+(?![\w:])", ip, text)
|
|
114
|
+
text = re.sub(r"(?<!\w)(?:\+\d{1,3}[ .-])?(?:\(\d{3}\)[ .-]?|\d{3}[ .-])\d{3}[ .-]\d{4}\b", "[phone]", text)
|
|
115
|
+
return text
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def prepare_export(session: Session, *, full: bool = False, redact=()) -> Session:
|
|
119
|
+
if full:
|
|
120
|
+
return session
|
|
121
|
+
# Resolve link/image syntax before redaction changes its delimiters. The
|
|
122
|
+
# import is local because renderers also call this export-policy function.
|
|
123
|
+
from .render import markdown_text
|
|
124
|
+
scrub = Redactor(session, redact)
|
|
125
|
+
prompt_ids = {id(e) for e in user_prompts(session)}
|
|
126
|
+
entries = []
|
|
127
|
+
for entry in session.entries:
|
|
128
|
+
if id(entry) not in prompt_ids and not (entry.kind == "assistant" and entry.phase in {"", "commentary", "final", "final_answer"}):
|
|
129
|
+
continue
|
|
130
|
+
text = scrub(markdown_text(entry.text, omit_links=True))
|
|
131
|
+
if entry.images:
|
|
132
|
+
text += "\n\n[Image omitted]"
|
|
133
|
+
entries.append(replace(entry, text=text, timestamp="", images=[], id="", call_id="", turn="", name="", result=None))
|
|
134
|
+
date = timestamp(session.timestamp)
|
|
135
|
+
title = "Conversation" if session.title in {session.id, session.source.stem} else scrub(markdown_text(session.title, omit_links=True))
|
|
136
|
+
return replace(session, title=title, id="", cwd="", timestamp=date.date().isoformat() if date else "", entries=entries, warnings=[], identifiers=[])
|