tidy.
Writing

A local LLM got the semantics right and the mechanics wrong

Sebastián Díaz Thomas · Santiago, Chile

My Desktop was covered in screenshots. Not a few, dozens, every one of them named with the date and time it was taken, which is the single piece of information I never remember. I knew I had captured the thing I was looking for. I could never find it. I would open them one at a time, close it, open the next.

What finally got to me was realising that the name of every one of those files was written inside the file. macOS just was not reading it.

So I spent a few months building a Mac menu bar app that does. Here is what I ran into.

Reading the file

For images this is Vision. VNRecognizeTextRequest with recognitionLevel = .accurate gets you the text in a screenshot of an invoice, a receipt, a slide, a scanned contract. It is fast enough on Apple Silicon that you can run it on every new file in Downloads without anyone noticing.

PDFs are two different problems wearing the same extension. A PDF exported from Word has a text layer and PDFKit hands it to you in a millisecond. A PDF that came out of a scanner or a phone camera is a picture of a page, and PDFKit returns an empty string. So the PDF path is: ask PDFKit first, and if what comes back is empty or nearly empty, render the first page to a bitmap and send it through the same OCR as an image.

The extracted text then gets turned into a filename. Strip diacritics, drop words shorter than two characters, take the first six meaningful ones, join them. Screenshot 2026-08-02 at 11.20.13.png becomes invoice-mueller-gmbh-03-07-2026.png.

That last part sounds trivial and it is where most of my time went.

The model gets the idea and misses the instruction

Apple's on-device model (FoundationModels, LanguageModelSession) is good at the semantic half. Give it the OCR text of a receipt and ask for a filename and it understands that "Müller GmbH" is the vendor and 03/07/2026 is the date. That part worked on the first try and still surprises me.

Then I let people write their own naming instruction in plain language, and the mechanics fell apart in ways I did not expect.

A German user asked for dates in TT-MM-JJJJ. The model returned files literally named TT-MM-JJJJ. It had treated the format string as text to reproduce rather than a pattern to fill.

Someone asked for names in uppercase. The model acknowledged the request in its reasoning and returned lowercase.

Someone asked for underscores instead of hyphens and got hyphens.

None of these are hallucinations in the usual sense. The model understood the document perfectly. It just did not carry out the formatting instruction, and it did not fail every time, which is worse than failing always: you cannot tell your users "this does not work", you have to tell them "this works most of the time", and nobody wants that in something that renames their files.

What fixed it was giving up on the model for the mechanical part. There is now a deterministic layer that runs after the model and enforces the boring things itself:

The division of labour ended up being:

The model decides what the file is about. Ordinary code decides how the name is written. Every time I tried to move that line, the result got worse.

Two smaller things in the same area. A fresh LanguageModelSession per call, because reusing one leaks context between unrelated files. And a 15-second timeout with a fallback to plain OCR naming, because a model that hangs must not be able to hang a file manager.

Filesystems have opinions

Two bugs from this project that I would not have predicted.

Do not touch a download that is still arriving. The obvious check is "has this file stopped changing", and that is necessary but not sufficient, because browsers disagree about how to download. Chrome writes to file.crdownload and renames at the end, which is easy. Firefox creates an empty file with the final name immediately and downloads to a separate .part, so for a while there is a zero-byte invoice.pdf sitting there looking finished. curl and wget grow the file in place under its final name, so the name tells you nothing at all. The rule that covers all three: never a zero-byte file, and nothing modified in the last few seconds.

APFS is case-insensitive by default and this will bite you. If the generated name differs from the current one only in capitalisation, and you check "does a file already exist at the destination" before renaming, the answer is yes, because the file you are about to rename is that file. My uniqueness helper dutifully appended " (1)". Users got Report (1).pdf when they asked for Report.pdf and I had no idea why for two days. Case-only renames need their own branch.

Not deleting anything

I decided early that the app would never delete a file. Things move to the Trash and no further, and any run undoes in one click.

That constraint shaped more of the design than any feature did. Undo is per run rather than per file, because when an automation misfires it misfires on forty files and nobody wants to click undo forty times. The undo stack is persisted to disk, because the first time someone quit the app and lost the ability to undo a sort from five minutes earlier, that was my fault and not theirs.

Renamed files get an extended attribute so the sweep does not OCR them again every fifteen seconds forever. That works everywhere except on volumes that silently drop xattrs, which some SMB shares do, so there is an in-memory fallback set for the session. Without it, a rule with an AI naming prompt would rename the same file on every pass, with a slightly different name each time, because model output is not deterministic. That one took a while to understand from a bug report.

The bug that taught me the most was not technical

A customer wrote to say the app installed, ran, processed her screenshots correctly, and never showed a menu bar icon. She had restarted it, reset preferences, restarted SystemUIServer.

macOS had hidden the icon because her menu bar was full. It does that silently, and MacBooks with a notch run out of menu bar sooner than you would think. My app had no other interface at all, so from where she sat it was dead software that had somehow still rearranged her files.

She solved it by deleting everything in her menu bar, and then apologised to me for the trouble.

The fix took an afternoon: show a window on first run that says the app is running and points at where to look, check whether the status item is actually on screen, and say so plainly if it is not. Also make reopening the app from Applications do something, since that is the first thing a person tries when they think an app did not start. Mine did nothing at all, which I had never noticed because I always knew where my icon was.

I have written a lot of careful code for this project. That afternoon was probably worth more than any of it.


The app is Tidy. $9 once, macOS 14 and up, and everything above runs on the device. I am a solo developer in Santiago, Chile, and I am happy to answer anything.