Important
See a feature you need on another OS, like Windows Recall? On Linux you can build it yourself, today.
Think of one nobody has built yet, like going from a screenshot back to exactly what you were looking at?
You can add that today too.
On other operating systems you can't, or at least not quickly.
A Windows Recall–style screen memory for KDE Plasma 6 on Wayland. Every minute (or on a hotkey) it screenshots the active window and records what you were looking at: the app, the window title, the browser URL, the paper and page open in Zotero, the Anki card under review, the Obsidian note, the file open in Neovim (in kitty or Neovide), the last shell command and its output, the Claude Code session, and the OCR'd text. Each capture is a small WebP plus a JSON file, searchable from the command line. You can jump back to the source with one key: the web page, the PDF page in Zotero, the Anki card, the Obsidian note, the file and line in Neovim, or the kitty window.
linux-recall-demo.mp4
- Capture: active window only, downscaled to logical pixels (about 150 KB per shot); hotkey
Meta+Alt+Ror a systemd user service every 60 s - Deduplication: same app and a perceptually similar image (dHash) → the shot is dropped; every decision is logged with its similarity score
- Time per app:
linux-recall-usagesums how long each app was in front from the daemon's log, per day if you like, with browsers split by site and kitty by foreground program (--by detail) - Context per app:
- browsers: URL of the active tab
- Zotero: item, DOI, current page, and a
zotero://open-pdf/...?page=Nlink - Anki: the card under review (or the Browse selection), with field text
- Obsidian: vault, note, heading, tags, and the Markdown source lines visible on screen
- InkyCap: notebox, note, and its title, date and tags (the note it last made active)
- Okular: document path, title, author, and current page of the active tab
- kitty: the focused tab and window, its shell and foreground processes; the last command and its output (shell integration); for Neovim the open file, cursor and visible text; for Claude Code the session id, title and last prompt
- Neovide: the same Neovim record as in kitty
- OCR: PaddleOCR cloud API (PP-OCRv6, Chinese + English), falling back to local RapidOCR on timeout or error
- Search:
jqrecipes andlrf, an fzf picker with the matching line boxed in red on the screenshot (rendered inline in kitty), andctrl-oto reopen the URL / PDF page (Zotero or Okular) / Anki card / Obsidian note / Neovim file / kitty window - Click to Do:
Meta+Alt+Cturns the active window into a page where the text in the screenshot is selectable word by word
Requirements: KDE Plasma 6 (Wayland), Python ≥ 3.12, spectacle; uv when running from source.
Optional: plasma-browser-integration plus its browser extension (URLs); Zotero 7+ with Settings → Advanced →
Allow other applications on this computer to communicate with Zotero; Anki with
AnkiConnect; Obsidian 1.12+ with its CLI enabled; PADDLEOCR_TOKEN for cloud OCR;
jq, fzf, ImageMagick and kitty for search.
Arch Linux (AUR)
linux-recall-git is in the AUR.
It builds from the latest commit and pulls in its dependencies from pacman (python-rapidocr also comes from the AUR),
so use an AUR helper:
paru -S linux-recall-git # or yay -S, or makepkg -si in a clone of the AUR repo
linux-recall-install-hotkey # Meta+Alt+R: capture, Meta+Alt+C: Click to Do
systemctl --user enable --now linux-recall # optional: capture every minute
source /usr/share/linux_recall/lr.zsh # in ~/.zshrc, for lr-search / lr-highlight (lrf is in /usr/bin)The search tools are optional dependencies: pacman -S --asdeps jq fzf imagemagick kitty plasma-browser-integration.
Local OCR needs python-rapidocr; with python-openvino installed as well it runs on OpenVINO instead of ONNX Runtime.
With the package, drop uv run from the commands below.
Debian / Ubuntu (.deb)
Each release has an amd64 .deb for distros with Plasma 6:
Debian 13, Kubuntu 25.04+, KDE neon. Debian does not package onnxruntime or rapidocr,
so the package bundles its own Python and dependencies under /usr/lib/linux-recall (about 500 MB installed, mostly OpenCV and ONNX Runtime).
sudo apt install ./linux-recall_*_amd64.deb # the file downloaded from the release
linux-recall-install-hotkey # Meta+Alt+R: capture, Meta+Alt+C: Click to Do
systemctl --user enable --now linux-recall # optional: capture every minute
source /usr/share/linux_recall/lr.zsh # in ~/.zshrc, for lr-search / lr-highlight (lrf is in /usr/bin)jq, fzf, imagemagick, kitty and plasma-browser-integration are recommended and installed by default;
lrf highlights matches with ImageMagick 7 (magick), which Ubuntu 24.04 and older lack.
With the package, drop uv run from the commands below.
git clone https://github.com/junyixu/linux_recall ~/WorkSpace/windows_recall_linux
cd ~/WorkSpace/windows_recall_linux
uv sync # or `uv venv --system-site-packages && uv sync` to use pacman's python-openvino
./scripts/install-hotkey.sh # Meta+Alt+R: capture, Meta+Alt+C: Click to Do
# optional: capture every minute; uv.conf points the unit at .venv (edit it if you cloned elsewhere)
cp systemd/linux-recall.service ~/.config/systemd/user/
install -Dm644 systemd/uv.conf ~/.config/systemd/user/linux-recall.service.d/uv.conf
systemctl --user daemon-reload && systemctl --user enable --now linux-recallImportant
Neither the packages nor the source install set PADDLEOCR_TOKEN; you have to add it yourself.
Without it, OCR is local only and Click to Do (cloud only) does not work.
Get an access token from Baidu AI Studio's PaddleOCR page, then put it in
~/.config/environment.d/ so the hotkey and the service see it (a variable exported only in ~/.zshrc does not reach them):
mkdir -p ~/.config/environment.d
echo 'PADDLEOCR_TOKEN=...' >> ~/.config/environment.d/linux-recall.confThen log out and back in; the session reads environment.d only at login.
On X11, any client can read the window list, the focused window and any window's pixels.
Wayland deliberately removes all three, and KWin doesn't implement the generic protocols that
wlroots compositors offer (wlr-screencopy, ext-image-copy-capture, ext-foreign-toplevel-list).
That's why OpenRecall (mss → XGetImage) fails outright on a Wayland session, and screenpipe, which can grab
whole monitors through the ScreenCast portal, still can't tell which window is focused on Plasma:
its active-window lookup only knows Hyprland, Sway and X11.
What linux_recall uses instead:
| Need | X11 way | What works on KWin Wayland (used here) |
|---|---|---|
| Focused window, its geometry, screens | _NET_ACTIVE_WINDOW, xdotool |
A throw-away KWin script loaded over D-Bus |
| Screenshot | XGetImage |
Spectacle in background mode (it's on KWin's screenshot whitelist) |
| Browser URL | read the window / X properties | Plasma Browser Integration's D-Bus tab list |
| Global hotkey | XGrabKey |
kglobalaccel over D-Bus |
| Idle / locked | XScreenSaver |
org.freedesktop.ScreenSaver.GetActive |
flowchart LR
T["Meta+Alt+R<br/>or daemon tick (60 s)"] --> K["KWin script over D-Bus<br/>active window + screens"]
K --> S["spectacle -b -a -S<br/>full-resolution window shot"]
S --> C{"which app?"}
C -->|browser| B["Plasma Browser Integration<br/>tab URL"]
C -->|Zotero| Z["Zotero Local API<br/>+ reader state → page"]
C -->|Anki| A["AnkiConnect<br/>current card"]
C -->|Obsidian| M["obsidian eval<br/>note + visible lines"]
C -->|Okular| P["org.kde.okular D-Bus<br/>document + page"]
C -->|kitty| N["kitten @ ls → shell → nvim<br/>current file + cursor"]
B & Z & A & M & P & N & C --> D{"daemon only:<br/>same app and dHash ≥ 0.95?"}
D -->|yes| X["drop"]
D -->|no| W["save downscaled WebP + JSON"]
W --> O["OCR the full-res original<br/>cloud → local fallback"]
O --> J["rewrite JSON with text + boxes"]
Wayland has no API for "which window is focused", but KWin's scripting engine can see everything.
kwin.py writes a short JavaScript file that reads workspace.activeWindow and workspace.screens,
loads it with org.kde.KWin /Scripting loadScript, runs it, and the script calls back into our own
D-Bus connection with callDBus(...) carrying a JSON payload. Then the script is unloaded.
This is the same trick kdotool uses, but one round trip returns every field we need
(caption, app id, pid, geometry, output, per-screen scale) in about 50 ms.
KWin's org.kde.KWin.ScreenShot2 only accepts callers listed in a whitelisted .desktop file,
so the tool runs spectacle --background --nonotify --new-instance -a -S:
-a -Scaptures only the active window, without its transparent drop shadow--new-instancematters: Spectacle is single-instance over D-Bus, so without it a hotkey shot during a daemon shot gets forwarded to the first process and its--outputis lost--scaledonly works together with full-desktop mode, so HiDPI window shots are downscaled in Python
On a 2× screen a window shot is 3840 px wide, but OCR runs on that full-resolution original, and only the copy that's kept is downscaled (÷ the output scale). OCR on the downscaled copy lost more than half the lines of small terminal text (105 → 42). OCR boxes are then mapped to the saved image's pixels.
The browser extension exports all tabs through a KRunner interface
(org.kde.plasma.browser_integration /TabsRunner). It doesn't say which tab is active, so the window
caption (minus " — Mozilla Firefox") is matched exactly against tab titles. Two gotchas:
TabsRunner caches the tab list until Teardown() (otherwise you get stale titles), and tabs with
identical titles are recorded as "match": "ambiguous". Private windows are skipped.
Zotero 7+ serves a read-only copy of its web API at http://localhost:23119/api/, but it has no endpoint for
"the open reader tab". The reader tab's window caption is Title - Author - Year - Zotero, so the tool searches
with qmode=titleCreatorYear and reads the current page from storage/<attachment>/.zotero-reader-state
(pageIndex, 0-based), which Zotero also keeps for linked files.
ctrl-o later opens zotero://open-pdf/library/items/<attachment>?page=N.
While reviewing, guiCurrentCard + cardsInfo give the card, note, deck and fields; in the Browse window,
guiSelectedNotes gives the selection. Fields are stored as plain text, so card content is searchable.
ctrl-o calls guiBrowse cid:<card> and guiSelectCard, then raises the Browse window with kdotool,
because KWin's focus-stealing prevention blocks Anki from raising an already-open window itself.
Each Okular process owns org.kde.okular-<pid> (the pid KWin reports), and every document tab is an object at
/okular, /okular2, … answering currentDocument, currentPage, pages and documentMetaData. Nothing marks
the active tab, so, as with browser tabs, the one whose title or file name equals the window caption wins
(match: "ambiguous" when several do). A handful of D-Bus calls take about 2 ms.
ctrl-o runs okular --page N <file>.
Obsidian 1.12+ ships a CLI that talks to the running app (Settings → General → Command line interface).
The window caption is Note - Vault - Obsidian 1.x, so the tool runs obsidian vault=<Vault> eval code=… in that
vault's window: one call (about 0.3 s) returns the active file, view mode, cursor, heading, tags, and the Markdown
source lines on screen (in reading view, the rendered sections inside the pane; in editing view, CodeMirror's
posAtCoords at the top and bottom edges). ctrl-o opens obsidian://open?vault=…&file=….
With allow_remote_control yes and listen_on unix:/tmp/kitty in kitty.conf, kitty listens on
/tmp/kitty-<pid> and exports that address to its children as KITTY_LISTEN_ON; the KWin window's pid is
kitty's, so the address is read from a child's /proc/<pid>/environ. kitten @ ls gives the tree
kitty → OS window → tab → window (the shell, e.g. zsh) → foreground_processes (what the shell is running).
Neovim is split into a TUI process and an nvim --embed server child (in its own process group, so kitty
doesn't list it); the server listens on $XDG_RUNTIME_DIR/nvim.<server pid>.0, and one
nvim --server … --remote-expr call (about 10–30 ms) returns the current file, cursor, mode, cwd, listed buffers,
and the exact text visible in every window of the current tabpage (lines w0..w$ with their line numbers; plugin
UIs and secret-looking files such as .env or ~/.ssh/* keep only the path). lrf searches that text too and shows
matches as file:line, so ctrl-o jumps to the matching line rather than wherever the cursor was. OCR still runs,
for the statusline, other UI and the boxed preview.
ctrl-o goes back to it: if that Neovim is still running, it :drops the file and sets the cursor over the same
socket, then kitten @ focus-window brings up its kitty window (buffers, splits and jumplist intact);
otherwise it opens nvim +<line> <file> in a new kitty tab. Neovide works the same way: its nvim --embed child is
the server, ctrl-o raises the Neovide window with kdotool windowactivate <KWin internal id>, or starts
neovide -- +<line> <file>. For other kitty captures, ctrl-o focuses the same kitty
window, or its tab if the window was closed (ids are never reused within a kitty process, and a restarted kitty
listens on a different /tmp/kitty-<pid>, so a stale id can't hit the wrong window).
With kitty's shell integration, kitten @ ls also reports each window's last_reported_cmdline, at_prompt and
last_cmd_exit_status, and kitten @ get-text --extent last_cmd_output returns that command's output, or the output
so far while it's still running: the exact terminal text, no OCR (the last 200 lines are kept; full-screen programs
and commands like pass, gpg, printenv or cat .env keep only the command line).
Claude Code writes ~/.claude/sessions/<pid>.json (session id, cwd, status) for each running CLI; the end of the
transcript ~/.claude/projects/*/<session id>.jsonl has the session's ai-title and last-prompt. If the kitty
window is gone, ctrl-o runs claude --resume <session id> in a new tab.
- Cloud: the PaddleOCR API (PP-OCRv6, needs
PADDLEOCR_TOKEN). It downscales detection input to 960 px and rejects anything above 4000 px, so a whole three-monitor desktop (6098 px wide) came back with 2 lines. Uploading just the window gives 100+ lines. A full queue (code 10010) is retried with backoff. Spectacle's WebP is uploaded as is: re-encoding it as PNG gave identical text at 4× the size, and JPEG (a second lossy pass) lost 5–60% of the characters. - Local: RapidOCR (PaddleOCR models), used when the cloud misses
--ocr-timeout(default 60 s, covering the whole attempt) or errors. The JSON records which engine ran and why the fallback happened. It runs on OpenVINO when that is installed, otherwise ONNX Runtime: same models, identical text, but a 3840×2080 window on 4 threads costs 8.6 s of CPU instead of 22 s. OpenVINO's GPU plugin (Xe2 iGPU) was no faster, and its default f16 changed ~5% of the lines, so OCR stays on the CPU. It runs in a child process, since the runtime keeps ~400 MB after the first run.
On a CPU budget, cloud OCR is almost free (~0.1 s of CPU; the 5–60 s is upload and queueing) and the screenshot itself is ~0.6 s, mostly Spectacle. So the daemon never blocks on OCR and keeps local OCR out of the user's way:
- Kept shots are saved with
"ocr": nulland their full-resolution original (~0.35 MB, about 3× the saved copy) goes to~/.cache/linux_recall/pending/; a background thread OCRs the newest one (cloud only,--ocr-timeoutdefaults to 180 s there) and fills in the JSON. The queue survives restarts and keeps at most 1500 originals. - A cloud failure pauses cloud calls for 1 min, doubling up to 30 min; the next shot after the pause probes whether it's back. A missing or rejected token pauses 30 min and sends one notification.
- On a metered connection (NetworkManager's
Meteredproperty; Android phone hotspots announce it) the cloud isn't used at all.--cloud-ocr unmeteredis the default for both the daemon and the hotkey, whose shots then join the same queue;alwaysignores metering andneverkeeps OCR local. To mark a connection NetworkManager doesn't recognize:nmcli connection modify <name> connection.metered yes. - Without the cloud, local OCR only runs while the screen is locked and the laptop is plugged in (a merely failing cloud first gets an hour to come back). One child process OCRs shot after shot, so the runtime and models load once instead of costing ~0.8 s per shot; unlocking kills it within 0.5 s (that shot stays queued), and so does an empty queue. It uses 4 threads: per 3840×2080 window, OpenVINO on 2/4/8 threads took 4.0/3.3/2.5 s and 7.1/8.6/11.3 s of CPU, so a busy day's ~600 shots need about half an hour of a locked night.
The JSON is written twice, atomically: right after the screenshot ("ocr": null) and again after OCR,
so a failed OCR never loses the capture.
similarity.py computes a 256-bit difference hash; similarity is 1 − Hamming distance / 256.
Measured on this setup: identical 1.0, clock tick 0.988, one new line of text 0.977, a new paragraph 0.93,
300 px scroll 0.82, unrelated content 0.5–0.73. The daemon drops a shot when it's the same app as the last
saved one and similarity ≥ --threshold (0.95); an app switch is always kept, and nothing is taken while the
screen is locked or the active window is excluded (see Privacy). Each cycle logs one line and appends to ~/.cache/linux_recall/daemon.jsonl:
KEEP kitty similarity 0.543 (threshold 0.95) changed [kept 2, skipped 0]
SKIP kitty similarity 1.000 (threshold 0.95) similar [kept 2, skipped 1]
KEEP firefox similarity 0.612 (threshold 0.95) app kitty -> firefox [kept 3, skipped 1]
With active use it keeps close to one shot a minute, about 10 MB per active hour.
Because skipped cycles are logged too, linux-recall-usage can tell how long each app was in front: every cycle
counts until the next one (capped at twice the interval, so suspend doesn't count), locked time is shown apart.
kitty 6h05m 50.0% ██████████████████████████████
firefox 2h52m 23.6% ██████████████▏
org.telegram.desktop 1h26m 11.8% ███████
total 12h10m
locked 9h55m
Meta+Alt+C screenshots the active window, OCRs it with per-word boxes, and opens an HTML page in Firefox:
the screenshot with an invisible text layer on top, built the way pdf.js does it. Every character is an
absolutely positioned <span>, stretched with scaleX to cover its box, and lines are ordered column by column,
so a drag doesn't pull in the sidebar. The cloud's word boxes are often off by several pixels (7 px median, a
whole word in the worst case), so clicktodo/textfit.py re-aligns every character to the actual ink in the screenshot,
using dynamic programming over glyph gaps.
uv run linux-recall-capture # one capture (what the hotkey runs)
uv run linux-recall-capture --mode fullscreen # whole desktop; OCR still only the active window
uv run linux-recall-capture --full-res # keep HiDPI resolution
journalctl --user -u linux-recall -f -o cat # watch the daemon's keep/skip decisions
uv run linux-recall-usage --today # time per app, from the daemon's log (--by detail: site / kitty program)Captures live in ~/.local/share/linux_recall/captures/YYYY-MM-DD/<id>.{webp,json};
logs and caches in ~/.cache/linux_recall/.
source scripts/lr.zsh # in ~/.zshrc
lrf lunar # fzf: app + time │ matching line, keyword in red │ site / paper / deck
# preview: screenshot with the match boxed (kitty), enter: print the JSON path,
# ctrl-f: print the file open in Neovim (app shows as kitty(neovim)),
# ctrl-t: print the InkyCap note's path, ctrl-s: open image,
# ctrl-o: reopen the URL / Zotero or Okular page / Anki card / Neovim file and line / kitty window
lr-search 截图保存 # plain TSV: time, app, URL or title, image path
lr-highlight <capture.json> lunar # PNG with matching lines boxedOr query the JSON directly:
cd ~/.local/share/linux_recall/captures
jq -r --arg q lunar '
select([.ocr.text, .window.caption, .browser.url] | map(. // "") | join("\n")
| ascii_downcase | contains($q | ascii_downcase))
| [.captured_at[:19], .window.app_name, (.browser.url // .window.caption)] | @tsv' */*.jsonMore recipes (timelines, per-app filters, papers read, dedup statistics): README.zh-CN.md.
Everything is stored unencrypted on your disk, and cloud OCR uploads the window image to Baidu AI Studio.
A small exclusion list (linux_recall/exclude.py) is checked right after the KWin query, before Spectacle runs, so
nothing of those windows is ever written: password managers (KeePassXC, Bitwarden, 1Password, KWallet), auth prompts
(polkit, ksshaskpass, pinentry), Spectacle's own region-select overlay (a frozen image of the whole desktop), and
windows whose caption contains Private Browsing, (Incognito), (Private) or [InPrivate], and browser tabs on listed
domains (SITES, e.g. a bank; the URL lookup moved ahead of the screenshot for this). Only the active window is
checked, and a site is only caught when Plasma Browser Integration reports the URL, so review what you record.
Unset PADDLEOCR_TOKEN in environment.d to keep OCR local.
A personal tool, built and tested on Arch Linux + Plasma 6.7 with three mixed-DPI monitors.
Next up: SQLite FTS5 index, event-driven triggers (window switch, ext-idle-notify-v1), expiring old images while
keeping the JSON, and encryption. Making the JSON more compact is low priority: the data directory sits on btrfs
with transparent compression, so the verbose text already takes little space on disk.
- Windows Recall (Microsoft, Copilot+ PCs): the original idea; Windows only.
- OpenRecall: open-source Recall clone in Python (AGPL-3.0).
Captures with
mss, which fails withXGetImage() failedon a Wayland session. - screenpipe: event-driven capture, accessibility tree plus OCR, SQLite full-text search. Now source-available rather than open source; on Plasma it records whole monitors through the ScreenCast portal, but its active-window lookup only supports Hyprland, Sway and X11, so it can't tell which native Wayland window you were in.
- Windrecorder: records and OCRs the screen; Windows only.
- ActivityWatch: tracks app and window usage time without screenshots; on KWin Wayland it needs awatcher.
- NormCap: one-off "select a region, copy its text" OCR, which works on Wayland. It's the closest to Click to Do.
{ "schema_version": 14, "captured_at": "2026-09-23T20:51:42.366+02:00", "trigger": "timer", // or "hotkey" "screenshot": {"file": "….webp", "mode": "window", "width": 1969, "height": 1068, "scale": 0.5128, "dhash": "…"}, "window": {"app_name": "Firefox", "caption": "…", "desktop_file": "firefox", "pid": 2770, "output": "DP-1"}, "browser": {"url": "https://…", "match": "exact"}, // null unless a browser "zotero": {"title": "…", "doi": "…", "page": 48, "open_link": "zotero://open-pdf/library/items/I2R4MPPK?page=48"}, "anki": {"mode": "review", "card_id": 1706122964826, "deck": "…", "fields": {"Front": "…"}, "browse_query": "cid:1706122964826"}, "kitty": {"address": "unix:/tmp/kitty-7201", "tab": {"id": 35, "title": "…"}, "window": {"id": 37, "pid": 16134, "cmdline": ["/bin/zsh"], "cwd": "…"}, "foreground_processes": [{"pid": 98870, "cmdline": ["nvim"], "cwd": "…"}], "nvim": {"pid": 98870, "server_pid": 98871, "file": "/…/notes.md", "line": 17, "col": 1, "filetype": "markdown", "modified": false, "mode": "n", "cwd": "…", "buffers": ["…"], "windows": [{"file": "/…/notes.md", "buftype": "", "current": true, "first": 3, "lines": ["line 3 of the file", "line 4", "…"]}]}, "shell": {"cmdline": "make test", "at_prompt": true, "exit_status": 1, "output_lines": 312, "output": ["… the last 200 lines …"]}, "claude": {"session_id": "c26e9654-…", "cwd": "…", "status": "busy", "title": "…", "last_prompt": "…", "transcript": "~/.claude/projects/…/c26e9654-….jsonl"}}, "neovide": {"nvim": {"file": "…", "line": 17, "windows": […]}}, // null unless Neovide "obsidian": {"vault": "Notes", "file": "diary/2026/09/2026-09-23.md", "mode": "preview", "heading": "…", "tags": ["#diary"], "first": 5, "lines": ["line 5 of the note", "…"], "open_link": "obsidian://open?vault=Notes&file=diary/2026/09/2026-09-23.md"}, "inkycap": {"notebox": "notes", "file": "reading/attention.typ", "path": "/home/…/notes/reading/attention.typ", "title": "…", "date": "2026-09-24", "zid": 20260924152701, "tags": ["ml"], "aliases": []}, "okular": {"path": "/home/…/paper.pdf", "file": "paper.pdf", "title": "…", "author": "…", "page": 12, "pages": 134, "tabs": 1, "match": "exact"}, "ocr": {"engine": "paddleocr-cloud PP-OCRv6", "fallback_reason": null, "elapsed_s": 4.9, "text": "…", "lines": [{"text": "…", "score": 0.99, "box": [[x, y], …]}]} }