This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady
What I Built
My Downloads folder is where files go to be forgotten. download (7).pdf. scan0001.pdf. untitled.py, which I wrote, at some point, for some reason. notes_final_FINAL(2).md. Every few weeks I tell myself I'll sort it, and every few weeks I don't, because opening forty files to work out what they are is exactly the kind of work I avoid.
So for Sloth Week I built sloth-tidy, a small Python CLI that does that sorting for me. It reads the first few hundred characters of each file, asks Gemma 3 running locally through Ollama where the file belongs and what it should be called, and moves it there. download (7).pdf turns into Receipts/hetzner-online-billing-september-2026.pdf. untitled.py turns into Code/tcp-port-scanner-homelab.py.
The effort it saves is the boring part: I never open the files. The model reads them, I read one line per file. And with --watch, I don't even have to run it. Anyone with a Downloads folder like mine gets to be a little lazier.
Because I'm handing my files to a program that moves them around, I spent most of the effort on making it hard to lose anything:
-
Dry run by default. Nothing moves without
--apply. -
The plan you approve is the plan that runs. The dry run saves its plan, and
--applyreuses it if no file has changed since. (I learned why that matters the hard way; see How I Built It.) -
sloth-tidy undoputs everything back from a JSON log and removes the folders it emptied. -
It never overwrites. Collisions become
name-2.ext. If a destination appears between planning and moving, that file is skipped. - It only talks to localhost. If the Ollama URL resolves to anything other than a loopback address, it refuses to start.
- If the model is down, it still works. It falls back to plain extension rules and says so.
Demo
sloth-tidy is a command-line tool, so the demo is its real terminal output, copied from demo/demo-output.txt in the repo, with screenshots of the same runs. Everything ran on CPU only (8 vCPUs, 16 GB of RAM, no GPU). The messy folder is generated by demo/make_messy.py, so you can reproduce it.
The mess:
$ ls Downloads
IMG_20261019_181233.jpg
New Text Document.txt
Screenshot 2026-10-21 at 23.14.07.png
archive.zip
code_1.90.2-1718751586_amd64.deb
data.json
download (7).pdf
export.csv
notes_final_FINAL(2).md
page.html
scan0001.pdf
untitled.py
The dry run. [ai ] means Gemma read the file. [ext] means an extension rule decided. The number in brackets is how long the model took for that file:
$ sloth-tidy Downloads
[ext] IMG_20261019_181233.jpg -> Images/IMG_20261019_181233.jpg (0.0s)
[ai ] New Text Document.txt -> Notes/groceries-library-book-friday.txt (10.0s)
[ext] Screenshot 2026-10-21 at 23.14.07.png -> Images/Screenshot 2026-10-21 at 23.14.07.png (0.0s)
[ext] archive.zip -> Archives/archive.zip (0.0s)
[ext] code_1.90.2-1718751586_amd64.deb -> Installers/code_1.90.2-1718751586_amd64.deb (0.0s)
[ai ] data.json -> Data/picoctf-data.json (2.8s)
[ai ] download (7).pdf -> Receipts/hetzner-online-billing-september-2026.pdf (3.0s)
[ai ] export.csv -> Receipts/swiggy-airtel-uber-transactions.csv (2.6s)
[ai ] notes_final_FINAL(2).md -> Notes/gsoc-proposal-ideas-fuzzing-harness-parser.md (3.2s)
[ai ] page.html -> Documents/ollama-api-docs.html (2.1s)
[ai ] scan0001.pdf -> Documents/scan0001-public-key-cryptography-cs342.pdf (2.9s)
[ai ] untitled.py -> Code/tcp-port-scanner-homelab.py (2.5s)
12 file(s) planned with gemma3:4b in 29.0s.
Dry run: nothing moved. Add --apply when you like the plan.
The first file took 10 seconds because that includes loading the model into memory. After that it settles at about 2–3 seconds per file on CPU.
Applying it. No second round of model calls. It reuses the plan I just read:
$ sloth-tidy Downloads --apply
Using the plan from your last dry run (no files changed since).
...same 12 lines...
Moved. Undo log: .sloth-tidy/undo-20261025-122904-731244.json
$ find Downloads -type f -not -path */.sloth-tidy/*
Downloads/Archives/archive.zip
Downloads/Code/tcp-port-scanner-homelab.py
Downloads/Data/picoctf-data.json
Downloads/Documents/ollama-api-docs.html
Downloads/Documents/scan0001-public-key-cryptography-cs342.pdf
Downloads/Images/IMG_20261019_181233.jpg
Downloads/Images/Screenshot 2026-10-21 at 23.14.07.png
Downloads/Installers/code_1.90.2-1718751586_amd64.deb
Downloads/Notes/groceries-library-book-friday.txt
Downloads/Notes/gsoc-proposal-ideas-fuzzing-harness-parser.md
Downloads/Receipts/hetzner-online-billing-september-2026.pdf
Downloads/Receipts/swiggy-airtel-uber-transactions.csv
Changing my mind:
$ sloth-tidy undo Downloads
Restored 12 file(s) from undo-20261025-122904-731244.json
Being properly lazy with watch mode. I left it watching an Inbox folder and dropped in an order confirmation. It waited until the file had been still for 8 seconds, then filed it:
$ sloth-tidy Inbox --watch --apply --every 5 --min-age 8
Watching /tmp/tmp.LpZzu6OBkd/Inbox every 5s; files must sit still for 8s. Ctrl+C to stop.
[demo] dropped Inbox/mail-attachment.txt at 12:29:06
[12:29:14] woke up, found something
[ai ] mail-attachment.txt -> Receipts/order-confirmation-keyboard.txt (2.2s)
1 file(s) planned with gemma3:4b in 2.2s.
Moved. Undo log: .sloth-tidy/undo-20261025-122917-096992.json
Back to sleep.
(The defaults are more relaxed: it checks every 5 minutes and only touches files that have been still for 10.)
When the model isn't running, or someone points it at a server:
$ sloth-tidy Downloads --host http://127.0.0.1:9
! model 'gemma3:4b' not reachable at http://127.0.0.1:9; falling back to extension rules
[ext] download (7).pdf -> Documents/download (7).pdf (0.0s)
...
$ sloth-tidy Downloads --host http://8.8.8.8:11434
sloth-tidy: error: 8.8.8.8 resolves to 8.8.8.8, which is not localhost. sloth-tidy only talks to a model on this machine.
Code
Phantom9869
/
sloth-tidy
A lazy folder tidier: a local Gemma model (via Ollama) sorts and renames your messy Downloads. Dry-run by default, with undo.
sloth-tidy 🦥
A lazy, local-first folder tidier. Point it at your messy Downloads, and a small
open-weight model (Gemma 3, running on your own machine through Ollama)
reads the start of each file, picks a category folder, and suggests a filename that
says what the file actually is.
download (7).pdf -> Receipts/hetzner-online-billing-september-2026.pdf
untitled.py -> Code/tcp-port-scanner-homelab.py
notes_final_FINAL(2).md -> Notes/gsoc-proposal-ideas-fuzzing-harness-parser.md
Demo
Real output from demo/demo-output.txt (Gemma 3 4B on CPU through Ollama).
Dry run: [ai ] = the model read the file, [ext] = extension rule.
Apply, then undo:
Watch mode: a file lands, sits still, gets filed.
Challenge note: this project was started and completed during the DEV Hacktoberfest Open-Source AI Challenge, Week 3 ("Slow and Steady") October 19 to 25, 2026. Any commits after the deadline will be listed here.
Why it's safe to leave alone
-
Dry run by default. Without
--applynothing moves. The dry run's plan…
About 600 lines of Python (comments included) using only the standard library, plus 20 pytest tests that mock the model and run in under a tenth of a second.
To run it: install Ollama, ollama pull gemma3:4b, then git clone https://github.com/Phantom9869/sloth-tidy && cd sloth-tidy && pip install -e . and point it at a folder with sloth-tidy ~/Downloads. That's a dry run; add --apply when you like the plan.
How I Built It
The pipeline has four small modules:
-
scanner.pylists the top-level files in a folder. It skips hidden files, symlinks, folders I've already organised, and half-finished downloads (.crdownload,.part). For each file it grabs up to 600 characters of text. That covers plain text, code, Markdown, CSV and JSON, HTML with the tags stripped, and PDFs through poppler'spdftotext. -
brain.pysends the filename, size, the extension's usual category, and the snippet to Ollama's/api/chat. The key trick is theformatfield: I pass a JSON schema whosecategoryis anenumof my eleven folders, so the model can't reply with prose or make up a folder. I still check the answer anyway, and anything strange falls back to the extension rule. -
planner.pyturns the suggested name into a safe slug (at most 6 words or 60 characters, cut at a word boundary, no../) and handles collisions. -
mover.pymoves files withos.rename, writes the undo log, and saves and reloads dry-run plans.
The design decisions below came from watching real runs go wrong.
Lesson 1: a small model will describe a photo it has never seen. On my first real run with gemma3:1b, I passed image files to the model with only their name and size. It renamed a blank test JPEG to landscape-sunset-view-coastal-cliffs-warm-light.jpg and filed a screenshot under Receipts/invoice-retail-store-purchase.png. It sounded confident and was completely made up. Now, if sloth-tidy can't read a file, it doesn't ask the model at all. Extension rules decide the folder and the original name stays. The prompt also says "use only words that appear in the content".
Lesson 2: the plan you read must be the plan that runs. Small models don't give the same answer twice. In an early run, the dry run proposed Notes/groceries-library-book-friday.txt, and the --apply run a minute later went with Notes/groceries-library-book.txt instead, because it asked the model again. Approving one plan and getting another is not okay for a tool that moves files. So the dry run now saves its plan along with each file's size and modification time, and --apply uses that exact plan unless something changed. It also saves the model calls, which suits a sloth.
Lesson 3: put a cap on output. Once, a single code file took 86.7 seconds before falling back to rules. The model just kept generating. Setting num_predict: 128 fixed it. When I tried a much lower cap (64), some replies got cut off mid-JSON. Those fell back to [ext] safely, but it was a waste.
1B vs 4B, measured on the same 12 files on the same CPU:
| gemma3:1b | gemma3:4b | |
|---|---|---|
| Whole dry run (9 model calls, cold start) | 15.0 s | 29.0 s |
| Typical warm call | ~1.1–1.5 s | ~2–3 s |
Memory reported by ollama ps
|
1.2 GB | 3.4 GB |
data.json (my picoCTF scores) |
transaction-log-server-access-verification ❌ |
picoctf-data ✅ |
untitled.py (a port scanner) |
python-script-analyzer-tool-basic-analysis 🤷 |
tcp-port-scanner-homelab ✅ |
The 1B model is twice as fast, but it makes things up more often, so 4B is the default. --model gemma3:1b is there for laptops with less RAM. Gemma 4B isn't perfect either. In the demo it filed export.csv, a table of several transactions, under Receipts even though the prompt says tables go in Data. That's exactly why the dry run exists: I read the plan, and if I don't like a line, I move that one file myself.
One more honest detail. On the machine I built this on, ollama pull couldn't reach Ollama's model storage. So I downloaded Gemma 3's GGUF weights from Hugging Face (ggml-org/gemma-3-4b-it-GGUF, Q4_K_M) and imported them with a two-line Modelfile (FROM ./gemma-3-4b-it-Q4_K_M.gguf). On a normal laptop, ollama pull gemma3:4b is all you need.
Why Does Open Innovation Matter?
My Downloads folder is personal. In this demo alone there's an invoice, a list of my spending with merchant names, my GSoC notes, and a security tool. A cloud API would have to receive the start of every one of those files to do this job. With an open-weight model on my own machine, none of it leaves. The tool enforces that by refusing any Ollama URL that isn't loopback. It's a hard rule, not a privacy policy.
A background job has to cost nothing. Watch mode is supposed to wake up every few minutes, forever. With a paid API, a tidier I'd never think about would turn into a bill I'd have to think about. Here the cost is some CPU time while the files are being read, and it runs offline.
I could open the box. Because the weights were just files, I could get them from Hugging Face when the usual route didn't work, try two model sizes in an afternoon, and measure the trade-off myself. Swapping models is one flag (--model), and any model Ollama can run will work. Ollama's JSON-schema format gave me something I really needed with a 1B–4B model: replies forced into a valid category.
Where closed would have been better: a big hosted model would probably have filed that CSV correctly. For a job where every file is private and the dry run catches mistakes, I'd take a slightly dumber model that never leaves my laptop.
The Lazy Payoff
Everything here was measured on the CPU-only setup above, on the 12 synthetic demo files. I didn't time myself sorting a folder by hand, so I won't put a number on that side.
-
A whole folder planned in under 30 seconds. The full dry run took 29.0 s with
gemma3:4b(15.0 s withgemma3:1b), including loading the model. After the first file it's about 2–3 seconds per file with 4B. Files that extension rules can handle take no model time at all. -
Applying costs nothing extra.
--applyreuses the plan from the dry run, so there's no second round of model calls, andundorestored all 12 files in one command. -
The real payoff is not running it at all. In watch mode, a new file dropped into
Inboxwas picked up on the next wake-up once it had been still for 8 seconds, and Gemma filed it underReceiptsin 2.2 s. With the default settings it checks every 5 minutes and waits until a file has been still for 10, so it just quietly keeps the folder tidy.
The cost of that laziness is reading the dry-run plan once and fixing the odd wrong line, like the CSV that ended up in Receipts.
Prize Categories
-
Best Use of Gemma. Gemma 3 (
gemma3:4bby default,gemma3:1btested) runs locally through Ollama and does all of the reading, categorising and naming, with its replies forced into a valid folder by a JSON schema.
Solo submission by @bladeharper_ec7070d6e28be.






Top comments (0)