
Time Lens
- 26 installs
- 7 repo stars
- Updated July 30, 2026
- vladmdgolam/agent-skills
Helps with ai & agent building tasks.
About
time-lens is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- time-lens
- AI & Agent Building
- AI-coding skill
Time Lens by the numbers
- 26 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #9,702 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/vladmdgolam/agent-skills --skill time-lensAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 26 |
|---|---|
| repo stars | ★ 7 |
| Last updated | July 30, 2026 |
| Repository | vladmdgolam/agent-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Project Time Tracker
Combines five data sources → reconciles → produces HTML dashboard + Markdown report.
Scripts
All scripts live in scripts/ next to this SKILL.md. Run them with python3:
| Script | Purpose | Key flags |
|---|---|---|
git_sessions.py | Parse git history → sessions → hours | <repo> --since YYYY-MM-DD --until YYYY-MM-DD |
wakatime_fetch.py | WakaTime API → daily hours, filtered to project | --start YYYY-MM-DD --end YYYY-MM-DD --project name |
claude_messages.py | Claude Code user prompts per day + timestamps | --project-path /abs/path or --filter name |
codex_messages.py | Codex CLI user prompts per day + timestamps | --project-path /abs/path or --filter name |
cursor_messages.py | Cursor IDE user prompts per day + timestamps | --project-path /abs/path or --filter name |
Workflow
1. Determine scope
Ask for or infer:
- Project directory/directories (git repos)
- Date range (first commit → last commit, or user-specified)
- Output location for HTML + markdown files
Auto-discover sub-repos: By default, scan the project directory for .git folders in subdirectories (not just the root). Each parent of a .git directory is a sub-repo to analyze.
# Find all git repos under the project directory
find /path/to/project -name ".git" -type d 2>/dev/null | sortThis produces a list like:
/path/to/project/frontend/.git
/path/to/project/backend/.git
/path/to/project/libs/shared/.gitEach of these (minus the /.git suffix) is a repo to run git_sessions.py, claude_messages.py, and codex_messages.py on. Also run these scripts on the root project directory itself (for Claude/Codex messages sent from the root, which is common when using monorepo-style workflows).
2. Extract data
Run all five scripts on every discovered repo. For git, Claude, Codex, and Cursor, run per sub-repo. For WakaTime, use the multi-project discovery approach described below.
# Git sessions — run per sub-repo
python3 git_sessions.py /path/to/project/frontend --since 2026-01-15 --until 2026-02-02
python3 git_sessions.py /path/to/project/backend --since 2026-01-15 --until 2026-02-02
# Claude Code — run per sub-repo AND the root directory
python3 claude_messages.py --project-path /path/to/project
python3 claude_messages.py --project-path /path/to/project/frontend
python3 claude_messages.py --project-path /path/to/project/backend
# Codex CLI — same as Claude
python3 codex_messages.py --project-path /path/to/project
python3 codex_messages.py --project-path /path/to/project/frontend
python3 codex_messages.py --project-path /path/to/project/backend
# Cursor IDE — same as Claude/Codex
python3 cursor_messages.py --project-path /path/to/project
python3 cursor_messages.py --project-path /path/to/project/frontend
python3 cursor_messages.py --project-path /path/to/project/backendWakaTime multi-project discovery: WakaTime often tracks sub-directories as separate projects (e.g., a monorepo at my-project/ may have WakaTime projects named my-project, frontend, backend, shared). A single --project query will miss the others.
1. First, run wakatime_fetch.py without --project to get the full project list for the date range:
python3 wakatime_fetch.py --start 2026-01-15 --end 2026-02-02
# Returns: { "projects": [{"project": "my-project", "hours": 9.2}, {"project": "frontend", "hours": 5.1}, ...] }2. Filter the returned projects list for names matching any of:
- The root project directory basename (e.g.,
my-project) - Any sub-repo directory basename (e.g.,
frontend,backend) - Any intermediate directory basename that contains a sub-repo (e.g.,
libs)
3. Fetch intervals for each matching project:
python3 wakatime_fetch.py --start 2026-01-15 --end 2026-02-02 --project my-project
python3 wakatime_fetch.py --start 2026-01-15 --end 2026-02-02 --project frontend
python3 wakatime_fetch.py --start 2026-01-15 --end 2026-02-02 --project backend4. Combine all intervals from all matching WakaTime projects into a single list for reconciliation.
Why this matters: In a project with 4 sub-repos, a single --project query captured only 9h of the actual 26.5h of WakaTime data. The other 17.5h was tracked under sub-directory project names.
Folder move detection: If claude_messages.py, codex_messages.py, or cursor_messages.py return 0 results, check the output for alternate_paths. If present, ask the user:
"No Claude/Codex history found at/current/path, but found sessions forproject-nameat/old/path. Was this project moved? Should I include that history too?"
If confirmed, re-run with --project-path /old/path and merge timestamps from both paths.
See references/folder-move-detection.md for full detection logic and edge cases.
3. Reconcile hours
Merged total = best estimate (git ∪ Claude ∪ Codex ∪ Cursor ∪ WakaTime intervals, no double-counting):
GAP_H = 1.5 # hours between events → new session
# 1. Collect intervals from git (start/end per session, converted to UTC epoch)
# 2. Collect intervals from Claude timestamps (detect sessions via gap threshold)
# 3. Collect intervals from Codex timestamps (same gap threshold)
# 4. Collect intervals from Cursor timestamps (same gap threshold)
# 5. Collect intervals from WakaTime "intervals" field (already [start, end] pairs in UTC epoch)
# 6. Combine all intervals into one list, sort by start
# 7. Merge overlapping/adjacent intervals:
# for each interval, if next.start - cur.end <= GAP_H * 3600 → extend current
# 8. For each merged interval: est = max(end - start + 0.5h, 0.5h)
# 9. total = Σ est
# Data formats (all UTC epoch floats):
# - git_sessions.py: convert local times using timezone offset from git log
# - claude_messages.py "timestamps": point events → detect sessions via gap
# - codex_messages.py "timestamps": point events → detect sessions via gap
# - cursor_messages.py "timestamps": point events → detect sessions via gap
# - wakatime_fetch.py "intervals": already [start_epoch, end_epoch] pairs
# (fetched from /durations API, per-file intervals pre-merged with 60s tolerance)Why merge matters: AI agent prompts (Claude/Codex/Cursor) often appear minutes before/after git commits in the same work session. WakaTime captures IDE keystrokes that may fall between commits. A user might research with Claude, use Cursor's AI, write code (WakaTime), then commit (git) — all one session. Union of all five sources captures the true session boundaries without double-counting.
- The merged total replaces "git-only" as the primary estimate
- WakaTime hours shown for reference (active keystrokes only, always lower)
See references/reconciliation.md for full pseudocode, the session detection function, and the hour estimate formula.
4. Generate HTML dashboard
Write a single-file HTML with inline Chart.js (CDN). Dark theme (#0a0a0a bg, #1a1a1a cards).
Required sections: 1. Stat cards — Merged total (git∪claude∪codex∪cursor∪waka), Git estimate, WakaTime, Sessions, Commits, Claude prompts, Codex prompts, Cursor prompts 2. Daily activity chart — Overlapping bars (git + WakaTime + merged) + AI prompts line on secondary axis 3. Gantt timeline — UTC horizontal bars; git, Claude, Codex, and Cursor as separate colored datasets on same chart (separate swimlane rows when they overlap on same day) 4. Data table — Session | Time (UTC) | Active | Est. | WakaTime | Claude | Codex | Cursor | Commits
Chart.js essentials:
Chart.defaults.color = '#888';
Chart.defaults.borderColor = '#2a2a2a';
// Overlapping bars: same barPercentage/categoryPercentage on both datasets, different opacity
// Mixed chart: type:'bar' on container, each dataset has its own type + yAxisID
// Gantt floating bars: data: [[startH, endH]], indexAxis: 'y'
// Claude as line on secondary axis:
{
type: 'line',
yAxisID: 'yClaude',
// right-side axis, max ~100, different color
}
// All charts: responsive: true, maintainAspectRatio: falseSave as <project-dir>/work-hours-analysis.html.
5. Generate Markdown report
# [Project] - Work Hours Analysis
## Summary
**Estimated Total Working Hours: Xh** (based on commit timing analysis)
| Date | Sessions | Time Range | Git Est. | WakaTime | Commits | Project |
...
## Timeline
- Start: [date], End: [date], Duration: N days
## Per-Project Breakdown
[sub-project sections with commit/session counts]
## Charts
### Daily Activity (ASCII)
Jan 15 ██░░░░░░░░░░░░░░░░░░ 0.5h
...
### Project Distribution
project-a ████████████████████ 45% (~18h)
...
## Methodology
- Session Detection: commits within 1.5h gap = same session
- Hour Estimate: Σ(session_duration + 0.5h buffer), min 0.5h/session
- Why Git > WakaTime: WakaTime only tracks active IDE typing; git includes thinking/research/AI promptingASCII bar: blocks = round(hours / max_hours * 20), █ filled, ░ empty, 20 cols wide.
Save as <project-dir>/total_hours.md.
---
Validation Checklist
Before running
- [ ] WakaTime API key —
~/.wakatime.cfgexists and containsapi_key = waka_...under[settings]. Runcat ~/.wakatime.cfgto verify. If missing, the WakaTime script will fail silently or with an auth error. - [ ] Git repo accessible — the project directory is a git repo with commits (
git log --oneline -5 /path/to/reporeturns results). If not,git_sessions.pywill return 0 sessions. - [ ] Claude history exists —
~/.claude/history.jsonlis present and non-empty, OR~/.claude/projects/contains session files for the project. If both are missing, Claude hours will be 0. - [ ] Cursor data accessible — the Cursor state database exists at the platform-specific path (macOS:
~/Library/Application Support/Cursor/User/globalStorage/state.vscdb, Windows:%APPDATA%\Cursor\User\globalStorage\state.vscdb, Linux:~/.config/Cursor/User/globalStorage/state.vscdb). If missing, Cursor hours will be 0. The script auto-detects the platform and reads SQLite databases in read-only mode. - [ ] Date range is valid —
--sinceis before--until; the range covers dates when work actually happened.
After generation
- [ ] HTML loads without errors — open
work-hours-analysis.htmlin a browser; all charts render; no JS console errors. - [ ] Total hours match — the "Merged total" stat card in HTML equals the "Estimated Total Working Hours" in
total_hours.md(allow ±0.01h for rounding). - [ ] Date range matches input — the first and last dates in the data table and the Timeline section match the requested
--since/--untilvalues. - [ ] Session count non-zero — at least one source contributed sessions. If all sources return 0, something is wrong (wrong path, wrong project name, date range outside project history).
---
Examples
Example 1: Single repo, standard report
Trigger phrase: "How many hours did I spend on the api-server project this month? Generate the full report."
Actions:
1. Determine scope: project at /Users/alice/code/api-server, date range inferred as 2026-02-01 → 2026-02-23 (current month to today).
2. Extract data:
python3 git_sessions.py /Users/alice/code/api-server --since 2026-02-01 --until 2026-02-23
python3 wakatime_fetch.py --start 2026-02-01 --end 2026-02-23 --project api-server
python3 claude_messages.py --project-path /Users/alice/code/api-server
python3 codex_messages.py --project-path /Users/alice/code/api-server
python3 cursor_messages.py --project-path /Users/alice/code/api-server3. Reconcile: git_sessions.py returns 14 sessions (22.5h), WakaTime returns 11.2h, Claude returns 47 prompts across 9 days, Codex returns 0, Cursor returns 12 prompts across 3 days. Merged total after union + gap merging: 27.1h.
4. Generate work-hours-analysis.html and total_hours.md in /Users/alice/code/api-server/.
Result: "You spent approximately 27.1 hours on api-server in February 2026 (14 git sessions, 47 Claude prompts, 12 Cursor prompts, WakaTime reference: 11.2h active typing). Report saved to /Users/alice/code/api-server/work-hours-analysis.html."
---
Example 2: Multi-repo project with folder move
Trigger phrase: "Calculate the total dev time for the marketplace project — it has a frontend and backend repo. Also I think I renamed the folder at some point."
Actions:
1. Determine scope: two repos at /Users/bob/marketplace-backend and /Users/bob/marketplace-frontend, user specifies date range 2025-11-01 → 2026-01-31.
2. Extract data:
python3 git_sessions.py /Users/bob/marketplace-backend --since 2025-11-01 --until 2026-01-31
python3 git_sessions.py /Users/bob/marketplace-frontend --since 2025-11-01 --until 2026-01-31
python3 wakatime_fetch.py --start 2025-11-01 --end 2026-01-31 --project marketplace
python3 claude_messages.py --project-path /Users/bob/marketplace-backend
python3 codex_messages.py --project-path /Users/bob/marketplace-backend
python3 cursor_messages.py --project-path /Users/bob/marketplace-backend3. claude_messages.py returns 0 results with alternate_paths: ["/Users/bob/old-market/backend"].
4. Ask user: "No Claude history found at /Users/bob/marketplace-backend, but found sessions for backend at /Users/bob/old-market/backend. Was this the previous location? Should I include that history?"
5. User confirms. Re-run: python3 claude_messages.py --project-path /Users/bob/old-market/backend. Merge timestamps from both runs.
6. Merge git sessions from both repos (backend + frontend), sort, re-merge within 1.5h gap. Reconcile with all sources.
Result: "Total estimated time: 84.7h across 3 months (backend + frontend combined, including Claude history from the old path /Users/bob/old-market/backend)."
---
Troubleshooting
WakaTime API key missing or invalid
Symptom: wakatime_fetch.py exits with an auth error, HTTP 401, or KeyError: 'api_key'.
Fix: 1. Check if the config exists: cat ~/.wakatime.cfg 2. If missing, create it:
[settings]
api_key = waka_xxxx...3. Get your key from wakatime.com/settings/api-key. 4. If the key exists but returns 401, it may be expired or revoked — generate a new one.
Also check: The --project flag matches the project name exactly as WakaTime recorded it (case-sensitive). You can verify project names in the WakaTime dashboard under Projects.
---
Git returns 0 sessions
Symptom: git_sessions.py returns "sessions": [] or "total_hours": 0.
Possible causes and fixes:
| Cause | Fix |
|---|---|
| Date range is outside project history | Check git log --oneline for actual date range; adjust --since/--until |
| Path is not a git repo | Verify with git -C /path/to/repo log --oneline -1 |
| No commits in range by the current user | Pass --author flag if filtering by author, or remove it |
| Shallow clone | Run git fetch --unshallow to restore full history |
---
Claude, Codex, or Cursor returns 0 sessions (no alternate_paths)
Symptom: Both timestamps: [] and alternate_paths: [].
Possible causes and fixes:
| Cause | Fix |
|---|---|
| The tool was not used on this project | Expected — note it in the report |
Wrong --project-path (typo, symlink, trailing slash) | Use realpath /path/to/repo to get the canonical absolute path; pass that |
| History files don't exist | Check ~/.claude/history.jsonl and ~/.claude/projects/ exist; check ~/.codex/sessions/ exists; check Cursor's state.vscdb exists at the platform-specific path (see Validation Checklist) |
| Project path uses a symlink that resolves differently | Use the resolved path: python3 -c "import os; print(os.path.realpath('/your/path'))" |
| Cursor database locked by running Cursor instance | The script opens databases in read-only mode — this should not happen, but if it does, try closing Cursor temporarily |
---
alternate_paths found but user says the path is wrong
Symptom: The script reports alternate paths but the user says none of them are the old project location.
Fix: Fall back to --filter <project-name> which does a substring match on directory names rather than an exact path match. Be aware this may pick up unrelated projects with similar names — review the session list with the user before merging.
python3 claude_messages.py --filter marketplace---
HTML chart renders blank or shows NaN
Symptom: The HTML file opens but charts are empty or show "NaN" values.
Possible causes:
- Reconciliation produced
nullorNonevalues that were serialized into the JS data arrays. - Timestamps were not converted to UTC before writing to HTML (local epoch vs UTC epoch mismatch).
- Chart.js CDN failed to load (offline environment).
Fix: 1. Open browser DevTools console — the specific JS error pinpoints the issue. 2. Verify all epoch timestamps are UTC floats, not strings. 3. For offline environments, download Chart.js and embed it inline: <script>/* chart.js source */</script>.
---
Total hours mismatch between HTML and Markdown
Symptom: The HTML stat card shows a different merged total than total_hours.md.
Cause: The reconciliation was run twice independently and produced slightly different results (e.g., due to floating-point rounding, or one file used stale data).
Fix: Run reconciliation once, store the result in a variable, and write the same computed value to both output files. Do not recompute independently for each output.
---
Notes
WakaTime auth and config: The script reads ~/.wakatime.cfg automatically. Always pass --project for per-project data. WakaTime tracks active keystrokes only — always lower than git estimate. Heavy Claude Code usage creates a large gap between WakaTime and actual effort. See references/data-sources.md for full auth setup, config format, and limitations.
Codex CLI data source: codex_messages.py reads ~/.codex/sessions/YYYY/MM/DD/rollup-*.jsonl. Each file has session_meta (first entry with payload.cwd + payload.id), event_msg entries with payload.type == "user_message" for actual prompts, and turn_context entries for boundaries. Output includes timestamps (UTC epoch floats) and alternate_paths for folder-move detection. See references/data-sources.md for full file structure.
Claude Code data sources: claude_messages.py uses two sources: ~/.claude/history.jsonl (primary; project field = abs path, timestamp in ms) and ~/.claude/projects/<encoded>/*.jsonl (session files; cwd field, type=="user" entries, ISO timestamps). Encoded dir name format: /Users/foo/bar → -Users-foo-bar. Always prefer --project-path over --filter. See references/data-sources.md for full field reference.
Cursor IDE data sources: cursor_messages.py reads from Cursor's SQLite databases (.vscdb files). Primary source is the platform-specific state.vscdb (macOS: ~/Library/Application Support/Cursor/User/globalStorage/, Windows: %APPDATA%\Cursor\User\globalStorage\, Linux: ~/.config/Cursor/User/globalStorage/) — the cursorDiskKV table contains composerData:{sessionId} entries (with workspaceUri for project matching) and bubbleId:{sessionId}:{messageId} entries (with per-message timestamps). Fallback source is workspace-level state.vscdb files under workspaceStorage/*/, with workspace.json mapping each workspace to its project folder. Databases are opened read-only. The script auto-detects the platform. Output format matches Claude/Codex scripts: timestamps array + alternate_paths for folder-move detection. See references/data-sources.md for full field reference.
Reconciliation algorithm: Gap threshold is 1.5h. All sources converted to UTC epoch float intervals. Intervals merged if gap ≤ threshold. Per-interval estimate: max(duration + 0.5h, 0.5h). Merged total = Σ estimates. See references/reconciliation.md for full pseudocode and the session detection helper function.
Folder move detection: claude_messages.py, codex_messages.py, and cursor_messages.py all scan known history for matching project names when 0 results are found at the provided path. Returns alternate_paths list. If non-empty, ask user to confirm, re-run with old path, merge timestamps. See references/folder-move-detection.md for detection logic and edge cases.
Multi-repo projects: By default, scan for .git subdirectories to auto-discover all sub-repos. Run git_sessions.py, claude_messages.py, codex_messages.py, and cursor_messages.py on each sub-repo plus the root directory. Use WakaTime multi-project discovery to find all matching WakaTime project names. Merge all session arrays, re-sort by date, recompute daily totals and grand total.
Data Sources Reference
WakaTime
Authentication & Config
The wakatime_fetch.py script reads the API key automatically from ~/.wakatime.cfg — no manual setup needed. This file is created by any WakaTime IDE plugin (Cursor, VS Code, etc.) when first installed.
To inspect the config:
cat ~/.wakatime.cfg # shows [settings] api_key = waka_xxxx...To set the key manually: visit wakatime.com/settings/api-key, copy your key, then paste it into ~/.wakatime.cfg under [settings]:
[settings]
api_key = waka_xxxx...Multi-Project Discovery (Default Workflow)
WakaTime often tracks sub-directories as separate projects. A monorepo at my-monorepo/ may have WakaTime projects named my-monorepo, frontend, api-service, shared-utils, etc. Querying only the root project name misses significant hours.
Default approach — always do this:
1. Run wakatime_fetch.py without --project first to get the full project list:
python3 wakatime_fetch.py --start 2026-01-15 --end 2026-02-02
# Returns: { "projects": [{"project": "my-monorepo", "hours": 9.2}, {"project": "frontend", "hours": 5.0}, ...] }2. Filter the projects list for names matching the root directory basename OR any sub-repo/sub-directory basename.
3. Fetch intervals for each matching project name:
python3 wakatime_fetch.py --start 2026-01-15 --end 2026-02-02 --project my-monorepo
python3 wakatime_fetch.py --start 2026-01-15 --end 2026-02-02 --project frontend
python3 wakatime_fetch.py --start 2026-01-15 --end 2026-02-02 --project api-service4. Combine all intervals into a single list for reconciliation. The merge algorithm handles overlaps.
Why this matters: In practice, querying only the root project name captured 9h out of 26.5h total — missing 66% of actual WakaTime data that was tracked under sub-directory names.
Behavior & Limitations
WakaTime tracks active keystrokes only — it always produces lower hour counts than the git estimate. When a developer does heavy Claude Code or Codex usage (prompting, reviewing AI output, researching), those intervals are not captured by WakaTime because no IDE keystrokes are generated. This creates a large gap between WakaTime hours and actual working effort.
WakaTime's /durations API returns per-file intervals pre-merged with a 60-second tolerance. These are already [start_epoch, end_epoch] pairs in UTC epoch floats, ready for the reconciliation step.
---
Claude Code Data Sources
claude_messages.py reads from two sources and merges them:
Source 1: ~/.claude/history.jsonl (primary)
One entry per submitted prompt. Relevant fields:
project: absolute path string of the project directorytimestamp: Unix timestamp in milliseconds
Filter by: project == abs_path
Source 2: ~/.claude/projects/<encoded>/*.jsonl (session files)
These are per-session conversation files. Each entry has:
cwd: absolute path string of the project directorytype:"user"or"assistant"timestamp: ISO 8601 string
Filter: type == "user" AND message.content is NOT a list[tool_result] (those are tool outputs, not user prompts).
Use these for sessions that predate `history.jsonl` — older Claude Code versions did not write to history.
Encoded dir name format: /Users/foo/bar → -Users-foo-bar (leading / becomes -, all other / become -).
Matching Preference
Always prefer --project-path /abs/path over --filter name for accurate matching. The --filter flag does a substring match on the project name and may pick up unrelated projects with similar names.
Output
The script outputs a timestamps array of UTC epoch floats (point events). These are used in the reconciliation step to detect sessions via the gap threshold. Also includes alternate_paths when the provided path yields 0 results (see folder-move-detection.md).
---
Codex CLI Data Sources
codex_messages.py reads from ~/.codex/sessions/YYYY/MM/DD/rollup-*.jsonl.
File Structure
Each .jsonl file contains newline-delimited JSON entries of different types:
| Entry type | Relevant fields | Purpose |
|---|---|---|
session_meta (first entry) | payload.cwd, payload.id | Project directory + session UUID |
event_msg with payload.type == "user_message" | payload.message (string), payload.text_elements, payload.images | Actual user prompts |
event_msg with payload.type == "agent_message" | — | Assistant responses (skip) |
turn_context | cwd, model | Turn boundaries |
Matching
Filter by session_meta.payload.cwd == abs_path (or the alternate path if a folder move is detected).
Output
Same as claude_messages.py: a timestamps array of UTC epoch floats plus alternate_paths if applicable.
---
Cursor IDE Data Sources
cursor_messages.py reads from Cursor's SQLite databases (.vscdb files) using two sources.
Source 1: Global Storage cursorDiskKV (primary)
Location (macOS): ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb Location (Windows): %APPDATA%/Cursor/User/globalStorage/state.vscdb Location (Linux): ~/.config/Cursor/User/globalStorage/state.vscdb
The cursorDiskKV table contains key-value pairs:
| Key pattern | Value contents | Purpose |
|---|---|---|
composerData:{sessionId} | JSON with workspaceUri, createdAt, updatedAt | Session metadata — workspaceUri maps to project path |
bubbleId:{sessionId}:{messageId} | JSON with type, timingInfo, createdAt, etc. | Per-message data — type=1 = user message |
Timestamp extraction priority chain (per bubble): 1. createdAt — ISO 8601 string (new format, >= Sept 2025) 2. timingInfo.clientStartTime — Unix ms 3. timingInfo.clientRpcSendTime — Unix ms (old format, assistant only) 4. timingInfo.clientSettleTime — Unix ms (old format) 5. timingInfo.clientEndTime — Unix ms 6. timestamp — Unix ms (legacy plain field)
Project matching: The workspaceUri field in composerData:{sessionId} is a file:// URI (e.g., file:///Users/alice/code/my-project). The script converts this to an absolute path and compares against --project-path.
Source 2: Workspace Storage ItemTable (fallback)
Location (macOS): ~/Library/Application Support/Cursor/User/workspaceStorage/*/state.vscdb Location (Windows): %APPDATA%/Cursor/User/workspaceStorage/*/state.vscdb Location (Linux): ~/.config/Cursor/User/workspaceStorage/*/state.vscdb
Each workspace directory also contains a workspace.json file with a folder field (a file:// URI) that maps the workspace to its project directory.
The ItemTable key-value store contains:
| Key | Value contents | Granularity |
|---|---|---|
composer.composerData | JSON with allComposers array, each having composerId, createdAt, lastUpdatedAt | Session-level only |
workbench.panel.aichat.view.aichat.chatdata | Legacy chat format with chatSessions → messages (has role, timestamp) | Per-message |
workbench.panel.chat.view.chat.chatdata | Legacy chat format (alternate key, same structure) | Per-message |
Use these for: older Cursor versions that don't have cursorDiskKV in global storage, or when the global storage database is unavailable.
Deduplication
The script collects from global storage first, then falls back to workspace storage, deduplicating by rounded epoch timestamp (same approach as claude_messages.py).
Output
Same as claude_messages.py and codex_messages.py: a timestamps array of UTC epoch floats, daily counts, sessions_found, and alternate_paths for folder-move detection. Also includes sources breakdown showing how many messages came from global_storage vs workspace_storage.
Folder Move Detection Reference
What It Is
claude_messages.py, codex_messages.py, and cursor_messages.py all include automatic detection of cases where a project directory has been renamed or moved. When a script finds 0 results for the provided path, it scans all known history for project names that partially match the provided path and returns candidate old locations in an alternate_paths field.
This is important because:
- Developers frequently rename or reorganize project directories.
- Claude Code, Codex, and Cursor sessions are indexed by absolute path, so a moved project appears as a completely different project in the history.
- Without this detection, moved projects would silently show 0 AI session hours.
---
How Detection Works
Claude Code (claude_messages.py)
Two scans are performed:
1. `history.jsonl` scan: All project field values are collected. Any path where the final directory component matches the queried project name (case-insensitive) is treated as a candidate.
2. Session file `cwd` scan: All ~/.claude/projects/<encoded>/ directories are checked. The cwd from the first type=="user" entry in each session file is collected. Same name-match logic applies.
The union of both scans, excluding the originally queried path, is returned as alternate_paths.
Codex CLI (codex_messages.py)
All ~/.codex/sessions/YYYY/MM/DD/rollup-*.jsonl files are scanned. The payload.cwd from each session_meta entry is collected. Any path where the final directory component matches the queried project name is treated as a candidate and returned in alternate_paths.
Cursor IDE (cursor_messages.py)
Two scans are performed:
1. Workspace storage scan: All workspace.json files under the platform-specific Cursor workspaceStorage/*/ directory are read. The folder field (a file:// URI) is converted to an absolute path. Any path where the final directory component matches the queried project name is treated as a candidate.
2. Global storage scan: The cursorDiskKV table in the platform-specific Cursor globalStorage/state.vscdb is queried for composerData:* entries. The workspaceUri from each entry is compared using the same name-match logic.
Platform-specific Cursor base paths: macOS: ~/Library/Application Support/Cursor/User/, Windows: %APPDATA%/Cursor/User/, Linux: ~/.config/Cursor/User/.
The union of both scans, excluding the originally queried path, is returned as alternate_paths.
---
What the alternate_paths Output Looks Like
{
"timestamps": [],
"prompt_count": 0,
"alternate_paths": [
"/Users/foo/archive/my-project",
"/Users/foo/old-name"
]
}A non-empty alternate_paths with an empty timestamps list is the trigger condition.
---
Workflow When alternate_paths Is Present
When claude_messages.py, codex_messages.py, or cursor_messages.py return 0 results and alternate_paths is non-empty:
1. Pause and ask the user:
"No Claude/Codex history found at/current/path, but found sessions forproject-nameat/old/path. Was this project moved? Should I include that history too?"
2. If the user confirms: Re-run the script with --project-path /old/path.
3. Merge the results: Combine timestamps arrays from both the new path run and the old path run. Sort the merged array before passing to the reconciliation step.
merged_timestamps = sorted(
new_path_result["timestamps"] + old_path_result["timestamps"]
)4. Log both paths in the Markdown report's Methodology section so the user understands what was included.
---
Edge Cases
| Situation | Handling |
|---|---|
| Project moved multiple times | alternate_paths may list multiple old locations. Ask user which to include; merge all confirmed ones. |
| Two different projects happen to share a directory name | Present all candidates to the user and let them decide which (if any) to include. |
alternate_paths is empty and 0 results | No history exists for this tool. Note it in the report but do not block generation. |
| Current path has some sessions, old path has more | Merge both regardless; the reconciliation deduplication handles overlapping timestamps. |
Reconciliation Algorithm Reference
Overview
The reconciliation step combines all five data sources into a single merged estimate of actual working time, eliminating double-counting where sources overlap.
Why merge matters: AI agent prompts (Claude/Codex/Cursor) often appear minutes before or after git commits in the same work session. WakaTime captures IDE keystrokes that may fall between commits. A typical flow: research with Claude → use Cursor's AI for edits → write code (WakaTime keystroke capture) → commit (git). All four events belong to one session. The union of all five sources captures true session boundaries without double-counting.
The merged total replaces "git-only" as the primary estimate. WakaTime hours are shown for reference only (active keystrokes only, always lower).
---
Gap Threshold
GAP_H = 1.5 # hoursTwo events separated by more than 1.5 hours are considered different work sessions. This threshold is used both for:
- Detecting sessions from point-event timestamps (Claude, Codex)
- Merging nearby intervals from all sources in the final union step
---
Input Data Formats
All timestamps must be converted to UTC epoch floats before merging.
| Source | Raw format | Conversion needed |
|---|---|---|
git_sessions.py | Local datetime strings with timezone offset from git log | Convert using offset → UTC epoch |
claude_messages.py | Point events, UTC epoch floats (already converted by script) | None — detect sessions via gap |
codex_messages.py | Point events, UTC epoch floats (already converted by script) | None — detect sessions via gap |
cursor_messages.py | Point events, UTC epoch floats (already converted by script) | None — detect sessions via gap |
wakatime_fetch.py | [start_epoch, end_epoch] pairs, UTC epoch floats | None — already intervals |
---
Algorithm (Pseudocode)
GAP_H = 1.5 # hours between events → new session
# --- Step 1: Gather intervals from git ---
# git_sessions.py output: list of {start_local, end_local, tz_offset}
git_intervals = []
for session in git_output["sessions"]:
start_epoch = local_to_utc_epoch(session["start"], session["tz_offset"])
end_epoch = local_to_utc_epoch(session["end"], session["tz_offset"])
git_intervals.append((start_epoch, end_epoch))
# --- Step 2: Detect sessions from Claude point events ---
claude_intervals = detect_sessions(claude_output["timestamps"], GAP_H)
# --- Step 3: Detect sessions from Codex point events ---
codex_intervals = detect_sessions(codex_output["timestamps"], GAP_H)
# --- Step 4: Detect sessions from Cursor point events ---
cursor_intervals = detect_sessions(cursor_output["timestamps"], GAP_H)
# --- Step 5: WakaTime intervals (already [start, end] pairs) ---
waka_intervals = wakatime_output["intervals"] # list of [start_epoch, end_epoch]
# --- Step 6: Combine and sort ---
all_intervals = git_intervals + claude_intervals + codex_intervals + cursor_intervals + waka_intervals
all_intervals.sort(key=lambda x: x[0]) # sort by start time
# --- Step 7: Merge overlapping/adjacent intervals ---
merged = []
for interval in all_intervals:
if merged and interval[0] - merged[-1][1] <= GAP_H * 3600:
# Extend current merged interval if gap is within threshold
merged[-1] = (merged[-1][0], max(merged[-1][1], interval[1]))
else:
merged.append(list(interval))
# --- Step 8: Estimate hours per merged interval ---
estimated_hours = []
for start, end in merged:
raw_duration_h = (end - start) / 3600
est = max(raw_duration_h + 0.5, 0.5) # add 0.5h buffer, min 0.5h
estimated_hours.append(est)
# --- Step 9: Sum ---
total_hours = sum(estimated_hours)---
Session Detection from Point Events
Used for Claude, Codex, and Cursor timestamps (which are point events, not intervals):
def detect_sessions(timestamps: list[float], gap_h: float) -> list[tuple]:
"""
Given a sorted list of UTC epoch floats (point events),
group into sessions separated by gap_h hours.
Returns list of (session_start, session_end) tuples.
"""
if not timestamps:
return []
timestamps = sorted(timestamps)
sessions = []
session_start = timestamps[0]
session_end = timestamps[0]
for ts in timestamps[1:]:
if ts - session_end > gap_h * 3600:
sessions.append((session_start, session_end))
session_start = ts
session_end = ts
sessions.append((session_start, session_end))
return sessions---
Hour Estimate Formula
For each merged interval:
est = max(end - start + 0.5h, 0.5h)- The
+0.5hbuffer accounts for work done before the first tracked event and after the last (e.g., the developer was thinking before opening the editor). - The
min 0.5hfloor ensures single-commit sessions (where start == end) still contribute a meaningful estimate rather than 0.
---
Multi-Repo Projects
When a project spans multiple git repositories (e.g., a monorepo that was split, or a backend + frontend pair):
1. Run git_sessions.py on each repository separately. 2. Collect all session arrays. 3. Before reconciliation, merge all git session arrays into one list, sort by start time, and re-merge sessions within the gap threshold. 4. Proceed with the standard reconciliation algorithm above.
#!/usr/bin/env python3
"""
Count Claude Code user prompts for a specific project, with per-day breakdown.
Data sources:
Primary: ~/.claude/history.jsonl — one entry per submitted prompt, has "project" (abs path)
and "timestamp" (ms epoch).
Fallback: ~/.claude/projects/<encoded>/*.jsonl — session files, use for sessions that
predate history.jsonl. Filter type="user", exclude tool_result content.
Usage:
python3 claude_messages.py --project-path /abs/path/to/repo
python3 claude_messages.py --filter project-name [substring match on dir name]
python3 claude_messages.py --project-path /path --projects-dir ~/.claude/projects
Output JSON:
{
"project_path": "...",
"total_user_messages": N,
"daily": {"YYYY-MM-DD": count, ...},
"sources": {"history": N, "session_files": N}
}
"""
import os
import json
import argparse
import glob
from datetime import datetime, timezone
from collections import defaultdict
def path_to_encoded(abs_path):
"""Convert absolute path to Claude's encoded project dir name: /a/b → -a-b"""
return abs_path.replace("/", "-")
def find_project_dirs(projects_dir, project_path=None, name_filter=None):
"""Return list of matching Claude project dirs."""
projects_dir = os.path.expanduser(projects_dir)
if not os.path.isdir(projects_dir):
return []
if project_path:
encoded = path_to_encoded(project_path)
candidate = os.path.join(projects_dir, encoded)
return [candidate] if os.path.isdir(candidate) else []
return [
e.path for e in sorted(os.scandir(projects_dir), key=lambda e: e.name)
if e.is_dir() and name_filter and name_filter.lower() in e.name.lower()
]
def is_real_prompt(obj):
"""Return True if this session entry is an actual user prompt (not a tool result)."""
content = obj.get("message", {}).get("content", "")
if isinstance(content, str):
return True
if isinstance(content, list) and content:
# list[text] = prompt with pasted content; list[tool_result] = tool output
return content[0].get("type") != "tool_result"
return False
def collect_from_history(project_path):
"""
Collect prompts from ~/.claude/history.jsonl filtered by project path.
Returns: {date: count}, set of rounded timestamps (for dedup with session files).
"""
daily = defaultdict(int)
seen_ts = set()
history_path = os.path.expanduser("~/.claude/history.jsonl")
if not os.path.exists(history_path) or not project_path:
return daily, seen_ts
with open(history_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if obj.get("project") != project_path:
continue
ts_ms = obj.get("timestamp")
if ts_ms is None:
continue
ts_float = ts_ms / 1000.0
date = datetime.fromtimestamp(ts_float, tz=timezone.utc).strftime("%Y-%m-%d")
seen_ts.add(round(ts_float))
daily[date] += 1
return daily, seen_ts
def collect_from_session_files(project_dirs, project_path, seen_ts):
"""
Collect actual user prompts from session .jsonl files.
Skips entries already accounted for in history (dedup by rounded timestamp).
Returns {date: count}.
"""
daily = defaultdict(int)
for proj_dir in project_dirs:
for jsonl_file in glob.glob(os.path.join(proj_dir, "*.jsonl")):
try:
with open(jsonl_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if obj.get("type") != "user":
continue
if project_path and obj.get("cwd") != project_path:
continue
if not is_real_prompt(obj):
continue
ts_str = obj.get("timestamp", "")
try:
dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
ts_rounded = round(dt.timestamp())
date = dt.strftime("%Y-%m-%d")
except Exception:
continue
if ts_rounded not in seen_ts:
seen_ts.add(ts_rounded)
daily[date] += 1
except (IOError, PermissionError):
pass
return daily
def main():
parser = argparse.ArgumentParser(description="Count Claude Code prompts per project by day")
parser.add_argument("--project-path", help="Absolute path to the project repo (exact match)")
parser.add_argument("--filter", help="Substring match on Claude project dir name")
parser.add_argument("--projects-dir", default="~/.claude/projects")
args = parser.parse_args()
if not args.project_path and not args.filter:
print(json.dumps({"error": "Provide --project-path or --filter"}))
return
project_dirs = find_project_dirs(args.projects_dir, args.project_path, args.filter)
if not project_dirs:
result = {
"project_path": args.project_path or args.filter,
"total_user_messages": 0,
"daily": {},
"timestamps": [],
"note": "No matching Claude project directory found"
}
# Folder move detection: scan history.jsonl for same basename at different paths
# (history.jsonl stores exact absolute paths — no lossy encoding)
if args.project_path:
target_name = os.path.basename(args.project_path)
alternates = set()
history_path = os.path.expanduser("~/.claude/history.jsonl")
if os.path.exists(history_path):
with open(history_path, "r", encoding="utf-8") as f:
for line in f:
try:
obj = json.loads(line.strip())
proj = obj.get("project", "")
if (proj and proj != args.project_path and
os.path.basename(proj) == target_name):
alternates.add(proj)
except (json.JSONDecodeError, KeyError):
pass
# Also check project dir names (session cwd fields)
for entry in os.scandir(os.path.expanduser(args.projects_dir)):
if not entry.is_dir():
continue
for jf in glob.glob(os.path.join(entry.path, "*.jsonl")):
try:
with open(jf) as f:
obj = json.loads(f.readline().strip())
cwd = obj.get("cwd", "")
if (cwd and cwd != args.project_path and
os.path.basename(cwd) == target_name):
alternates.add(cwd)
except Exception:
pass
break # only need first line of first file per project
if alternates:
result["alternate_paths"] = sorted(alternates)
result["note"] = (
f"No Claude history at {args.project_path}, "
f"but found history for '{target_name}' at "
f"{len(alternates)} other path(s). Project may have been moved."
)
print(json.dumps(result, indent=2))
return
# Primary: history.jsonl (one entry per prompt, most reliable)
daily_history, seen_ts = collect_from_history(args.project_path)
# Fallback: session files for prompts not captured in history
daily_sessions = collect_from_session_files(project_dirs, args.project_path, seen_ts)
# Merge
all_dates = set(daily_history) | set(daily_sessions)
daily_merged = {
date: daily_history.get(date, 0) + daily_sessions.get(date, 0)
for date in sorted(all_dates)
}
print(json.dumps({
"project_path": args.project_path or args.filter,
"project_dirs": project_dirs,
"total_user_messages": sum(daily_merged.values()),
"daily": daily_merged,
"sources": {
"history": sum(daily_history.values()),
"session_files": sum(daily_sessions.values()),
},
# Raw sorted timestamps (Unix epoch floats) — use for merged session detection
"timestamps": sorted(seen_ts),
}, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Count Codex CLI user prompts for a specific project, with per-day breakdown.
Data source: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
- session_meta entry: payload.cwd = project directory
- event_msg entry: payload.type == "user_message" = actual user prompt
Usage:
python3 codex_messages.py --project-path /abs/path/to/repo
python3 codex_messages.py --filter project-name
Output JSON:
{
"project_path": "...",
"total_user_messages": N,
"daily": {"YYYY-MM-DD": count, ...},
"timestamps": [epoch_float, ...],
"sessions_found": N
}
"""
import os
import json
import argparse
import glob
from datetime import datetime, timezone
from collections import defaultdict
def scan_sessions(sessions_dir, project_path=None, name_filter=None):
"""
Scan Codex session files. Filter by cwd == project_path (exact) or
basename match for name_filter (substring).
Returns (daily counts, sorted timestamp list, session count).
"""
sessions_dir = os.path.expanduser(sessions_dir)
if not os.path.isdir(sessions_dir):
return {}, [], 0
daily = defaultdict(int)
timestamps = []
sessions_found = 0
for jsonl_path in sorted(glob.glob(
os.path.join(sessions_dir, "**", "*.jsonl"), recursive=True
)):
cwd = None
session_prompts = []
for line in open(jsonl_path, "r", encoding="utf-8"):
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
# Extract cwd from session_meta (always first entry)
if obj.get("type") == "session_meta":
cwd = obj.get("payload", {}).get("cwd", "")
continue
# Check cwd match
if cwd is None:
continue
if project_path and cwd != project_path:
break # wrong project, skip rest of file
if name_filter and name_filter.lower() not in os.path.basename(cwd).lower():
break
# Count user prompts
if (obj.get("type") == "event_msg" and
obj.get("payload", {}).get("type") == "user_message"):
ts_str = obj.get("timestamp", "")
try:
dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
ts_epoch = dt.timestamp()
date = dt.strftime("%Y-%m-%d")
session_prompts.append((date, ts_epoch))
except Exception:
session_prompts.append((None, None))
if session_prompts:
sessions_found += 1
for date, ts in session_prompts:
if date:
daily[date] += 1
if ts:
timestamps.append(ts)
return dict(sorted(daily.items())), sorted(timestamps), sessions_found
def find_alternate_paths(sessions_dir, project_path):
"""
When no sessions match project_path, look for sessions with the same
basename (last path component) at different parent paths.
Suggests the project may have been moved.
"""
sessions_dir = os.path.expanduser(sessions_dir)
if not os.path.isdir(sessions_dir):
return []
target_name = os.path.basename(project_path)
alternates = set()
for jsonl_path in glob.glob(
os.path.join(sessions_dir, "**", "*.jsonl"), recursive=True
):
for line in open(jsonl_path, "r", encoding="utf-8"):
try:
obj = json.loads(line.strip())
except json.JSONDecodeError:
continue
if obj.get("type") == "session_meta":
cwd = obj.get("payload", {}).get("cwd", "")
if cwd and cwd != project_path and os.path.basename(cwd) == target_name:
alternates.add(cwd)
break # only read session_meta
return sorted(alternates)
def main():
parser = argparse.ArgumentParser(description="Count Codex user prompts per project by day")
parser.add_argument("--project-path", help="Absolute path to the project repo (exact match)")
parser.add_argument("--filter", help="Substring match on project basename")
parser.add_argument("--sessions-dir", default="~/.codex/sessions")
args = parser.parse_args()
if not args.project_path and not args.filter:
print(json.dumps({"error": "Provide --project-path or --filter"}))
return
daily, timestamps, sessions_found = scan_sessions(
args.sessions_dir, args.project_path, args.filter
)
result = {
"project_path": args.project_path or args.filter,
"total_user_messages": sum(daily.values()),
"daily": daily,
"timestamps": timestamps,
"sessions_found": sessions_found,
}
# Folder move detection
if sum(daily.values()) == 0 and args.project_path:
alternates = find_alternate_paths(args.sessions_dir, args.project_path)
if alternates:
result["alternate_paths"] = alternates
result["note"] = (
f"No Codex sessions found at {args.project_path}, "
f"but found sessions for '{os.path.basename(args.project_path)}' "
f"at {len(alternates)} other path(s). Project may have been moved."
)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Count Cursor IDE user prompts for a specific project, with per-day breakdown.
Data sources (paths auto-detected per platform):
Primary: <Cursor User dir>/globalStorage/state.vscdb
— cursorDiskKV table: composerData:{sessionId} (workspace URI) and
bubbleId:{sessionId}:{messageId} (per-message timestamps)
Fallback: <Cursor User dir>/workspaceStorage/*/state.vscdb
— ItemTable: composer.composerData (session-level timestamps only)
— ItemTable: legacy chatdata keys (per-message timestamps)
Cursor User dir:
macOS: ~/Library/Application Support/Cursor/User
Windows: %APPDATA%/Cursor/User
Linux: ~/.config/Cursor/User
Usage:
python3 cursor_messages.py --project-path /abs/path/to/repo
python3 cursor_messages.py --filter project-name
Output JSON:
{
"project_path": "...",
"total_user_messages": N,
"daily": {"YYYY-MM-DD": count, ...},
"timestamps": [epoch_float, ...],
"sessions_found": N,
"sources": {"global_storage": N, "workspace_storage": N}
}
"""
import os
import sys
import json
import argparse
import sqlite3
from datetime import datetime, timezone
from collections import defaultdict
from urllib.parse import unquote, urlparse
def get_cursor_data_dir():
"""Return the Cursor User data directory for the current platform."""
if sys.platform == "darwin":
return os.path.expanduser("~/Library/Application Support/Cursor/User")
elif sys.platform == "win32":
return os.path.join(os.environ.get("APPDATA", ""), "Cursor", "User")
else: # Linux
return os.path.expanduser("~/.config/Cursor/User")
def uri_to_path(uri):
"""Convert a file:// URI to an absolute path."""
if not uri:
return ""
parsed = urlparse(uri)
if parsed.scheme == "file":
return unquote(parsed.path)
return uri
def paths_match(path1, path2):
"""Check if two paths refer to the same directory."""
return os.path.normpath(path1) == os.path.normpath(path2)
def scan_workspace_mappings(cursor_dir):
"""Scan workspaceStorage dirs → dict of workspace_id → project_path."""
ws_dir = os.path.join(cursor_dir, "workspaceStorage")
if not os.path.isdir(ws_dir):
return {}
mappings = {}
for entry in os.scandir(ws_dir):
if not entry.is_dir():
continue
ws_json = os.path.join(entry.path, "workspace.json")
if not os.path.exists(ws_json):
continue
try:
with open(ws_json, "r", encoding="utf-8") as f:
data = json.load(f)
folder = data.get("folder", "")
if folder:
mappings[entry.name] = uri_to_path(folder)
except (json.JSONDecodeError, IOError):
pass
return mappings
def _extract_bubble_timestamp(bubble):
"""
Extract a UTC epoch float from a Cursor bubble/message object.
Tries fields in priority order (mirrors cursor-history's fallback chain).
Returns epoch float or None.
"""
# 1. createdAt ISO string (new format, >= Sept 2025)
created_at = bubble.get("createdAt")
if created_at and isinstance(created_at, str):
try:
dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
return dt.timestamp()
except (ValueError, TypeError):
pass
# 2. timingInfo fields (Unix ms)
timing = bubble.get("timingInfo")
if isinstance(timing, dict):
for field in ("clientStartTime", "clientRpcSendTime",
"clientSettleTime", "clientEndTime"):
val = timing.get(field)
if val and isinstance(val, (int, float)) and val > 1_000_000_000_000:
return val / 1000.0
# 3. Plain timestamp field (legacy, ms epoch)
ts = bubble.get("timestamp")
if ts and isinstance(ts, (int, float)) and ts > 1_000_000_000_000:
return ts / 1000.0
return None
def collect_from_global_storage(cursor_dir, project_path=None, name_filter=None):
"""
Collect user prompt timestamps from global storage's cursorDiskKV table.
Returns (daily_counts, timestamps, sessions_found).
"""
global_db = os.path.join(cursor_dir, "globalStorage", "state.vscdb")
if not os.path.exists(global_db):
return {}, [], 0
daily = defaultdict(int)
timestamps = []
sessions_found = 0
try:
conn = sqlite3.connect(f"file:{global_db}?mode=ro", uri=True)
cur = conn.cursor()
# Check if cursorDiskKV table exists
cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'"
)
if not cur.fetchone():
conn.close()
return {}, [], 0
# Get all composer sessions with their workspace URIs
cur.execute(
"SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'"
)
matching_session_ids = []
for key, value in cur.fetchall():
try:
session_id = key.split(":", 1)[1]
data = json.loads(value) if isinstance(value, str) else {}
ws_uri = data.get("workspaceUri", "")
ws_path = uri_to_path(ws_uri)
if project_path:
if not ws_path or not paths_match(ws_path, project_path):
continue
elif name_filter:
basename = os.path.basename(os.path.normpath(ws_path)) if ws_path else ""
if not basename or name_filter.lower() not in basename.lower():
continue
matching_session_ids.append(session_id)
except (json.JSONDecodeError, IndexError, AttributeError):
continue
# For each matching session, get user-message bubble data
for session_id in matching_session_ids:
session_ts = []
cur.execute(
"SELECT value FROM cursorDiskKV WHERE key LIKE ?",
(f"bubbleId:{session_id}:%",),
)
for (value,) in cur.fetchall():
try:
bubble = json.loads(value) if isinstance(value, str) else {}
except json.JSONDecodeError:
continue
# Only count user messages (type=1)
if bubble.get("type") != 1:
continue
ts_epoch = _extract_bubble_timestamp(bubble)
if ts_epoch is not None:
session_ts.append(ts_epoch)
if session_ts:
sessions_found += 1
for ts in session_ts:
date = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
daily[date] += 1
timestamps.append(ts)
conn.close()
except (sqlite3.Error, IOError):
pass
return dict(sorted(daily.items())), sorted(timestamps), sessions_found
def collect_from_workspace_storage(cursor_dir, project_path=None,
name_filter=None, seen_ts=None):
"""
Fallback: collect timestamps from per-workspace state.vscdb databases.
Reads composer.composerData and legacy chatdata keys from ItemTable.
Deduplicates against already-seen timestamps from global storage.
Returns (daily_counts, timestamps, sessions_found).
"""
if seen_ts is None:
seen_ts = set()
ws_mappings = scan_workspace_mappings(cursor_dir)
if not ws_mappings:
return {}, [], 0
daily = defaultdict(int)
timestamps = []
sessions_found = 0
for ws_id, ws_path in ws_mappings.items():
if project_path and not paths_match(ws_path, project_path):
continue
if name_filter:
basename = os.path.basename(os.path.normpath(ws_path))
if name_filter.lower() not in basename.lower():
continue
db_path = os.path.join(cursor_dir, "workspaceStorage", ws_id, "state.vscdb")
if not os.path.exists(db_path):
continue
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
cur = conn.cursor()
# --- Composer data (session-level timestamps) ---
cur.execute(
"SELECT value FROM ItemTable WHERE key = 'composer.composerData'"
)
row = cur.fetchone()
if row and row[0]:
try:
data = json.loads(row[0])
for composer in data.get("allComposers", []):
created = composer.get("createdAt")
updated = composer.get("lastUpdatedAt")
for ts_ms in (created, updated):
if (ts_ms and isinstance(ts_ms, (int, float))
and ts_ms > 1_000_000_000_000):
ts_epoch = ts_ms / 1000.0
ts_rounded = round(ts_epoch)
if ts_rounded not in seen_ts:
seen_ts.add(ts_rounded)
date = datetime.fromtimestamp(
ts_epoch, tz=timezone.utc
).strftime("%Y-%m-%d")
daily[date] += 1
timestamps.append(ts_epoch)
if created or updated:
sessions_found += 1
except json.JSONDecodeError:
pass
# --- Legacy chat data keys (per-message timestamps) ---
for key in (
"workbench.panel.aichat.view.aichat.chatdata",
"workbench.panel.chat.view.chat.chatdata",
):
cur.execute("SELECT value FROM ItemTable WHERE key = ?", (key,))
row = cur.fetchone()
if not (row and row[0]):
continue
try:
data = json.loads(row[0])
for session in data.get("chatSessions", data.get("tabs", [])):
has_msgs = False
for msg in session.get("messages", session.get("bubbles", [])):
if msg.get("role") != "user":
continue
ts_ms = msg.get("timestamp")
if (ts_ms and isinstance(ts_ms, (int, float))
and ts_ms > 1_000_000_000_000):
ts_epoch = ts_ms / 1000.0
ts_rounded = round(ts_epoch)
if ts_rounded not in seen_ts:
seen_ts.add(ts_rounded)
date = datetime.fromtimestamp(
ts_epoch, tz=timezone.utc
).strftime("%Y-%m-%d")
daily[date] += 1
timestamps.append(ts_epoch)
has_msgs = True
if has_msgs:
sessions_found += 1
except json.JSONDecodeError:
pass
conn.close()
except (sqlite3.Error, IOError):
pass
return dict(sorted(daily.items())), sorted(timestamps), sessions_found
def find_alternate_paths(cursor_dir, project_path):
"""
When no sessions match project_path, look for workspaces with the same
basename at different parent paths.
"""
ws_mappings = scan_workspace_mappings(cursor_dir)
target_name = os.path.basename(os.path.normpath(project_path))
alternates = set()
for _ws_id, ws_path in ws_mappings.items():
if not ws_path:
continue
if (not paths_match(ws_path, project_path)
and os.path.basename(os.path.normpath(ws_path)) == target_name):
alternates.add(ws_path)
# Also check global storage composerData for workspaceUri matches
global_db = os.path.join(cursor_dir, "globalStorage", "state.vscdb")
if os.path.exists(global_db):
try:
conn = sqlite3.connect(f"file:{global_db}?mode=ro", uri=True)
cur = conn.cursor()
cur.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name='cursorDiskKV'"
)
if cur.fetchone():
cur.execute(
"SELECT value FROM cursorDiskKV WHERE key LIKE 'composerData:%'"
)
for (value,) in cur.fetchall():
try:
data = json.loads(value) if isinstance(value, str) else {}
ws_uri = data.get("workspaceUri", "")
ws_path = uri_to_path(ws_uri)
if (ws_path and not paths_match(ws_path, project_path)
and os.path.basename(os.path.normpath(ws_path))
== target_name):
alternates.add(ws_path)
except (json.JSONDecodeError, AttributeError):
pass
conn.close()
except (sqlite3.Error, IOError):
pass
return sorted(alternates)
def main():
parser = argparse.ArgumentParser(
description="Count Cursor IDE prompts per project by day"
)
parser.add_argument(
"--project-path",
help="Absolute path to the project repo (exact match)",
)
parser.add_argument(
"--filter", help="Substring match on project basename"
)
args = parser.parse_args()
if not args.project_path and not args.filter:
print(json.dumps({"error": "Provide --project-path or --filter"}))
return
cursor_dir = get_cursor_data_dir()
# Primary: global storage (per-message timestamps from cursorDiskKV)
daily_global, ts_global, sessions_global = collect_from_global_storage(
cursor_dir, args.project_path, args.filter
)
# Fallback: workspace storage (session-level + legacy per-message timestamps)
seen_ts = {round(t) for t in ts_global}
daily_ws, ts_ws, sessions_ws = collect_from_workspace_storage(
cursor_dir, args.project_path, args.filter, seen_ts
)
# Merge
all_dates = set(daily_global) | set(daily_ws)
daily_merged = {
date: daily_global.get(date, 0) + daily_ws.get(date, 0)
for date in sorted(all_dates)
}
all_timestamps = sorted(set(ts_global + ts_ws))
result = {
"project_path": args.project_path or args.filter,
"total_user_messages": sum(daily_merged.values()),
"daily": daily_merged,
"timestamps": all_timestamps,
"sessions_found": sessions_global + sessions_ws,
"sources": {
"global_storage": sum(daily_global.values()),
"workspace_storage": sum(daily_ws.values()),
},
}
# Folder move detection
if sum(daily_merged.values()) == 0 and args.project_path:
alternates = find_alternate_paths(cursor_dir, args.project_path)
if alternates:
result["alternate_paths"] = alternates
result["note"] = (
f"No Cursor sessions found at {args.project_path}, "
f"but found workspaces for "
f"'{os.path.basename(args.project_path)}' "
f"at {len(alternates)} other path(s). "
f"Project may have been moved."
)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Parse git log to detect work sessions and estimate total hours.
Usage:
python3 git_sessions.py <repo_path> [--since YYYY-MM-DD] [--until YYYY-MM-DD]
python3 git_sessions.py /path/to/repo --since 2026-01-01 --until 2026-02-28
Output: JSON with sessions, daily hours, and totals.
"""
import subprocess
import json
import sys
import argparse
from datetime import datetime, timedelta
from collections import defaultdict
SESSION_GAP_HOURS = 1.5 # commits > this apart = new session
SESSION_BUFFER = 0.5 # hours added per session (startup/context-switching)
MIN_SESSION_HOURS = 0.5 # minimum session duration
def get_commits(repo_path, since=None, until=None):
cmd = ["git", "-C", repo_path, "log", "--format=%H\t%ai\t%s", "--no-merges"]
if since:
cmd += [f"--since={since}"]
if until:
cmd += [f"--until={until}"]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"Error: {result.stderr}", file=sys.stderr)
return []
commits = []
for line in result.stdout.strip().split("\n"):
if not line:
continue
parts = line.split("\t", 2)
if len(parts) >= 2:
sha, ts_str = parts[0], parts[1]
msg = parts[2] if len(parts) > 2 else ""
try:
# Parse ISO 8601 with timezone offset
ts = datetime.fromisoformat(ts_str)
commits.append({"sha": sha, "ts": ts, "msg": msg})
except ValueError:
pass
return sorted(commits, key=lambda c: c["ts"])
def detect_sessions(commits):
"""Group commits into sessions (gap > SESSION_GAP_HOURS = new session)."""
if not commits:
return []
sessions = []
current = [commits[0]]
for commit in commits[1:]:
gap = (commit["ts"] - current[-1]["ts"]).total_seconds() / 3600
if gap > SESSION_GAP_HOURS:
sessions.append(current)
current = [commit]
else:
current.append(commit)
sessions.append(current)
return sessions
def session_hours(session):
"""Duration of a session including buffer, minimum enforced."""
if len(session) == 1:
raw = 0.0
else:
raw = (session[-1]["ts"] - session[0]["ts"]).total_seconds() / 3600
return max(raw + SESSION_BUFFER, MIN_SESSION_HOURS)
def main():
parser = argparse.ArgumentParser(description="Estimate work hours from git history")
parser.add_argument("repo", help="Path to git repository")
parser.add_argument("--since", help="Start date YYYY-MM-DD")
parser.add_argument("--until", help="End date YYYY-MM-DD")
args = parser.parse_args()
commits = get_commits(args.repo, args.since, args.until)
if not commits:
print(json.dumps({"error": "No commits found"}))
return
sessions = detect_sessions(commits)
# Build output
session_data = []
daily_hours = defaultdict(float)
total_hours = 0.0
for s in sessions:
start = s[0]["ts"]
end = s[-1]["ts"]
h = session_hours(s)
date_str = start.strftime("%Y-%m-%d")
daily_hours[date_str] += h
total_hours += h
session_data.append({
"date": date_str,
"start": start.strftime("%H:%M"),
"end": end.strftime("%H:%M"),
"start_h": round(start.hour + start.minute / 60, 3),
"end_h": round((end.hour + end.minute / 60) + SESSION_BUFFER, 3),
"duration_h": round(h, 2),
"commits": len(s),
})
print(json.dumps({
"repo": args.repo,
"total_commits": len(commits),
"total_sessions": len(sessions),
"total_hours": round(total_hours, 2),
"date_range": {
"first": commits[0]["ts"].strftime("%Y-%m-%d"),
"last": commits[-1]["ts"].strftime("%Y-%m-%d"),
},
"sessions": session_data,
"daily_hours": dict(sorted(daily_hours.items())),
}, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Fetch WakaTime coding time from the WakaTime API.
Reads API key from ~/.wakatime.cfg [settings] api_key.
Usage:
python3 wakatime_fetch.py --start 2026-01-15 --end 2026-02-02
python3 wakatime_fetch.py --start 2026-01-15 --end 2026-02-02 --project my-project
Output: JSON with daily summaries and per-project breakdown.
"""
import configparser
import os
import json
import sys
import argparse
import urllib.request
import urllib.parse
import base64
from datetime import datetime, timedelta
def read_api_key():
cfg_path = os.path.expanduser("~/.wakatime.cfg")
if not os.path.exists(cfg_path):
return None
config = configparser.ConfigParser()
config.read(cfg_path)
return config.get("settings", "api_key", fallback=None)
def api_request(endpoint, api_key, params=None):
base_url = "https://api.wakatime.com/api/v1"
url = f"{base_url}{endpoint}"
if params:
url += "?" + urllib.parse.urlencode(params)
# WakaTime uses Basic Auth with base64-encoded API key
encoded_key = base64.b64encode(api_key.encode()).decode()
req = urllib.request.Request(url, headers={
"Authorization": f"Basic {encoded_key}",
"User-Agent": "project-time-tracker-skill/1.0",
})
try:
with urllib.request.urlopen(req, timeout=15) as response:
return json.loads(response.read().decode())
except urllib.error.HTTPError as e:
return {"error": f"HTTP {e.code}: {e.reason}"}
except Exception as e:
return {"error": str(e)}
def fetch_summaries(api_key, start, end, project=None):
params = {"start": start, "end": end}
if project:
params["project"] = project
data = api_request("/users/current/summaries", api_key, params)
if "error" in data:
return data
# Parse daily summaries
daily = []
project_totals = {}
total_seconds = 0
for day in data.get("data", []):
date = day.get("range", {}).get("date", "")
day_seconds = day.get("grand_total", {}).get("total_seconds", 0)
total_seconds += day_seconds
if day_seconds > 0:
daily.append({
"date": date,
"hours": round(day_seconds / 3600, 2),
"text": day.get("grand_total", {}).get("text", ""),
})
# Aggregate per-project
for proj in day.get("projects", []):
pname = proj.get("name", "unknown")
psecs = proj.get("total_seconds", 0)
project_totals[pname] = project_totals.get(pname, 0) + psecs
return {
"start": start,
"end": end,
"total_hours": round(total_seconds / 3600, 2),
"daily": daily,
"projects": [
{"project": k, "hours": round(v / 3600, 2)}
for k, v in sorted(project_totals.items(), key=lambda x: -x[1])
],
}
def fetch_durations(api_key, start, end, project=None):
"""
Fetch coding duration intervals from the /durations API.
Each entry has `time` (epoch start) and `duration` (seconds).
Returns list of [start_epoch, end_epoch] intervals for union computation.
Note: /durations requires one request per day, so this iterates over the date range.
"""
intervals = []
current = datetime.strptime(start, "%Y-%m-%d")
end_dt = datetime.strptime(end, "%Y-%m-%d")
while current <= end_dt:
date_str = current.strftime("%Y-%m-%d")
params = {"date": date_str}
if project:
params["project"] = project
data = api_request("/users/current/durations", api_key, params)
if "error" not in data:
for entry in data.get("data", []):
t = entry.get("time", 0)
d = entry.get("duration", 0)
if t and d > 0:
intervals.append([t, t + d])
current += timedelta(days=1)
# Merge adjacent/overlapping intervals (WakaTime may have per-file splits)
if not intervals:
return intervals
intervals.sort()
merged = [intervals[0]]
for s, e in intervals[1:]:
if s <= merged[-1][1] + 60: # 60s tolerance for file switches
merged[-1][1] = max(merged[-1][1], e)
else:
merged.append([s, e])
return merged
def main():
parser = argparse.ArgumentParser(description="Fetch WakaTime coding stats")
parser.add_argument("--start", required=True, help="Start date YYYY-MM-DD")
parser.add_argument("--end", required=True, help="End date YYYY-MM-DD")
parser.add_argument("--project", help="Filter by project name")
args = parser.parse_args()
api_key = read_api_key()
if not api_key:
print(json.dumps({"error": "No WakaTime API key found in ~/.wakatime.cfg"}))
sys.exit(1)
result = fetch_summaries(api_key, args.start, args.end, args.project)
# Also fetch duration intervals for union computation
# Only do this for active days to avoid hammering the API for empty days
active_dates = [d["date"] for d in result.get("daily", [])] if "error" not in result else []
all_intervals = []
for date_str in active_dates:
params = {"date": date_str}
if args.project:
params["project"] = args.project
data = api_request("/users/current/durations", api_key, params)
if "error" not in data:
for entry in data.get("data", []):
t = entry.get("time", 0)
d = entry.get("duration", 0)
if t and d > 0:
all_intervals.append([t, t + d])
# Merge adjacent per-file intervals
if all_intervals:
all_intervals.sort()
merged_intervals = [all_intervals[0]]
for s, e in all_intervals[1:]:
if s <= merged_intervals[-1][1] + 60:
merged_intervals[-1][1] = max(merged_intervals[-1][1], e)
else:
merged_intervals.append([s, e])
result["intervals"] = merged_intervals
else:
result["intervals"] = []
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()