DEV Community

Cover image for My Mac was 99% full, so I let Gemma judge my junk (on a short leash)
Edy Cu
Edy Cu

Posted on

My Mac was 99% full, so I let Gemma judge my junk (on a short leash)

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

One evening this week, df -h said my MacBook had 11Gi available out of 926Gi. 99% full. About twenty minutes earlier, a full real run of my new tool had found 118.5 GB of caches, build folders and temp files, marked 34.2 GB of it safe to delete, and deleted none of it. That last part is on purpose.

I knew roughly where the space had gone: node_modules everywhere (the scan counted 63 node_modules folders and kept 42 of them), a Gradle cache, browser caches, .next builds, and a pnpm store the size of a small country. Cleaning that by hand is an afternoon of du -sh * | sort -h and second-guessing every rm -rf. My inner sloth said no. It also wouldn't install a "cleaner" that guesses: guess wrong and you rebuild real work, which is the opposite of lazy.

So I built sweep, for developers whose laptops quietly fill up with node_modules, target/, .venv and package caches. One command finds the regenerable junk on a Mac, asks an open-weight model (Gemma 4, 31B) which of it is safe to delete, and prints a plan. It is a dry run by default. sweep --apply deletes only the SAFE items, and only after you type the size back to it, like DELETE 34.2 GB.

That real run on my Mac:

Measurement Value
Items found 167
Found 118.5 GB
SAFE (by du) 45 items · 34.2 GB
ASK 20 items · 33.6 GB
KEEP 102 items · 50.7 GB
Wall time 202.6 s
Deleted nothing (dry run)

Demo

sweep: a real dry run on a full Mac, from df to the SAFE / ASK / KEEP plan

A second real dry run on the same Mac, about 50 minutes later: df -h ~, then sweep --redact --top 5 (--redact swaps the names under your home for labels like <project-007>). Most of the 3 min 17 s scan and of the 13 s of Gemma calls are cut, and each cut is labelled. This run found 111.7 GB in 145 items, not 118.5 GB in 167: the two scans saw different sets of items; this run's numbers.

The plan groups items under SAFE, ASK and KEEP, each with its size and a one-line reason (the top 15 per group by default), and --json lists every item with who decided. Some real verdicts from that run (project names are redacted in the published evidence):

Size Item Verdict Decided by Why
10.5 GB ~/.gradle/caches SAFE Gemma "Gradle build caches are regenerable."
2.0 GB ~/Library/Caches/BraveSoftware SAFE Gemma "Very old browser cache."
14.9 GB ~/Library/pnpm/store ASK Gemma "Large pnpm store; deleting may require re-downloading many dependencies."
1.4 GB a project's node_modules ASK Gemma "Relatively recent activity (32 days ago) and large size; user should confirm if still needed."
14.0 GB Docker ASK rule never deleted by sweep; it prints docker system prune for you to run
3.8 GB a project's node_modules KEEP rule its repo has uncommitted changes

sweep --json gives the same plan as data. Two real entries from its items list:

[
  {
    "path": "~/.gradle/caches",
    "kind": "build-cache",
    "bytes": 10546253824,
    "verdict": "safe",
    "reason": "Gradle build caches are regenerable.",
    "decided_by": "model"
  },
  {
    "path": "~/<dir>/<project-001>/node_modules",
    "kind": "node_modules",
    "bytes": 3778359296,
    "verdict": "keep",
    "reason": "its repo has uncommitted changes",
    "decided_by": "rule"
  }
]
Enter fullscreen mode Exit fullscreen mode

The full report, with every number: evidence/dry-run-2026-10-25.md.

Code

GitHub logo edycutjong / sweep

A lazy disk-cleanup agent for macOS: an open-weight model (Gemma) judges what is safe; nothing is deleted without your OK.

sweep 🧹

A lazy disk-cleanup agent for macOS. It finds the junk, asks an open-weight model what is safe, and never deletes without your typed OK.



Hacktoberfest AI Challenge Real dry run


Python Gemma OpenRouter macOS License


macOS only for now — adding Linux or Windows is a small pull request, PRs welcome. Every cache location lives in one file, src/sweep/catalogue.py; a port also touches rules.py (protected paths) and reminder.py (launchd).

📸 See it in Action

sweep: a real dry run on a full Mac, from df to the SAFE / ASK / KEEP plan

A second real dry run on the same Mac, about 50 minutes after the evidence run below: df -h ~, then sweep --redact --top 5 (--redact swaps the names under your home for labels like <project-007>). Most of the 3 min 17 s scan and of the 13 s of model calls are cut, and each cut is labelled. This run's numbers: demo-run-2026-10-25.md.

