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 ADDED
@@ -0,0 +1,3 @@
1
+ """Claudexer: browse, resume, and export local Codex and Claude Code sessions."""
2
+
3
+ __version__ = "0.4.0"
claudexer/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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=[])