GitHub Personal Stats GitHub

Collecting it

The collector, the daemon, the editor extension, and where your record is kept.

Collecting Activity

Two commands and two editor plugins, none of which are needed by the cards above.

The collector reads databases your editor keeps and the daemon listens on a socket, so neither is published to crates.io alongside the renderer; build them from the repository:

git clone https://github.com/liuchong/github-personal-stats
cd github-personal-stats
cargo install --path crates/collect
cargo install --path crates/daemon
github-personal-stats-collect

That reads what is on this machine and writes a record. Once the configuration below is in place it needs no arguments, and a timer can run it for you:

github-personal-stats-daemon install

Installing the daemon registers it with launchd or systemd, so it starts with your session, rebuilds the record on a timer, and listens on loopback for the editor extension.

It also serves a page showing what it has, which is the quickest way to see whether any of this is working. The page needs the same shared secret the plugins use, because anything running on your machine can reach a loopback port, including a page you have open in a browser. The daemon prints the address with the secret in it when it starts:

listening on http://127.0.0.1:7391
panel http://127.0.0.1:7391/?token=…

The extension in plugins/vscode covers Cursor, VS Code and VSCodium from one build. It reports nothing but a timestamp, a date, and the kind of file open — no paths, no project names, no content — to the daemon on your own machine.

Emacs

The mode in plugins/emacs reports the same three things, and needs no build:

(add-to-list 'load-path "/path/to/github-personal-stats/plugins/emacs")
(require 'github-personal-stats)
(github-personal-stats-mode 1)

There is nothing else to configure, because it does not require the daemon to be running. If the daemon answers, pulses go to it and appear in its panel at once; if it does not, they are appended to the journal on disk that the collector reads anyway. M-x github-personal-stats-status says which of the two is happening, and the mode line shows stats while reporting, stats~ when it last fell back to the journal, and stats! when pulses are piling up rather than going anywhere.

That fallback is what makes Emacs on a machine with no daemon worth setting up — a server you ssh into, say. Nothing is lost and nothing has to be running; the pulses wait in the journal until the collector next runs there:

github-personal-stats-collect --sink git --repo ~/.local/state/github-personal-stats/storage

You can also insist on one or the other with github-personal-stats-sink, which takes auto (the default), daemon or journal.

What the mode deliberately does not do is work out hours, decide what a language is, or publish anything. A pulse is a moment. Moments become sessions, and sessions become a published record, once for every source rather than once per editor — which is why adding an editor cannot change what an hour means, and why an hour spent in Emacs while an agent worked in a terminal is still one hour rather than two.

Telling whether it is working

An installed plugin and a working one look identical from the extensions list, so ask instead:

github-personal-stats-daemon status
daemon      listening on 127.0.0.1:7391
token       /Users/you/.local/state/github-personal-stats/token
publishing  git git@github.com:you/personal-stats-data.git via /Users/you/.local/state/github-personal-stats/storage on master
editors     vscode 1.5.1 — 412 pulses on 2026-08-24, last 30s ago
editors     emacs 1.5.1 — 96 pulses on 2026-08-24, last 2m ago
collected   100 days, agent 146h 29m, editor 3h 12m

Each line is something that can be separately broken. editors no plugin has loaded means exactly that: reload the editor window. token missing means no plugin can report at all, because the shared secret it needs is not there yet.

The day is named rather than called "today" because the two are not always the same thing. A plugin files its work under the date its own machine shows, so east of UTC the journal is already on tomorrow's file for the first hours of the evening — and those are the hours most likely to be worked. Naming the day it found means the line cannot claim to be about now when it is about yesterday.

A plugin says hello when it starts, so a loaded plugin is visible before it has any work to report:

editors     vscode 1.5.1 — loaded 12m ago, nothing reported recently

That line is the normal state of a window in the background: a window nobody is looking at is not somewhere anybody is working.

Your editor's status bar says the same thing from the other side:

Editor time appears in the record only after the next rebuild, so a few minutes of reported work shows up as editor 0h 0m until then.

What the plugins measure

Time at the editor. Not time you spent typing.

The first version of the VS Code extension measured typing, by watching the things only a person does: moving the caret, switching file, saving. It is a more precise question and it turned out to be the wrong one. Over thirty-seven hours of real work it reported nothing at all, because a day spent directing an agent raises none of those events — the prompt goes into a panel that is not a document, and the edits come back from something that is not you.

So a pulse is sent when the window takes focus and every pulseSeconds while it keeps focus, whoever is typing. Each pulse is filed under the kind of file open at the time; a window showing an output panel or a settings page still counts, filed under no language, because you were there either way.

The one honest limitation: a window left focused while you walk away is counted until it loses focus. The idle timeout bounds how far that can run and cannot detect it. Reporting a little too much for a coffee break is a smaller error than reporting nothing for a working day.

The Emacs mode adds a cutoff there, and this is the only place the two plugins differ. It stops counting after ten minutes without input, because Emacs is habitually left in front of you for days at a time and a focused frame alone would report sleep as work. Reading, scrolling and directing an agent all produce input, so the grace period costs nothing real; github-personal-stats-idle-seconds changes it.

In a terminal, that cutoff is the whole test. A tty — and anything under tmux or screen — may never report focus, and Emacs then answers "definitely not focused" for ever, so requiring focus would record a day at the terminal as no work at all. Where focus can be known it is believed; where it cannot, whether Emacs was given anything to do is the only signal there is.

Agent time is measured separately and does not need the plugin at all. Lines an AI wrote, which models wrote them, and the time spent changing code come from the editor's own record of what it generated, which is read directly. This is why each day keeps editor and agent as two numbers rather than one: they overlap by design, a day can be long in one and empty in the other, and adding them would count neither honestly.

The practical consequences:

So editor 0h 0m next to a large agent figure now means what it says: nothing was reported from a focused editor window. Either no editor is running, or the plugin has not loaded — which status distinguishes.

When the counting itself changes

The record keeps whichever reading of a day saw the most, which is right while the counting stays the same, and wrong the moment it changes: a larger figure from an old rule is not a fuller reading, and nothing smaller can ever replace it. Left alone, a figure could only ever be corrected upwards.

So a change of rule can be asked for explicitly:

github-personal-stats-collect --recount

This replaces the hours and lines recorded for the days the current run can actually see. Each measure is replaced only where the run has something to say about it, because the sources forget at different rates: a day whose editor lines have aged out still arrives with its commits, and treating that as a reading of zero lines would delete the only copy of them. Days older than your sources remember are not touched at all, and the daemon never does this on its own.

Activity Storage

The machine that has the records is not the machine that renders your cards, so the record has to travel. There are three ways to move it, and which one you use is written in a configuration file rather than chosen on every command.

The configuration lives at <state>/config, which on Linux and macOS is ~/.local/state/github-personal-stats/config unless XDG_STATE_HOME says otherwise. Lines are name = value using an option's own name without the dashes, # starts a comment, and a flag on the command line overrides the file.

How your history is kept

This is worth understanding before choosing a backend, because it is the reason the record is shaped the way it is.

Your editor does not keep its detail forever. Cursor holds roughly the last thirty days, so a collection run today can tell you a great deal about this week and nothing whatsoever about April. Run the collector and it will report April as zero hours — not because you did not work, but because nothing on your machine remembers.

So a collection is never written out as your history. It is merged into it. Each day is kept in its own file, and a day's file is replaced only when a later reading of *that same day* saw more than the one before. A day that has aged out of your editor's store keeps the figures it was recorded with, which means your history grows longer than any source it was read from:

snapshots/m-1a2b3c4d/manifest.json
snapshots/m-1a2b3c4d/2026-05-12.json
snapshots/m-1a2b3c4d/2026-08-25.json
snapshots/m-1a2b3c4d/2026-08-26.json

Two practical consequences. Running the collector twice in an hour is harmless — it changes nothing, so there is nothing to commit. And the record is the only copy of anything older than about a month: deleting a day's file does not free up something that can be collected again, it forgets that day permanently. The one time you would want to delete one is if a day was recorded wrongly, since a smaller correction cannot otherwise replace a larger figure already written.

The manifest holds an index of the days and a running total of all of them, so anything that just wants your lifetime hours reads one small file rather than your whole history.

File

The default. Everything under one directory. This is what you want if the machine that collects also renders.

sink = file
output = /Users/you/.local/state/github-personal-stats/record

Git

A git repository as storage. This is the one to use when your cards are rendered by a scheduled GitHub Actions run, because CI has no way to read your laptop.

sink = git
origin = git@github.com:your-login/personal-stats-data.git
repo = /Users/you/.local/state/github-personal-stats/storage
branch = master

repo is a working checkout the collector owns: it clones it if it is not there, brings it up to date before each commit, and you can delete it whenever you like. Keep it in the state directory rather than among your projects — it is storage, not somewhere to work.

Each machine writes into snapshots/<machine>/ under its own random identifier, so several machines can share one repository with nothing to merge. Whoever renders the cards adds the days up.

Because a run only rewrites the days it learned something about, the history reads as a log of your work rather than a wall of identical commits:

Record activity for 2026-08-26
Record activity for 3 days, 2026-08-24 to 2026-08-26

A collection that finds nothing new commits nothing, so a collector on a timer does not fill the history with noise, and a remote you cannot reach right now leaves the commit waiting locally for the next run.

Nothing here is specific to GitHub. Any git remote both your machine and your renderer can reach will do, including one on a server of your own that the public internet cannot see.

HTTP

For sending snapshots to a service that collects from many people. The interface exists; the implementation does not, because the protocol and what counts as an identity are decisions for whoever runs such a service.

Reading private storage from your profile workflow

If the storage repository is private — and per-day detail is a good reason to keep it private — your profile repository's workflow needs permission to read it. The built-in GITHUB_TOKEN cannot: it is scoped to the repository the workflow runs in.

This needs no personal access token. A read-only deploy key is narrower and enough:

ssh-keygen -t ed25519 -N "" -C "stats renderer" -f /tmp/key
gh repo deploy-key add /tmp/key.pub --title "stats renderer (read-only)" \
  --repo your-login/personal-stats-data
gh secret set STATS_DATA_KEY --repo your-login/your-login < /tmp/key
rm /tmp/key /tmp/key.pub

Then check the storage out alongside your profile repository, and point a render step at it:

- name: Read the storage repository
  uses: actions/checkout@v4
  with:
    repository: your-login/personal-stats-data
    ssh-key: ${{ secrets.STATS_DATA_KEY }}
    path: storage

- uses: liuchong/github-personal-stats@v1.5.1
  with:
    card: activity
    path: profile/activity.svg
    options: --activity-record storage/snapshots --width 900 --height auto

The activity card needs no token, so that step has none: it renders from the checkout and nothing else.

Rendered SVGs are then committed back into your profile repository with the built-in token, so the images are public while the day-by-day record is not. A token is only needed to write to a repository other than the one the workflow runs in.

Two things are worth knowing before you conclude a key is broken. A deploy key authenticates git transport and not the GitHub REST API, so reading the storage through the API would need a personal access token — that is the reason this design speaks git instead. And actions/checkout fails against a repository with no commits in it, in a way that reads like a permission problem, so publish once before judging your credentials.