worklog-for-claude-code 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.
worklog/identity.py ADDED
@@ -0,0 +1,238 @@
1
+ """Which Claude account was logged in for each session (several accounts, one config folder).
2
+
3
+ Claude Code keeps the logged-in account in the `oauthAccount` object of its global
4
+ config file (~/.claude.json for the default folder, <CLAUDE_CONFIG_DIR>/.claude.json
5
+ otherwise). We read only `accountUuid` and `emailAddress` from it. Tokens live in a
6
+ different file (.credentials.json) that this module never opens.
7
+
8
+ The SessionStart hook stores session_id -> account id in a LOCAL state file
9
+ (~/.local/state/claude-worklog/accounts.json, mode 600). The collector later uses it to
10
+ pick the configured account name for each session. Raw ids and e-mails stay in that
11
+ file (and in config.toml if you match by them); only the account NAME reaches records.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import errno
16
+ import fcntl
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ import os
21
+ import time
22
+ from contextlib import contextmanager
23
+ from dataclasses import dataclass
24
+ from datetime import datetime, timedelta
25
+ from pathlib import Path
26
+
27
+ from .config import STATE_DIR, _expand, home_relative
28
+
29
+ log = logging.getLogger(__name__)
30
+ STATE_VERSION = 1
31
+ KEEP_DAYS = 120 # longer than Claude Code's default transcript retention
32
+ _MAX_CONFIG_BYTES = 20_000_000
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Identity:
37
+ account_id: str
38
+ email: str | None
39
+
40
+
41
+ # ------------------------------------------------------------------ reading
42
+ def default_claude_dir() -> Path:
43
+ return (Path.home() / ".claude").resolve()
44
+
45
+
46
+ def identity_path(config_dir: Path, explicit: bool = False) -> Path:
47
+ """Global config file Claude Code uses for `config_dir`.
48
+
49
+ `explicit` means CLAUDE_CONFIG_DIR was set; then the file is always inside it.
50
+ Without it, the default ~/.claude folder keeps the file next to it at ~/.claude.json.
51
+ """
52
+ if not explicit and config_dir.resolve() == default_claude_dir():
53
+ return Path.home() / ".claude.json"
54
+ return config_dir / ".claude.json"
55
+
56
+
57
+ def read_identity(path: Path) -> Identity | None:
58
+ """The logged-in account from a Claude Code global config file, or None.
59
+
60
+ Never logs the values it reads.
61
+ """
62
+ try:
63
+ if path.stat().st_size > _MAX_CONFIG_BYTES:
64
+ log.warning("%s is unexpectedly large; not reading account", path)
65
+ return None
66
+ data = json.loads(path.read_text(encoding="utf-8"))
67
+ except FileNotFoundError:
68
+ return None
69
+ except (OSError, ValueError) as exc:
70
+ log.warning("cannot read account from %s: %s", path, type(exc).__name__)
71
+ return None
72
+ oa = data.get("oauthAccount") if isinstance(data, dict) else None
73
+ if not isinstance(oa, dict):
74
+ return None # logged out, or an API-key login without an OAuth account
75
+ uid = oa.get("accountUuid")
76
+ if not isinstance(uid, str) or not uid.strip() or len(uid) > 200:
77
+ return None
78
+ email = oa.get("emailAddress")
79
+ email = email.strip() if isinstance(email, str) and "@" in email and len(email) <= 254 else None
80
+ return Identity(uid.strip(), email)
81
+
82
+
83
+ def hook_config_dir() -> tuple[Path, bool]:
84
+ """(config folder of the running Claude Code session, whether it was set explicitly)."""
85
+ env = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
86
+ return (_expand(env), True) if env else (default_claude_dir(), False)
87
+
88
+
89
+ # ------------------------------------------------------------------ privacy helpers
90
+ def ref(account_id: str) -> str:
91
+ """Short opaque handle for an account id, safe to put in HTML forms and logs."""
92
+ return hashlib.sha256(account_id.encode("utf-8")).hexdigest()[:12]
93
+
94
+
95
+ def mask_email(email: str | None) -> str:
96
+ """alice@example.com -> a***@example.com (for display only)."""
97
+ if not email or "@" not in email:
98
+ return "(no e-mail)"
99
+ local, _, domain = email.rpartition("@")
100
+ return f"{local[:1]}***@{domain}"
101
+
102
+
103
+ # ------------------------------------------------------------------ state file
104
+ def state_path() -> Path:
105
+ return STATE_DIR / "accounts.json"
106
+
107
+
108
+ def _empty() -> dict:
109
+ return {"version": STATE_VERSION, "sessions": {}, "logins": {}}
110
+
111
+
112
+ def load_state(path: Path | None = None) -> dict:
113
+ """Read the state file. Missing or damaged files give an empty state (logged)."""
114
+ path = path or state_path()
115
+ try:
116
+ data = json.loads(path.read_text(encoding="utf-8"))
117
+ except FileNotFoundError:
118
+ return _empty()
119
+ except (OSError, ValueError) as exc:
120
+ log.warning("ignoring unreadable %s: %s", path, exc)
121
+ return _empty()
122
+ if not isinstance(data, dict):
123
+ return _empty()
124
+ state = _empty()
125
+ for key in ("sessions", "logins"):
126
+ if isinstance(data.get(key), dict):
127
+ state[key] = {k: v for k, v in data[key].items() if isinstance(k, str) and isinstance(v, dict)}
128
+ return state
129
+
130
+
131
+ def _save_state(state: dict, path: Path) -> None:
132
+ path.parent.mkdir(parents=True, exist_ok=True)
133
+ tmp = path.with_suffix(".json.tmp")
134
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) # never world-readable, even briefly
135
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
136
+ json.dump(state, fh, indent=1, sort_keys=True)
137
+ os.chmod(tmp, 0o600)
138
+ os.replace(tmp, path)
139
+
140
+
141
+ @contextmanager
142
+ def _lock(path: Path, timeout: float):
143
+ """Short exclusive lock for the state file (separate from the store lock, which can be held long)."""
144
+ path.parent.mkdir(parents=True, exist_ok=True)
145
+ with open(path.with_suffix(".lock"), "w") as fh:
146
+ deadline = time.monotonic() + timeout
147
+ while True:
148
+ try:
149
+ fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
150
+ break
151
+ except OSError as exc:
152
+ if exc.errno not in (errno.EAGAIN, errno.EACCES) or time.monotonic() > deadline:
153
+ raise TimeoutError("account state is locked") from exc
154
+ time.sleep(0.05)
155
+ try:
156
+ yield
157
+ finally:
158
+ fcntl.flock(fh, fcntl.LOCK_UN)
159
+
160
+
161
+ def _prune(state: dict, now: datetime) -> None:
162
+ cutoff = (now - timedelta(days=KEEP_DAYS)).isoformat()
163
+ state["sessions"] = {k: v for k, v in state["sessions"].items() if str(v.get("at", "")) >= cutoff}
164
+
165
+
166
+ def record_session(session_id: str, config_dir: Path, ident: Identity | None, event: str,
167
+ path: Path | None = None, now: datetime | None = None, timeout: float = 2.0) -> None:
168
+ """Remember which account a session runs under.
169
+
170
+ event "start": the account at SessionStart. The first start wins, so a later resume or
171
+ compaction of the same session can't re-assign it.
172
+ event "end": stored separately; used only if no start was recorded (sessions that were
173
+ already running when the hook was installed). A different account at end is logged.
174
+ """
175
+ path = path or state_path()
176
+ now = now or datetime.now().astimezone()
177
+ stamp = now.isoformat(timespec="seconds")
178
+ with _lock(path, timeout):
179
+ state = load_state(path)
180
+ entry = state["sessions"].setdefault(session_id, {"dir": home_relative(config_dir), "at": stamp})
181
+ if ident is not None:
182
+ key = "id" if event == "start" else "end_id"
183
+ if entry.get(key) is None:
184
+ entry[key] = ident.account_id
185
+ if event == "end" and entry.get("id") not in (None, ident.account_id):
186
+ log.warning("session %s: account changed during the session (%s -> %s); "
187
+ "counted under the account it started with",
188
+ session_id[:8], ref(entry["id"]), ref(ident.account_id))
189
+ login = state["logins"].setdefault(ident.account_id, {"first_seen": stamp, "dirs": []})
190
+ login["last_seen"] = stamp
191
+ if ident.email:
192
+ login["email"] = ident.email
193
+ d = home_relative(config_dir)
194
+ if d not in login.get("dirs", []):
195
+ login["dirs"] = sorted({*login.get("dirs", []), d})
196
+ _prune(state, now)
197
+ _save_state(state, path)
198
+
199
+
200
+ def session_logins(path: Path | None = None) -> dict[str, Identity]:
201
+ """session_id -> Identity for every session with a recorded account."""
202
+ state = load_state(path)
203
+ logins = state["logins"]
204
+ out = {}
205
+ for sid, entry in state["sessions"].items():
206
+ uid = entry.get("id") or entry.get("end_id")
207
+ if isinstance(uid, str) and uid:
208
+ email = (logins.get(uid) or {}).get("email")
209
+ out[sid] = Identity(uid, email if isinstance(email, str) else None)
210
+ return out
211
+
212
+
213
+ def detected_logins(path: Path | None = None) -> list[dict]:
214
+ """Accounts seen logged in, newest first: {ref, account_id, email, dirs, last_seen}."""
215
+ rows = []
216
+ for uid, info in load_state(path)["logins"].items():
217
+ dirs = [_expand(d) for d in info.get("dirs", []) if isinstance(d, str)]
218
+ email = info.get("email") if isinstance(info.get("email"), str) else None
219
+ rows.append({"ref": ref(uid), "account_id": uid, "email": email, "dirs": dirs,
220
+ "last_seen": str(info.get("last_seen", ""))})
221
+ return sorted(rows, key=lambda r: r["last_seen"], reverse=True)
222
+
223
+
224
+ # ------------------------------------------------------------------ hook entry point
225
+ def note_session(session_id: object, event: str) -> None:
226
+ """Called from the hook. Records the current login; never raises."""
227
+ try:
228
+ if not isinstance(session_id, str) or not session_id or len(session_id) > 200:
229
+ log.warning("%s hook without a usable session_id; account not recorded", event)
230
+ return
231
+ config_dir, explicit = hook_config_dir()
232
+ ident = read_identity(identity_path(config_dir, explicit))
233
+ if ident is None:
234
+ log.info("session %s: no logged-in Claude account found for %s", session_id[:8], home_relative(config_dir))
235
+ record_session(session_id, config_dir, ident, event)
236
+ log.info("session %s %s: account %s", session_id[:8], event, ref(ident.account_id) if ident else "unknown")
237
+ except Exception: # noqa: BLE001 - a tracking hook must never break Claude Code
238
+ log.exception("recording the account for a session failed")
worklog/parser.py ADDED
@@ -0,0 +1,180 @@
1
+ """Parse Claude Code JSONL transcripts into per-session, per-day aggregates.
2
+
3
+ Transcripts live at <config_dir>/projects/<encoded-path>/<session-id>.jsonl.
4
+ Their format is NOT a documented stable API, so parsing is defensive:
5
+ malformed or unknown lines are skipped and counted, never fatal.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ from collections import Counter
12
+ from dataclasses import dataclass, field
13
+ from datetime import date, datetime
14
+ from pathlib import Path
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+ EDIT_TOOLS = {"Edit", "MultiEdit", "Write", "NotebookEdit"}
19
+ # User entries that are system/CLI noise rather than real prompts.
20
+ _SKIP_PREFIXES = ("<command-", "<local-command", "<system-reminder", "Caveat:", "[Request interrupted")
21
+ _USAGE_FIELDS = {
22
+ "input_tokens": "input",
23
+ "output_tokens": "output",
24
+ "cache_creation_input_tokens": "cache_creation",
25
+ "cache_read_input_tokens": "cache_read",
26
+ }
27
+
28
+
29
+ def zero_tokens() -> dict[str, int]:
30
+ return {k: 0 for k in _USAGE_FIELDS.values()}
31
+
32
+
33
+ @dataclass
34
+ class ParseStats:
35
+ files: int = 0
36
+ lines: int = 0
37
+ bad_lines: int = 0
38
+
39
+
40
+ @dataclass
41
+ class SessionDay:
42
+ session_id: str
43
+ day: date
44
+ first: datetime | None = None
45
+ last: datetime | None = None
46
+ cwd_counts: Counter = field(default_factory=Counter)
47
+ branches: set[str] = field(default_factory=set)
48
+ tokens: dict[str, int] = field(default_factory=zero_tokens)
49
+ tokens_by_model: dict[str, dict[str, int]] = field(default_factory=dict)
50
+ api_calls: int = 0
51
+ prompts: list[str] = field(default_factory=list)
52
+ files: set[str] = field(default_factory=set)
53
+ tool_calls: Counter = field(default_factory=Counter)
54
+
55
+ def touch(self, ts: datetime) -> None:
56
+ self.first = ts if self.first is None or ts < self.first else self.first
57
+ self.last = ts if self.last is None or ts > self.last else self.last
58
+
59
+ def add_usage(self, model: str, usage: dict) -> None:
60
+ per_model = self.tokens_by_model.setdefault(model, zero_tokens())
61
+ for src, dst in _USAGE_FIELDS.items():
62
+ v = usage.get(src)
63
+ if isinstance(v, int) and not isinstance(v, bool) and v > 0:
64
+ self.tokens[dst] += v
65
+ per_model[dst] += v
66
+ self.api_calls += 1
67
+
68
+
69
+ def parse_ts(value: object) -> datetime | None:
70
+ """ISO-8601 (UTC 'Z') -> aware datetime in the machine's local timezone."""
71
+ if not isinstance(value, str):
72
+ return None
73
+ try:
74
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone()
75
+ except ValueError:
76
+ return None
77
+
78
+
79
+ def _entries(path: Path, stats: ParseStats):
80
+ stats.files += 1
81
+ with path.open("r", encoding="utf-8", errors="replace") as fh:
82
+ for line in fh:
83
+ line = line.strip()
84
+ if not line:
85
+ continue
86
+ stats.lines += 1
87
+ try:
88
+ obj = json.loads(line)
89
+ except json.JSONDecodeError:
90
+ stats.bad_lines += 1 # e.g. a partially written last line
91
+ continue
92
+ if isinstance(obj, dict):
93
+ yield obj
94
+
95
+
96
+ def _user_prompt(entry: dict) -> str | None:
97
+ if entry.get("isMeta") or entry.get("isSidechain"):
98
+ return None # sidechain = sub-agent prompts written by Claude, not you
99
+ msg = entry.get("message")
100
+ if not isinstance(msg, dict):
101
+ return None
102
+ content = msg.get("content")
103
+ if isinstance(content, str):
104
+ text = content
105
+ elif isinstance(content, list):
106
+ parts = []
107
+ for block in content:
108
+ if not isinstance(block, dict):
109
+ continue
110
+ if block.get("type") == "tool_result":
111
+ return None # tool output fed back to the model, not a prompt
112
+ if block.get("type") == "text" and isinstance(block.get("text"), str):
113
+ parts.append(block["text"])
114
+ text = "\n".join(parts)
115
+ else:
116
+ return None
117
+ text = text.strip()
118
+ if not text or text.startswith(_SKIP_PREFIXES):
119
+ return None
120
+ return text
121
+
122
+
123
+ def parse_transcript(
124
+ path: Path,
125
+ day: date,
126
+ sessions: dict[str, SessionDay],
127
+ seen_usage: set[tuple],
128
+ seen_uuids: set[str],
129
+ stats: ParseStats,
130
+ ) -> None:
131
+ """Merge entries of `path` dated `day` (local time) into `sessions`.
132
+
133
+ `seen_usage` dedups token usage by (message.id, requestId): streamed responses
134
+ write one line per content block, each repeating the same usage.
135
+ `seen_uuids` dedups entries copied into a new file when a session is resumed.
136
+ """
137
+ for entry in _entries(path, stats):
138
+ ts = parse_ts(entry.get("timestamp"))
139
+ if ts is None or ts.date() != day:
140
+ continue
141
+ uuid = entry.get("uuid")
142
+ if isinstance(uuid, str):
143
+ if uuid in seen_uuids:
144
+ continue
145
+ seen_uuids.add(uuid)
146
+
147
+ sid = str(entry.get("sessionId") or path.stem)
148
+ sd = sessions.setdefault(sid, SessionDay(sid, day))
149
+ sd.touch(ts)
150
+ if isinstance(entry.get("cwd"), str):
151
+ sd.cwd_counts[entry["cwd"]] += 1
152
+ if isinstance(entry.get("gitBranch"), str) and entry["gitBranch"]:
153
+ sd.branches.add(entry["gitBranch"])
154
+
155
+ etype = entry.get("type")
156
+ if etype == "user":
157
+ prompt = _user_prompt(entry)
158
+ if prompt:
159
+ sd.prompts.append(prompt)
160
+ elif etype == "assistant":
161
+ msg = entry.get("message")
162
+ if not isinstance(msg, dict):
163
+ continue
164
+ model = msg.get("model") if isinstance(msg.get("model"), str) else "unknown"
165
+ content = msg.get("content")
166
+ for block in content if isinstance(content, list) else []:
167
+ if isinstance(block, dict) and block.get("type") == "tool_use":
168
+ name = str(block.get("name", "unknown"))
169
+ sd.tool_calls[name] += 1
170
+ inp = block.get("input") if isinstance(block.get("input"), dict) else {}
171
+ fp = inp.get("file_path") or inp.get("notebook_path")
172
+ if name in EDIT_TOOLS and isinstance(fp, str):
173
+ sd.files.add(fp)
174
+ usage = msg.get("usage")
175
+ if isinstance(usage, dict) and model != "<synthetic>":
176
+ key = (msg.get("id"), entry.get("requestId"))
177
+ if key[0] is None or key not in seen_usage:
178
+ if key[0] is not None:
179
+ seen_usage.add(key)
180
+ sd.add_usage(model, usage)
worklog/redact.py ADDED
@@ -0,0 +1,39 @@
1
+ """Best-effort redaction of secrets and PII before anything leaves the machine.
2
+
3
+ This is a safety net, not a guarantee. The data repo must still be PRIVATE.
4
+ Order matters: credentials-in-URLs must be handled before e-mail addresses.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+
10
+ _R = "[REDACTED]"
11
+
12
+ _PATTERNS: list[tuple[re.Pattern[str], str]] = [
13
+ (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(-----END [A-Z ]*PRIVATE KEY-----|$)", re.S),
14
+ "[REDACTED_PRIVATE_KEY]"),
15
+ # scheme://user:password@host -> scheme://[REDACTED]@host
16
+ (re.compile(r"\b([a-zA-Z][a-zA-Z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@"), r"\1" + _R + "@"),
17
+ (re.compile(r"\bsk-ant-[A-Za-z0-9_-]{10,}"), _R), # Anthropic
18
+ (re.compile(r"\bsk-[A-Za-z0-9_-]{20,}"), _R), # OpenAI-style
19
+ (re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}"), _R), # GitHub
20
+ (re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}"), _R),
21
+ (re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}"), _R), # GitLab
22
+ (re.compile(r"\bATBB[A-Za-z0-9]{20,}"), _R), # Bitbucket app password
23
+ (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), _R), # AWS access key id
24
+ (re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}"), _R), # Slack
25
+ (re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"), _R), # Google API key
26
+ (re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"), "[REDACTED_JWT]"),
27
+ (re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{16,}"), "Bearer " + _R),
28
+ # key=value / key: value for obviously sensitive keys
29
+ (re.compile(r"(?i)\b([A-Za-z0-9_]*(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key)[A-Za-z0-9_]*)"
30
+ r"(\s*[:=]\s*)(['\"]?)[^\s'\"]{4,}\3"), r"\1\2" + _R),
31
+ (re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), "[REDACTED_EMAIL]"),
32
+ (re.compile(r"\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b"), "[REDACTED_IP]"),
33
+ ]
34
+
35
+
36
+ def redact(text: str) -> str:
37
+ for pattern, repl in _PATTERNS:
38
+ text = pattern.sub(repl, text)
39
+ return text