"For the firm data — could the uploaded files have a temporary place or similar that is building and needs to be cleared?"

That was the question. Forty seconds of grepping showed the answer was yes, in three different ways.

Where firm data was accumulating

Three places, by design:

  1. Skill working directories at /tmp/aai-* or $TMPDIR/aai-*. Every skill that downloads a PDF or pulls API data creates one via mktemp -d, fills it with PDFs and JSON dumps, then runs rm -rf "$TMPDIR" at the end of the bash block. Standard pattern.
  2. The persistent PDF cache at ~/.aaicase/cache/uploads/{uploadId}.pdf. Used by the mail prefetcher so re-reading a PDF doesn't re-download it.
  3. The API response cache at ~/.aaicase/cache/_*_index.json. Stores parsed responses from /users/index, /tasks/index, etc.

The third one (API cache) was fine. Per-resource TTLs ranged from 60 seconds (volatile data like activities) to 24 hours (static reference data like users). Properly bounded.

The first two were leaks.

Why rm -rf at end of bash block isn't enough

The skill pattern looks correct:

set -euo pipefail
TMPDIR=$(mktemp -d ".../aai-audit-XXXXXX") && chmod 700 "$TMPDIR"
# ... fetch data, download PDFs, do work ...
rm -rf "$TMPDIR"

The cleanup line only runs on the happy path. Every other exit leaves the directory:

Each crash leaves the working dir with whatever was downloaded. Run a skill twice a day for a month, expect 60 stale dirs. Run on a developer machine for a few weeks of probing? 240 stale dirs.

The audit on the dev machine

$ ls -la /var/folders/.../T/ | grep "aai-" | head -5
drwx------  aai-audit-LQZADZ   2026-05-20
drwx------  aai-audit-zLaH1R   2026-05-18
drwx------  aai-bind-3j2zw3    2026-05-20
drwx------  aai-bind-3wQu1T    2026-05-18
drwx------  aai-bind-Gm1T71    2026-05-20

$ du -sh /var/folders/.../T/aai-audit-LQZADZ
3.8M

$ ls /var/folders/.../T/aai-audit-LQZADZ
activities.json   1.1M
case.json         9.5K
events.json       144K
parties.json      8.4K
tasks.json        1.1M

That's a complete case-data dump from May 20. Activities (every UR decision, every QME report, every defense letter), tasks (the attorney's full case task list), events (the calendar), parties (every doctor, adjuster, opposing counsel on the case). Sitting in /tmp with no expiration.

The directory had mode 0o700 (owner-only) — good. But "owner-only" still means it's there if the laptop is lost, stolen, or backed up to a cloud-sync service that ignores ownership bits.

The /clear-cache bug

The CLI had a slash command /clear-cache that the documentation promised would clear the cache. The implementation:

const files = readdirSync(cacheDir);
for (const f of files) {
  unlinkSync(join(cacheDir, f));
}

readdirSync returns both files and directories. unlinkSync only removes files — it errors silently on directories (the error was caught and ignored). So cache/uploads/ (a subdirectory containing all the downloaded PDFs) was always skipped by /clear-cache.

A user who saw "Cache cleared (10 files, 11 MB freed)" and assumed everything was gone? The 11 MB was just the API response JSON. The downloaded PDFs were untouched.

The fix

New lib/cache-cleanup.mjs exposing two operations:

Automatic startup sweep. Runs once on every aaicase startup, non-blocking, best-effort. Default policy:

Configurable via env: AAI_UPLOADS_TTL_DAYS, AAI_UPLOADS_MAX_MB, AAI_TMP_TTL_HOURS. Skip entirely with AAI_CLEANUP_SKIP=1. If anything is removed, prints a one-line stderr summary so the operator sees the cleanup happened.

Manual wipe. The /clear-cache slash command now actually clears everything: top-level API caches, downloaded PDFs in cache/uploads/, and stale /tmp/aai-* dirs regardless of age. One summary line afterwards: "Cleared N items (X MB freed)."

Why the TTLs are what they are

The 24-hour TTL on /tmp/aai-* is much longer than any real skill run (the longest is the audit-case re-bind which finishes in ~10 minutes). Anything older than 24 hours is unambiguously stale. A 1-hour TTL would be safer but risks deleting a still-running long audit.

The 7-day TTL on PDF downloads matches the working assumption that an attorney who downloaded a doc this week may still be reading it, but one from a month ago has already been filed and the cache copy is just stored content.

The 500 MB cap is a backstop — for a firm processing ~50 PDFs per day at ~2 MB each, that's about 5 days of headroom even without the TTL kicking in.

The live test

Ran the helper on the dev machine before shipping:

$ node -e "import('./lib/cache-cleanup.mjs').then(m => { ... })"
Cleanup result: {
  uploads: { removedTtl: 0, removedSize: 0, bytesFreed: 0 },
  tmp:     { removed: 240, bytesFreed: 34612496, scanned: 275 }
}

240 of 275 removed. The 35 that survived were all within the 24-hour window — from active development sessions that day. After the sweep, /tmp had only fresh skill directories.

The general shape of this kind of bug

Three layers of defense against a leak surface and only one of them was working:

  1. Skill cleanup at end of bash block — fragile, only happy path
  2. Persistent cache TTL — didn't exist for PDFs
  3. User-invoked wipe — silently skipped the subdirectory

Each layer would have caught the leak if the others failed. None of them did, and the result accumulated quietly. The lesson is the usual one: every storage location needs a defined retention policy AND an enforcement mechanism AND a user-invoked escape hatch. Two out of three is a leak.

v4.5.137 adds the missing two layers. The first startup after upgrade typically frees a meaningful amount of disk — and removes a quietly-growing collection of firm content from /tmp.