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/__init__.py +3 -0
- worklog/cli.py +249 -0
- worklog/collector.py +197 -0
- worklog/config.py +294 -0
- worklog/demo.py +214 -0
- worklog/hooks.py +78 -0
- worklog/identity.py +238 -0
- worklog/parser.py +180 -0
- worklog/redact.py +39 -0
- worklog/report.py +229 -0
- worklog/services.py +139 -0
- worklog/store.py +135 -0
- worklog/web.py +776 -0
- worklog_for_claude_code-0.1.0.dist-info/METADATA +112 -0
- worklog_for_claude_code-0.1.0.dist-info/RECORD +19 -0
- worklog_for_claude_code-0.1.0.dist-info/WHEEL +5 -0
- worklog_for_claude_code-0.1.0.dist-info/entry_points.txt +2 -0
- worklog_for_claude_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- worklog_for_claude_code-0.1.0.dist-info/top_level.txt +1 -0
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
|