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/__init__.py
ADDED
worklog/cli.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Command line interface: init, doctor, install-hook, hook, collect, report, serve, demo."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import date, datetime, timedelta
|
|
11
|
+
from logging.handlers import RotatingFileHandler
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .config import CONFIG_PATH, STATE_DIR, ConfigError, example_config, load_config
|
|
15
|
+
from .hooks import HOOK_EVENTS, install_hook, installed_events
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("worklog")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _setup_logging(verbose: bool, quiet: bool = False) -> None:
|
|
21
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
root = logging.getLogger()
|
|
23
|
+
root.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
24
|
+
fh = RotatingFileHandler(STATE_DIR / "worklog.log", maxBytes=1_000_000, backupCount=3)
|
|
25
|
+
fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
|
26
|
+
root.addHandler(fh)
|
|
27
|
+
if not quiet:
|
|
28
|
+
sh = logging.StreamHandler(sys.stderr)
|
|
29
|
+
sh.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
|
30
|
+
sh.setLevel(logging.DEBUG if verbose else logging.WARNING)
|
|
31
|
+
root.addHandler(sh)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_day(value: str) -> date:
|
|
35
|
+
v = value.lower()
|
|
36
|
+
if v == "today":
|
|
37
|
+
return date.today()
|
|
38
|
+
if v == "yesterday":
|
|
39
|
+
return date.today() - timedelta(days=1)
|
|
40
|
+
try:
|
|
41
|
+
return date.fromisoformat(value)
|
|
42
|
+
except ValueError as exc:
|
|
43
|
+
raise argparse.ArgumentTypeError("use YYYY-MM-DD, 'today' or 'yesterday'") from exc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------ commands
|
|
47
|
+
def cmd_init(args) -> int:
|
|
48
|
+
if CONFIG_PATH.exists() and not args.force:
|
|
49
|
+
print(f"Config already exists: {CONFIG_PATH} (use --force to overwrite)")
|
|
50
|
+
return 1
|
|
51
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
CONFIG_PATH.write_text(example_config(), encoding="utf-8")
|
|
53
|
+
CONFIG_PATH.chmod(0o600)
|
|
54
|
+
print(f"Wrote {CONFIG_PATH}. Edit it, then run `claude-worklog doctor`.")
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_doctor(args) -> int:
|
|
59
|
+
ok = True
|
|
60
|
+
try:
|
|
61
|
+
cfg = load_config()
|
|
62
|
+
except ConfigError as exc:
|
|
63
|
+
print(f"✗ config: {exc}")
|
|
64
|
+
return 1
|
|
65
|
+
print(f"✓ config: {cfg.path} (device={cfg.device})")
|
|
66
|
+
git_dir = cfg.store_repo / ".git"
|
|
67
|
+
if git_dir.exists():
|
|
68
|
+
remote = subprocess.run(["git", "-C", str(cfg.store_repo), "remote"],
|
|
69
|
+
capture_output=True, text=True).stdout.strip()
|
|
70
|
+
print(f"✓ store repo: {cfg.store_repo} (remote: {remote or 'none — local only'})")
|
|
71
|
+
else:
|
|
72
|
+
ok = False
|
|
73
|
+
print(f"✗ store repo: {cfg.store_repo} is not a git clone")
|
|
74
|
+
from .identity import detected_logins, mask_email
|
|
75
|
+
|
|
76
|
+
for a in cfg.accounts:
|
|
77
|
+
proj = a.config_dir / "projects"
|
|
78
|
+
n = sum(1 for _ in proj.rglob("*.jsonl")) if proj.is_dir() else 0
|
|
79
|
+
mark = "✓" if proj.is_dir() else "✗"
|
|
80
|
+
ok &= proj.is_dir()
|
|
81
|
+
events = installed_events(a)
|
|
82
|
+
missing = [ev for ev in HOOK_EVENTS if ev not in events]
|
|
83
|
+
hooks = "hooks installed" if not missing else f"hook NOT installed: {', '.join(missing)}"
|
|
84
|
+
role = (" [matched login]" if a.matched else
|
|
85
|
+
" [default for shared folder]" if len(cfg.accounts_by_dir()[a.config_dir]) > 1 else "")
|
|
86
|
+
print(f"{mark} account {a.name}{role}: {a.config_dir} ({n} transcripts, {hooks})")
|
|
87
|
+
for login in detected_logins(): # e-mails masked: doctor output gets pasted into issues
|
|
88
|
+
for d in login["dirs"]:
|
|
89
|
+
if d in cfg.accounts_by_dir():
|
|
90
|
+
name = cfg.resolve_account(d, login["account_id"], login["email"]).name
|
|
91
|
+
print(f" detected login {mask_email(login['email'])} in {d} -> counted as {name}")
|
|
92
|
+
exe = shutil.which(cfg.claude_bin)
|
|
93
|
+
missing = cfg.claude_bin + " not found (AI summaries will fall back to plain)"
|
|
94
|
+
print(f"{'✓' if exe else '!'} claude binary: {exe or missing}")
|
|
95
|
+
return 0 if ok else 1
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def cmd_install_hook(args) -> int:
|
|
99
|
+
"""Add the SessionStart and SessionEnd hooks to each account's settings.json (backup first, idempotent)."""
|
|
100
|
+
cfg = load_config()
|
|
101
|
+
targets = [a for a in cfg.accounts if not args.account or a.name == args.account]
|
|
102
|
+
if not targets:
|
|
103
|
+
print(f"No account named {args.account!r}")
|
|
104
|
+
return 1
|
|
105
|
+
ok_all = True
|
|
106
|
+
for a in targets:
|
|
107
|
+
ok, msg = install_hook(a)
|
|
108
|
+
ok_all &= ok
|
|
109
|
+
print(("✓ " if ok else "✗ ") + msg)
|
|
110
|
+
return 0 if ok_all else 1
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_serve(args) -> int:
|
|
114
|
+
from .web import serve
|
|
115
|
+
serve(args.port)
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_demo(args) -> int:
|
|
120
|
+
from .demo import run
|
|
121
|
+
return run(args.port, seed=args.seed)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_install_service(args) -> int:
|
|
125
|
+
from . import services
|
|
126
|
+
from .web import HOST_NAME
|
|
127
|
+
try:
|
|
128
|
+
for msg in services.install(args.port, report=args.report, start=not args.no_start):
|
|
129
|
+
print(msg)
|
|
130
|
+
except RuntimeError as exc:
|
|
131
|
+
print(f"✗ {exc}")
|
|
132
|
+
return 1
|
|
133
|
+
if not args.no_start:
|
|
134
|
+
print(f"\n✓ Worklog UI is running: http://{HOST_NAME}:{args.port}")
|
|
135
|
+
print(" It starts automatically whenever you log in. Remove any cron lines you added earlier.")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_hook(args) -> int:
|
|
140
|
+
"""Called by Claude Code at SessionStart and SessionEnd.
|
|
141
|
+
|
|
142
|
+
Must be fast, must never fail the session and must print nothing: SessionStart
|
|
143
|
+
stdout is added to Claude's context. SessionEnd hooks share a 1.5 s budget.
|
|
144
|
+
"""
|
|
145
|
+
from .identity import note_session
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
payload = sys.stdin.read(1_000_000) if not sys.stdin.isatty() else ""
|
|
149
|
+
data = json.loads(payload) if payload.strip() else {}
|
|
150
|
+
data = data if isinstance(data, dict) else {}
|
|
151
|
+
sid = data.get("session_id")
|
|
152
|
+
event = data.get("hook_event_name") or "SessionEnd" # hooks installed by 0.1.0 were SessionEnd only
|
|
153
|
+
if event == "SessionStart":
|
|
154
|
+
note_session(sid, "start")
|
|
155
|
+
return 0
|
|
156
|
+
log.info("SessionEnd hook for session %s; spawning background collect", str(sid)[:8])
|
|
157
|
+
subprocess.Popen( # detached so Claude Code exits immediately
|
|
158
|
+
[sys.executable, "-m", "worklog.cli", "--quiet", "collect", "--days", "2"],
|
|
159
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
160
|
+
start_new_session=True,
|
|
161
|
+
)
|
|
162
|
+
note_session(sid, "end") # after the spawn: the collect must not wait on us
|
|
163
|
+
except Exception: # noqa: BLE001 - a tracking hook must never break Claude Code
|
|
164
|
+
log.exception("hook failed")
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def cmd_collect(args) -> int:
|
|
169
|
+
from .collector import collect
|
|
170
|
+
|
|
171
|
+
cfg = load_config()
|
|
172
|
+
end = args.date or date.today()
|
|
173
|
+
days = [end - timedelta(days=i) for i in reversed(range(args.days))]
|
|
174
|
+
n = collect(cfg, days)
|
|
175
|
+
print(f"collected {n} new/updated record(s) for {days[0]}..{days[-1]}")
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cmd_report(args) -> int:
|
|
180
|
+
from .report import build_report
|
|
181
|
+
|
|
182
|
+
cfg = load_config()
|
|
183
|
+
now = datetime.now()
|
|
184
|
+
day = args.date or (date.today() - timedelta(days=1) if args.auto and now.hour < 6 else date.today())
|
|
185
|
+
use_ai = cfg.use_ai and not args.no_ai
|
|
186
|
+
if not args.no_collect:
|
|
187
|
+
from .collector import collect
|
|
188
|
+
collect(cfg, [day])
|
|
189
|
+
text, path = build_report(cfg, day, use_ai=use_ai, write=not args.stdout)
|
|
190
|
+
print(text if args.stdout else f"report written: {path}")
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def main(argv: list[str] | None = None) -> int:
|
|
195
|
+
ap = argparse.ArgumentParser(prog="claude-worklog", description=__doc__)
|
|
196
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
197
|
+
ap.add_argument("-v", "--verbose", action="store_true")
|
|
198
|
+
ap.add_argument("--quiet", action="store_true", help=argparse.SUPPRESS)
|
|
199
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
200
|
+
|
|
201
|
+
p = sub.add_parser("init", help="write an example config")
|
|
202
|
+
p.add_argument("--force", action="store_true")
|
|
203
|
+
p.set_defaults(func=cmd_init)
|
|
204
|
+
sub.add_parser("doctor", help="check config, store repo, accounts, hooks").set_defaults(func=cmd_doctor)
|
|
205
|
+
p = sub.add_parser("install-hook", help="add SessionStart/SessionEnd hooks to each account's settings.json")
|
|
206
|
+
p.add_argument("--account")
|
|
207
|
+
p.set_defaults(func=cmd_install_hook)
|
|
208
|
+
sub.add_parser("hook", help="(called by Claude Code)").set_defaults(func=cmd_hook)
|
|
209
|
+
p = sub.add_parser("collect", help="parse transcripts and sync records")
|
|
210
|
+
p.add_argument("--date", type=_parse_day, help="last day to collect (default today)")
|
|
211
|
+
p.add_argument("--days", type=int, default=1, choices=range(1, 31), metavar="N", help="days back (1-30)")
|
|
212
|
+
p.set_defaults(func=cmd_collect)
|
|
213
|
+
p = sub.add_parser("report", help="build the daily report")
|
|
214
|
+
p.add_argument("--date", type=_parse_day, help="YYYY-MM-DD, today (default) or yesterday")
|
|
215
|
+
p.add_argument("--no-ai", action="store_true", help="skip claude -p summaries")
|
|
216
|
+
p.add_argument("--no-collect", action="store_true", help="don't collect this device first")
|
|
217
|
+
p.add_argument("--stdout", action="store_true", help="print instead of writing to the store")
|
|
218
|
+
p.add_argument("--auto", action="store_true", help="before 06:00 report yesterday, else today (for the timer)")
|
|
219
|
+
p.set_defaults(func=cmd_report)
|
|
220
|
+
p = sub.add_parser("serve", help="run the web UI on loopback")
|
|
221
|
+
p.add_argument("--port", type=int, default=8765)
|
|
222
|
+
p.set_defaults(func=cmd_serve)
|
|
223
|
+
p = sub.add_parser("demo", help="try the web UI on generated fake data (touches none of your files)")
|
|
224
|
+
p.add_argument("--port", type=int, default=8766)
|
|
225
|
+
p.add_argument("--seed", type=int, default=7, help="change for different fake data")
|
|
226
|
+
p.set_defaults(func=cmd_demo)
|
|
227
|
+
p = sub.add_parser("install-service", help="run the UI + collect/report timers as systemd user services")
|
|
228
|
+
p.add_argument("--port", type=int, default=8765)
|
|
229
|
+
p.add_argument("--report", action=argparse.BooleanOptionalAction, default=True,
|
|
230
|
+
help="build the daily report on this device (use --no-report on all but one device)")
|
|
231
|
+
p.add_argument("--no-start", action="store_true", help="only write unit files")
|
|
232
|
+
p.set_defaults(func=cmd_install_service)
|
|
233
|
+
|
|
234
|
+
args = ap.parse_args(argv)
|
|
235
|
+
if args.cmd == "demo": # its own process must not log into the real state folder
|
|
236
|
+
logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")
|
|
237
|
+
return args.func(args)
|
|
238
|
+
_setup_logging(args.verbose, quiet=args.quiet or args.cmd == "hook")
|
|
239
|
+
if args.cmd == "serve":
|
|
240
|
+
logging.getLogger().handlers[-1].setLevel(logging.INFO) # journald shows startup + errors
|
|
241
|
+
try:
|
|
242
|
+
return args.func(args)
|
|
243
|
+
except (ConfigError, TimeoutError) as exc:
|
|
244
|
+
log.error("%s", exc)
|
|
245
|
+
return 2
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
sys.exit(main())
|
worklog/collector.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Collector: local Claude Code transcripts -> redacted per-session records in the store."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from datetime import date, datetime, time, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from .config import SUMMARIZER_CWD, Account, Config
|
|
12
|
+
from .identity import Identity, session_logins
|
|
13
|
+
from .parser import ParseStats, SessionDay, parse_transcript
|
|
14
|
+
from .redact import redact
|
|
15
|
+
from .store import GitStore
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
SCHEMA = 1
|
|
19
|
+
_SAFE = re.compile(r"[^A-Za-z0-9_-]")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _home_relative(p: Path | str) -> str:
|
|
23
|
+
p = str(p)
|
|
24
|
+
home = str(Path.home())
|
|
25
|
+
return "~" + p[len(home):] if p == home or p.startswith(home + "/") else p
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_git_root(start: Path) -> Path | None:
|
|
29
|
+
for p in (start, *start.parents):
|
|
30
|
+
if (p / ".git").exists():
|
|
31
|
+
return p
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sanitize_remote(url: str) -> dict | None:
|
|
36
|
+
"""Reduce a remote URL to host + repo slug. Credentials are always dropped.
|
|
37
|
+
|
|
38
|
+
Handles https://user:token@host/o/r.git, ssh://git@host:22/o/r.git, git@host:o/r.git
|
|
39
|
+
"""
|
|
40
|
+
url = url.strip()
|
|
41
|
+
if not url:
|
|
42
|
+
return None
|
|
43
|
+
m = re.match(r"^(?:[^@/\s]+@)?([^:/\s]+):(?!//)(.+)$", url) # scp-like syntax
|
|
44
|
+
if m and "://" not in url:
|
|
45
|
+
host, path = m.group(1), m.group(2)
|
|
46
|
+
else:
|
|
47
|
+
parts = urlsplit(url)
|
|
48
|
+
if not parts.hostname:
|
|
49
|
+
return None
|
|
50
|
+
host, path = parts.hostname, parts.path
|
|
51
|
+
slug = path.strip("/").removesuffix(".git")
|
|
52
|
+
return {"host": host.lower(), "slug": slug} if slug else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _git(root: Path, *args: str) -> str | None:
|
|
56
|
+
try:
|
|
57
|
+
out = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, timeout=15)
|
|
58
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
59
|
+
log.debug("git %s failed in %s: %s", args[0], root, exc)
|
|
60
|
+
return None
|
|
61
|
+
return out.stdout if out.returncode == 0 else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _commits(root: Path, day: date) -> list[dict]:
|
|
65
|
+
"""Your own non-merge commits on `day` (local time), across all branches."""
|
|
66
|
+
email = (_git(root, "config", "user.email") or "").strip()
|
|
67
|
+
if not email:
|
|
68
|
+
return [] # no identity -> can't tell which commits are yours; include none
|
|
69
|
+
since = datetime.combine(day, time.min).astimezone().isoformat()
|
|
70
|
+
until = datetime.combine(day + timedelta(days=1), time.min).astimezone().isoformat()
|
|
71
|
+
out = _git(root, "log", "--all", "--no-merges", f"--author={email}", f"--since={since}",
|
|
72
|
+
f"--until={until}", "--format=%h%x09%s", "--max-count=100")
|
|
73
|
+
commits = []
|
|
74
|
+
for line in (out or "").splitlines():
|
|
75
|
+
h, _, subject = line.partition("\t")
|
|
76
|
+
if h:
|
|
77
|
+
commits.append({"hash": h, "subject": redact(subject)[:200]})
|
|
78
|
+
return commits
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_record(sd: SessionDay, account: Account, cfg: Config) -> dict | None:
|
|
82
|
+
cwd = sd.cwd_counts.most_common(1)[0][0] if sd.cwd_counts else None
|
|
83
|
+
cwd_path = Path(cwd) if cwd else None
|
|
84
|
+
if cwd_path and (cwd_path == SUMMARIZER_CWD or SUMMARIZER_CWD in cwd_path.parents):
|
|
85
|
+
return None # our own `claude -p` summarization runs
|
|
86
|
+
if sd.api_calls == 0 and not sd.prompts:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
root = find_git_root(cwd_path) if cwd_path and cwd_path.exists() else None
|
|
90
|
+
project: dict = {"branches": sorted(sd.branches)}
|
|
91
|
+
if root:
|
|
92
|
+
remote = sanitize_remote(_git(root, "remote", "get-url", "origin") or "")
|
|
93
|
+
project.update(kind="git", name=root.name, path=_home_relative(root), remote=remote)
|
|
94
|
+
else:
|
|
95
|
+
project.update(kind="misc", name=cfg.misc_label, path=_home_relative(cwd) if cwd else None,
|
|
96
|
+
folder=cwd_path.name if cwd_path else None, remote=None)
|
|
97
|
+
|
|
98
|
+
files = []
|
|
99
|
+
for f in sorted(sd.files)[:200]:
|
|
100
|
+
fp = Path(f)
|
|
101
|
+
files.append(str(fp.relative_to(root)) if root and root in fp.parents else _home_relative(fp))
|
|
102
|
+
|
|
103
|
+
prompts = [redact(p)[: cfg.max_prompt_chars] for p in sd.prompts[: cfg.max_prompts]]
|
|
104
|
+
minutes = round((sd.last - sd.first).total_seconds() / 60) if sd.first and sd.last else 0
|
|
105
|
+
return {
|
|
106
|
+
"schema": SCHEMA,
|
|
107
|
+
"session_id": sd.session_id,
|
|
108
|
+
"date": sd.day.isoformat(),
|
|
109
|
+
"account": account.name,
|
|
110
|
+
"device": cfg.device,
|
|
111
|
+
"project": project,
|
|
112
|
+
"start": sd.first.isoformat(timespec="seconds") if sd.first else None,
|
|
113
|
+
"end": sd.last.isoformat(timespec="seconds") if sd.last else None,
|
|
114
|
+
"span_minutes": minutes,
|
|
115
|
+
"api_calls": sd.api_calls,
|
|
116
|
+
"tokens": sd.tokens,
|
|
117
|
+
"tokens_by_model": sd.tokens_by_model,
|
|
118
|
+
"prompt_count": len(sd.prompts),
|
|
119
|
+
"prompts": prompts,
|
|
120
|
+
"files_touched": files,
|
|
121
|
+
"tool_calls": dict(sd.tool_calls),
|
|
122
|
+
"commits": _commits(root, sd.day) if root and cfg.include_commits else [],
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _transcripts(config_dir: Path, label: str, day: date) -> list[Path]:
|
|
127
|
+
base = config_dir / "projects"
|
|
128
|
+
if not base.is_dir():
|
|
129
|
+
log.warning("account %s: no transcripts dir at %s", label, base)
|
|
130
|
+
return []
|
|
131
|
+
cutoff = datetime.combine(day, time.min).astimezone().timestamp()
|
|
132
|
+
found = []
|
|
133
|
+
for p in base.rglob("*.jsonl"):
|
|
134
|
+
try:
|
|
135
|
+
mtime = p.stat().st_mtime
|
|
136
|
+
except OSError:
|
|
137
|
+
continue # deleted while scanning
|
|
138
|
+
if mtime >= cutoff: # a file untouched since before `day` can't hold entries for it
|
|
139
|
+
found.append((mtime, p))
|
|
140
|
+
return [p for _, p in sorted(found)]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _move_stale(store: GitStore, day: date, device: str, account: str, fname: str) -> int:
|
|
144
|
+
"""Move this session's record from another account's folder into `account`'s.
|
|
145
|
+
|
|
146
|
+
Happens when a login is named after its sessions were already collected under the
|
|
147
|
+
folder's default. Only called for sessions whose login the hook recorded, so records
|
|
148
|
+
written before accounts were tracked are never touched. Git history keeps the old path.
|
|
149
|
+
"""
|
|
150
|
+
moved = 0
|
|
151
|
+
base = store.path / "records" / day.isoformat() / device
|
|
152
|
+
for old in sorted(base.glob(f"*/{fname}")) if base.is_dir() else []:
|
|
153
|
+
if old.parent.name == account:
|
|
154
|
+
continue
|
|
155
|
+
src, dst = str(old.relative_to(store.path)), f"records/{day.isoformat()}/{device}/{account}/{fname}"
|
|
156
|
+
store.move(src, dst)
|
|
157
|
+
log.info("session %s: record moved from account %s to %s", fname[:8], old.parent.name, account)
|
|
158
|
+
moved += 1
|
|
159
|
+
return moved
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def collect(cfg: Config, days: list[date]) -> int:
|
|
163
|
+
store = GitStore(cfg.store_repo)
|
|
164
|
+
logins: dict[str, Identity] = session_logins() # local state written by the SessionStart hook
|
|
165
|
+
written = 0
|
|
166
|
+
with store.lock():
|
|
167
|
+
store.pull()
|
|
168
|
+
for day in days:
|
|
169
|
+
# Several accounts may share one folder: read it once, then pick the account per session.
|
|
170
|
+
for config_dir, group in cfg.accounts_by_dir().items():
|
|
171
|
+
label = "/".join(a.name for a in group)
|
|
172
|
+
stats = ParseStats()
|
|
173
|
+
sessions: dict[str, SessionDay] = {}
|
|
174
|
+
seen_usage: set[tuple] = set()
|
|
175
|
+
seen_uuids: set[str] = set()
|
|
176
|
+
for path in _transcripts(config_dir, label, day):
|
|
177
|
+
try:
|
|
178
|
+
parse_transcript(path, day, sessions, seen_usage, seen_uuids, stats)
|
|
179
|
+
except OSError as exc:
|
|
180
|
+
log.warning("cannot read %s: %s", path, exc)
|
|
181
|
+
for sd in sessions.values():
|
|
182
|
+
login = logins.get(sd.session_id)
|
|
183
|
+
account = (cfg.resolve_account(config_dir, login.account_id, login.email) if login
|
|
184
|
+
else cfg.default_account(config_dir)) # unrecorded / older sessions
|
|
185
|
+
record = build_record(sd, account, cfg)
|
|
186
|
+
if record is None:
|
|
187
|
+
continue
|
|
188
|
+
fname = f"{_SAFE.sub('_', sd.session_id)}.json"
|
|
189
|
+
if login:
|
|
190
|
+
written += _move_stale(store, day, cfg.device, account.name, fname)
|
|
191
|
+
rel = f"records/{day.isoformat()}/{cfg.device}/{account.name}/{fname}"
|
|
192
|
+
written += store.write_json(rel, record)
|
|
193
|
+
log.info("%s %s: %d files, %d lines (%d malformed), %d sessions",
|
|
194
|
+
day, label, stats.files, stats.lines, stats.bad_lines, len(sessions))
|
|
195
|
+
if written:
|
|
196
|
+
store.commit_and_push(f"collect {cfg.device}: {written} record(s)")
|
|
197
|
+
return written
|