DEV Community

Cover image for SlothSort: a local AI that tidies my Downloads folder while I sleep
Parth Sharma
Parth Sharma

Posted on AI-assisted

SlothSort: a local AI that tidies my Downloads folder while I sleep

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

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 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.

Demo

Try it yourself in 5 minutes

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.

1. Get the code and the model

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.

You should see lines like:

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.

4. Do it for real

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?

  • Try another open model: ollama pull llama3.2, then add --model llama3.2
  • Edit CATEGORIES in sloth.py to match your own life (Invoices, Memes, Thesis)
  • Turn off your Wi-Fi before step 3. It still works, because the model runs on your machine.

Code

`#!/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[0].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)`

How I Built It

  1. Scan. The script lists files in ~/Downloads, skipping hidden files, partial downloads (.crdownload, .part) and anything edited in the last 2 minutes.
  2. Peek. For text and PDF files it grabs the first ~1,500 characters. For other files it just uses the filename.
  3. Ask the local model. It sends the filename and snippet to Ollama's /api/chat with format: "json" and temperature: 0. The model returns {category, new_name, summary}. Constraining the categories to a fixed list keeps the output predictable.
  4. Move safely. Names are sanitised, collisions get -2, -3 suffixes, and the default mode is a dry run that only prints what would happen. --apply does the real move and writes each move to a journal.
  5. Digest. A markdown summary of the night's work is saved to ~/.slothsort/digests/.
  6. Undo. sloth.py undo reads the journal and puts everything from the last run back.
  7. Schedule. One cron line makes it fully hands-off.

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.

Why Does Open Innovation Matter?

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.

  • Privacy: an open-weight model running on my own machine means the contents of my files never leave my laptop.
  • Offline: it works with no internet, including during a power or connectivity cut, which is common enough where I live.
  • Cost: it runs on hardware I already own. A closed API would charge per token every night, forever, for a task I wanted to stop thinking about.
  • Control: the model is one config value. I can swap Qwen for Llama or Gemma with --model, or fine-tune on my own naming style, without waiting for a vendor.
  • Verifiable: the code is MIT, the model weights are open, and anyone can read exactly what touches their files.

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.

My Agent Session

I used Claude for the code and at some other areas which needed improvement.

The Lazy Payoff

  • Before: 140 unsorted files and a search-by-panic habit.
  • After: zero minutes spent sorting, with one cron line and a digest I skim in 10 seconds.
  • Setup took about 7 minutes, and it has now saved me from the "where did I download that?" hunt every week.
  • Because it defaults to dry-run and keeps an undo journal, I trust it enough to ignore it, which is the whole point of a sloth.

Prize Categories

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.

Top comments (0)