SlothSort: a local AI that tidies my Downloads folder while I sleep
This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady What I Built SlothSort is a tiny local agent that cleans up my Downloads folder so I never have to. Every night it looks at each new file, reads a snippet of it, decides what it is, renames it to something s

Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady Submission 🦥
This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady
SlothSort is a tiny local agent that cleans up my Downloads folder so I never have to. Every night it looks at each new file, reads a snippet of it, decides what it is, renames it to something sensible ( Final_FINAL(2).pdf becomes electricity-bill-oct-2026.pdf ), files it into a category folder, and writes a short digest I can read over morning chai. Every move is journaled, so one command undoes the whole run.
It is about 120 lines of Python with no framework and no API keys. The only dependency is Ollama, plus optional pypdf for reading PDFs.
You don't need an API key, an account or internet (after the one-time model download). This uses a sandbox folder with fake messy files, so your real files are never touched.
git clone https://github.com/[[REPLACE-YOUR-USERNAME]]/slothsort.git cd slothsort ollama pull qwen2.5:7b # one-time download, then fully offline pip install pypdf # optional, only needed to read PDFs Enter fullscreen mode Exit fullscreen mode 2. Create a messy sandbox
mkdir -p /tmp/messy && cd /tmp/messy cat > "Final_FINAL(2).txt" << ' EOF ' Electricity Bill - October 2026 Account No: 48201933 Amount due: Rs 2,340 Due date: 28 Oct 2026 EOF cat > "notes (3).txt" << ' EOF ' Operating Systems - lecture notes Round robin vs shortest job first scheduling. Context switch overhead, time quantum, starvation. EOF cat > "script_old.py" << ' EOF ' import requests def fetch(url): return requests.get(url, timeout=10).json() EOF cat > "doc1.txt" << ' EOF ' Hi team, the Diwali party is on Friday at 6 PM. Please bring a snack and confirm your headcount. EOF cat > "download.csv" << ' EOF ' date,item,amount 2026-10-01,groceries,1200 2026-10-03,petrol,800 EOF cd - Enter fullscreen mode Exit fullscreen mode 3. Dry run (nothing moves yet)
python3 sloth.py run --src /tmp/messy --dest /tmp/sorted --min-age 0 Enter fullscreen mode Exit fullscreen mode --min-age 0 is only needed here because the files were just created. Normally SlothSort skips anything edited in the last 2 minutes, in case it is still downloading.
WOULD MOVE: Final_FINAL(2).txt -> Receipts/electricity-bill-oct-2026.txt WOULD MOVE: notes (3).txt -> Study/os-scheduling-notes.txt Enter fullscreen mode Exit fullscreen mode Exact names will vary a little, since it is a small local model.
python3 sloth.py run --src /tmp/messy --dest /tmp/sorted --min-age 0 --apply ls -R /tmp/sorted cat ~/.slothsort/digests/ *.md # your morning digest Enter fullscreen mode Exit fullscreen mode 5. Changed your mind? Undo it
python3 sloth.py undo ls /tmp/messy # everything is back, original names Enter fullscreen mode Exit fullscreen mode 6. Go full sloth (optional)
Point it at your real folder and let cron do it every night at 2 AM:
crontab -e # add this line: 0 2 * * * /usr/bin/python3 ~/slothsort/sloth.py run --apply Enter fullscreen mode Exit fullscreen mode Want to experiment?
`#!/usr/bin/env python3 """SlothSort: a local, open-weight file tidier. Nothing leaves your machine.""" import argparse, json, re, shutil, time, urllib.request from datetime import datetime from pathlib import Path
OLLAMA = " http://localhost:11434/api/chat " MODEL = "qwen2.5:7b" CATEGORIES = ["Documents", "Study", "Receipts", "Images", "Code", "Media", "Archives", "Misc"] TEXT_EXT = {".txt", ".md", ".csv", ".json", ".py", ".js", ".html", ".log"} SKIP_EXT = {".crdownload", ".part", ".tmp", ".download"} HOME = Path.home() / ".slothsort" JOURNAL = HOME / "journal.jsonl" DIGESTS = HOME / "digests"
SYSTEM = ( "You tidy files. Given a filename and a text snippet, reply ONLY with JSON: " '{"category": one of %s, "new_name": "short-descriptive-kebab-case-name-without-extension", ' '"summary": "max 12 words"}. If unsure, use category "Misc" and keep the original name.' % CATEGORIES )
def snippet(path, limit=1500): ext = path.suffix.lower() try: if ext in TEXT_EXT: return path.read_text(errors="ignore")[:limit] if ext == ".pdf": from pypdf import PdfReader # optional: pip install pypdf return (PdfReader(str(path)).pages.extract_text() or "")[:limit] except Exception: pass return ""
def ask(name, text, model): body = json.dumps({ "model": model, "stream": False, "format": "json", "options": {"temperature": 0}, "messages": [ {"role": "system", "content": SYSTEM}, {"role": "user", "content": f"Filename: {name}\nSnippet:\n{text or '(no text available)'}"}, ], }).encode() req = urllib.request.Request(OLLAMA, body, {"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=180) as r: return json.loads(json.loads(r.read())["message"]["content"])
def clean(s, fallback): s = re.sub(r"[^\w- ]", "", str(s)).strip().replace(" ", "-").lower()[:60] return s or fallback
def unique(path): if not path.exists(): return path i = 2 while path.with_name(f"{path.stem}-{i}{path.suffix}").exists(): i += 1 return path.with_name(f"{path.stem}-{i}{path.suffix}")
def run(args): src, dest = Path(args.src).expanduser(), Path(args.dest).expanduser() HOME.mkdir(exist_ok=True); DIGESTS.mkdir(exist_ok=True) run_id = datetime.now().strftime("%Y%m%d-%H%M%S") moved = [] for f in sorted(src.iterdir()): if not f.is_file() or f.name.startswith(".") or f.suffix.lower() in SKIP_EXT: continue if time.time() - f.stat().st_mtime < args.min_age * 60: continue # probably still downloading try: r = ask(f.name, snippet(f), args.model) except Exception as e: print(f"! skipped {f.name}: {e}") continue cat = r.get("category") if r.get("category") in CATEGORIES else "Misc" new = unique(dest / cat / (clean(r.get("new_name"), f.stem) + f.suffix.lower())) summary = str(r.get("summary", ""))[:100] print(f"{'MOVE' if args.apply else 'WOULD MOVE'}: {f.name} -> {cat}/{new.name}") if args.apply: new.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(f), str(new)) with JOURNAL.open("a") as j: j.write(json.dumps({"run": run_id, "from": str(f), "to": str(new)}) + "\n") moved.append((f.name, cat, new.name, summary)) lines = [f"# SlothSort digest {run_id}", f"Tidied {len(moved)} files while you slept.\n"] lines += [f"- {a} -> {b}/{c}: {d}" for a, b, c, d in moved] digest = DIGESTS / f"{run_id}.md" digest.write_text("\n".join(lines) + "\n") print(f"\nDigest: {digest}")
def undo(_): if not JOURNAL.exists(): return print("Nothing to undo.") rows = [json.loads(l) for l in JOURNAL.read_text().splitlines() if l.strip()] last = rows[-1]["run"] keep = [r for r in rows if r["run"]!= last] for r in reversed([r for r in rows if r["run"] == last]): if Path(r["to"]).exists(): shutil.move(r["to"], unique(Path(r["from"]))) print(f"restored {r['from']}") JOURNAL.write_text("".join(json.dumps(r) + "\n" for r in keep))
if name == " main ": p = argparse.ArgumentParser(description="Local AI file tidier") sub = p.add_subparsers(required=True) r = sub.add_parser("run") r.add_argument("--src", default="~/Downloads") r.add_argument("--dest", default="~/Sorted") r.add_argument("--model", default=MODEL) r.add_argument("--min-age", type=int, default=2, help="minutes since last edit") r.add_argument("--apply", action="store_true", help="actually move files (default is dry-run)") r.set_defaults(fn=run) u = sub.add_parser("undo"); u.set_defaults(fn=undo) a = p.parse_args(); a.fn(a)`
The hardest part was getting the small model to reliably return valid JSON with a category from my list. JSON mode, a strict system prompt and falling back to Misc on anything unexpected fixed it.
A file tidier has to read my files: bills, bank statements, ID scans, college documents. I did not want to upload any of that to a server I don't control just to avoid dragging files into folders.
A closed model could probably sort slightly better, but for a task this small, a local 7B model is good enough, and "good enough, private and free" beats "perfect, remote and metered" here.
I used Claude for the code and at some other areas which needed improvement.
Best Open-Source AI Project (overall): SlothSort is a new project built around an open-weight model running locally through Ollama, published in a public MIT-licensed repo with the model and its license named in the README.