sweep is a dry run by default. This is the real result on the author's own MacBook…

Repo: github.com/edycutjong/sweep (MIT, Python 3.11+, 64 tests)

You need uv, macOS, and an OpenRouter key in OPENROUTER_API_KEY.

uv tool install git+https://github.com/edycutjong/sweep

sweep            # dry run: prints the plan, deletes nothing
sweep --json     # the same plan as JSON (never deletes)
sweep --apply    # delete the SAFE items after a typed confirmation
Enter fullscreen mode Exit fullscreen mode

How I Built It

The model never gets the last word on danger

A language model next to rm -rf is a scary combination, so sweep splits the job in two.

Plain code goes first. rules.py looks at each item and either settles it or sets a ceiling. Some rules are final, and the model never sees those items:

  • Docker images, containers and build cache: never deleted by sweep. It prints docker system prune for you to run.
  • A folder macOS won't let it read: ASK, never SAFE.
  • Anything outside the allowed locations (Documents, Desktop, ~/.ssh, ~/.config, system folders, and more): KEEP.
  • A project whose git repo has uncommitted changes, or a commit in the last 14 days: KEEP.
  • A temp file modified in the last 24 hours: KEEP.

Everything else gets a cap. The cap is safe if the item looks regenerable, and ask if nothing next to it can rebuild it (say, a node_modules with no package.json beside it). Trash, Downloads and simulator data are capped at ASK no matter what.

Then Gemma judges what's left. The final verdict is the more cautious of the rule's cap and Gemma's answer, and a missing or invalid answer counts as ASK:

CAUTION = {"safe": 0, "ask": 1, "keep": 2}

def more_cautious(a: str, b: str) -> str:
    return a if CAUTION[a] >= CAUTION[b] else b
Enter fullscreen mode Exit fullscreen mode

The model can pull a SAFE down to ASK or KEEP, but it can never lift a KEEP to SAFE. A timeout, a garbage reply or a skipped item becomes ASK. So a model failure costs me a question, never a folder. A wrong SAFE inside a SAFE cap is still possible, which is why every line shows its reason, and why --apply lists every target, re-checks each item right before deleting it (project folders get their git state re-read), and logs every path it removes or fails to remove.

In the real run, a hard rule settled or capped 111 of the 167 items, and Gemma's own verdict stood for the other 56.

The contract: bare JSON, or it doesn't count

Gemma sees metadata only: an id, a trimmed path (never the absolute one), the kind, size and age, and for project folders the git state (repo or not, last-commit age, dirty). Never file contents. The system prompt ends with the exact shape it must return:

{"items":[{"id":"<input id>","verdict":"safe|ask|keep","reason":"<one short plain-English sentence>"}]}
Enter fullscreen mode Exit fullscreen mode

parse_verdicts is strict on purpose. A reply wrapped in a code fence doesn't parse, so the whole batch becomes ASK. An entry with an extra key, an id I never sent, a duplicate id, or a reason over 200 characters is dropped, and a dropped entry becomes ASK:

for e in entries:
    if not isinstance(e, dict) or set(e) != {"id", "verdict", "reason"}:
        continue
    if not isinstance(e["id"], str) or e["id"] not in ids or e["id"] in good:
        continue
    reason = e["reason"]
    if e["verdict"] in VERDICTS and isinstance(reason, str) and 0 < len(reason) <= 200:
        good[e["id"]] = {"verdict": e["verdict"], "reason": reason}
Enter fullscreen mode Exit fullscreen mode

Calls go out in batches of 10, at temperature 0, with response_format: json_object, through plain urllib. No SDK.

Picking the model: five open models, the same 20 real items

Before writing the scanner I ran a spike: 20 real items from a read-only scan of my Mac (13 cache locations and 7 project folders), the same prompt for every model, 3 runs each. No rules ran here. This was the raw model.

Model Valid entries per run (of 20) p50 per 10-item call Unsafe SAFE verdicts*
google/gemma-4-31b-it (pinned) 20 / 20 / 20 7.35 s 0
google/gemma-3-27b-it 20 / 20 / 20 7.67 s 0
openai/gpt-oss-120b 20 / 20 / 20 8.01 s 0
meta-llama/llama-3.3-70b-instruct 20 / 20 / 20 10.19 s 0
qwen/qwen3-235b-a22b-2507 20 / 20 / 20 14.76 s 0

* "Unsafe SAFE" means SAFE on Downloads, simulator data, or a project folder whose repo is dirty or had a commit in the last 14 days. It is not a general safety score.

All five cleared the JSON bar, so the tie-break was the part a human actually reads: the reasons. Gemma 4 explained regenerability ("Rust target directory is a regenerable build artifact."). Gemma 3's reasons leaned on size ("It's a large npm cache."). Gemma 4 was also the only one that marked my Trash ASK on its own: "Trash contains files the user explicitly deleted but may want to recover." Fair point, Gemma. It also had the lowest p50 of the five.

The 64 pytest tests cover the hard rules, the JSON contract and its failure fallbacks, the CLI, and --apply against fixture folders only. They use a fake model and never call Gemma. The stack is Python 3.11, uv and rich. I built it with Claude Code as my pair programmer. The model inside the product is Gemma, and nothing closed sits in that path.

Honest limits

  • macOS only for now. Every cache location lives in catalogue.py. A Linux or Windows port is a small pull request that also touches rules.py and reminder.py. PRs welcome, it's Hacktoberfest.
  • Hosted, not local. Trimmed paths, kinds, sizes, ages and git state go to OpenRouter. File contents never do.
  • Not bit-deterministic. The model id is pinned and the temperature is 0, but the serving provider isn't, and a re-run of the spike flipped 2 of 20 borderline verdicts (the pnpm store and the cargo registry, SAFE to ASK). Drift can't cross a rule's cap, but inside a SAFE cap two runs can differ.
  • 34.2 GB is a du number, not freed space. pnpm and bun hard-link files, so deleting frees less. --apply reports the measured change in free space.
  • It's slow. About 200 s on my Mac, for one du per folder, the git reads and the model calls. Slow and steady, as the theme says.
  • 14 macOS-protected folders were unreadable without Full Disk Access. They stay ASK, never SAFE.
  • The reasons are Gemma's judgement, even when they sound like facts. --json records decided_by for every item.

Why Does Open Innovation Matter?

A tool that deletes things needs a model I can pin, re-run and swap more than it needs a clever one. Open weights gave me that.

  • I could compare before trusting. Five open models from four labs, one prompt, one endpoint, one evening, then a pick from a table instead of vibes. Switching is one constant, MODEL_ID. I only tested open models, so I can't tell you Gemma beats a closed one on accuracy. Here is what open gave me instead.
  • The judge can move onto your machine. Today sweep calls Gemma on OpenRouter, because running a 31B model on a 99%-full laptop while du crawls the disk was not the lazy option. But the weights are on Hugging Face. Serve them behind a local OpenAI-compatible server, point ENDPOINT and MODEL_ID in model.py at it (the client still expects some value in OPENROUTER_API_KEY), and not even folder names leave the laptop. A closed model can be retired or quietly updated behind its name. A weights file I keep can't, so the spike above stays re-runnable against the exact same model.
  • The judge can be checked. The plan footer prints the exact model id, --json marks which items it decided, and the weights are public, so anyone can run the same model on the same metadata and look. A closed API shows me a model name. Open weights let me re-run the model itself.
  • The safety rules are open too. They are plain Python: 130 lines in rules.py, the merge in plan.py, the delete-time re-checks in apply.py. What sweep may delete is one code review away, and changing it is one PR.

The Lazy Payoff

My total effort for that run: one command. sweep spent 202.6 s walking the disk, sizing 167 items, reading git state and asking Gemma about the rest, while I sat very still, sloth-style. It reported 34.2 GB (by du) as SAFE, each item with a reason, 20 items it wanted me to look at, and 102 it refused to touch. One of those was a 3.8 GB node_modules in a repo with uncommitted changes: exactly the folder a blanket rm -rf of every node_modules would have eaten.

Doing this by hand means du on every cache, then git status in every project before touching its build folders. I didn't time myself doing that, because I didn't do it. That's the point.

For the full sloth, sweep --install-reminder writes a launchd job for a dry run every Sunday at 10:00, with one notification. It never deletes, and it doesn't switch itself on. It prints the launchctl line that does.

Prize Categories

  • Best Use of Gemma: google/gemma-4-31b-it, served through OpenRouter, is the judge for every item the hard rules leave open. It was picked over four other open models on reason quality and latency (table above).

If your Mac is down to its last few gigabytes too, the dry run deletes nothing, so it's an easy thing to try (it does send trimmed paths and sizes to OpenRouter): github.com/edycutjong/sweep. On Linux or Windows? Send a PR for the catalogue.

Top comments (0)