Storae`optimizer
📝 PythonOnly for people who have serious balls. A combination of rule-based logic and AI to do a precision sweep if you really can't find big directories anymore.
Python
#!/usr/bin/env python3
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import argparse
import asyncio
import getpass
import hashlib
import heapq
import itertools
import json
import logging
import os
import shutil
import statistics
import sys
import threading
import time
import urllib.error
import urllib.request
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
DEFAULT_GATEWAY_ENDPOINT = "https://devplace.net/openai/v1/chat/completions"
DEFAULT_GATEWAY_MODEL = "molodetz"
DEFAULT_REQUEST_TIMEOUT_SECONDS = 180.0
DEFAULT_TEMPERATURE = 0.4
DEFAULT_MAX_OUTPUT_TOKENS = 16000
DEFAULT_DIRECTORY_TIMEOUT_SECONDS = 45.0
DEFAULT_MAX_DIRECTORIES_PER_TOP_LEVEL = 200_000
DEFAULT_MAX_WORKERS = min(16, max(4, (os.cpu_count() or 4) * 2))
DEFAULT_TOP_DIRECTORIES_SHOWN = 20
DEFAULT_TOP_FILES_SHOWN = 25
DEFAULT_TOP_EXTENSIONS_SHOWN = 15
DEFAULT_LARGE_FILE_THRESHOLD_MB = 100.0
DEFAULT_STALE_DAYS = 180
DEFAULT_MIN_DUPLICATE_SIZE_MB = 1.0
DEFAULT_MAX_DUPLICATE_GROUPS_HASHED = 300
DEFAULT_RECLAIMABLE_RECENT_ACTIVITY_DAYS = 98
TOP_DIRECTORY_CANDIDATES_PER_TOP_LEVEL = 100
LARGE_FILE_HEAP_MINIMUM_CAPACITY = 500
LARGE_FILE_HEAP_CAPACITY_MULTIPLIER = 20
DUPLICATE_GROUP_MAX_MEMBERS_TRACKED = 64
DUPLICATE_HASH_CHUNK_BYTES = 1024 * 1024
DUPLICATE_GROUP_PATHS_SHOWN_IN_PAYLOAD = 8
RECLAIMABLE_SUBTREE_ENTRY_CAP = 300_000
RECLAIMABLE_CANDIDATES_SHOWN = 20
STALE_FILES_SHOWN = 20
INACCESSIBLE_EXAMPLES_TRACKED = 30
INACCESSIBLE_EXAMPLES_SHOWN_IN_PAYLOAD = 15
PROTECTED_DATABASE_FILES_SHOWN = 15
BYTE_UNITS = ("B", "KiB", "MiB", "GiB", "TiB", "PiB")
HARD_EXCLUDED_DIRECTORY_NAMES = frozenset({"lost+found"})
HARD_EXCLUDED_ABSOLUTE_PATHS = frozenset({
Path("/proc"), Path("/sys"), Path("/dev"), Path("/run"), Path("/var/run"),
})
DATABASE_FILE_EXTENSIONS = frozenset({
".db", ".db3", ".sqlite", ".sqlite3", ".sqlitedb", ".s3db", ".sl3",
".db-wal", ".db-shm", ".db-journal", ".sqlite-wal", ".sqlite-shm", ".sqlite-journal",
".mdb", ".accdb", ".fdb", ".gdb", ".ndf", ".myd", ".myi", ".ibd", ".frm",
".rdb", ".dbf", ".mdf", ".ldf", ".dsk", ".kdbx", ".realm", ".cdb",
})
RECLAIMABLE_DIRECTORY_NAMES: dict[str, str] = {
"__pycache__": "python bytecode cache",
".pytest_cache": "pytest cache",
".mypy_cache": "mypy cache",
".ruff_cache": "ruff cache",
".tox": "python tox environments",
"venv": "python virtual environment",
".venv": "python virtual environment",
"virtualenv": "python virtual environment",
"site-packages": "python installed packages",
"node_modules": "node.js dependency tree",
".pnpm-store": "pnpm package store",
".npm": "npm cache",
".yarn": "yarn cache",
"bower_components": "bower dependency tree",
"target": "build output (cargo/maven/gradle)",
"build": "build output",
"dist": "build output",
"out": "build output",
"cmake-build-debug": "build output",
"cmake-build-release": "build output",
".gradle": "gradle cache",
".m2": "maven cache",
".cargo": "cargo registry cache",
".rustup": "rust toolchains",
".nuget": "nuget cache",
".gem": "ruby gem cache",
"vendor": "vendored dependencies",
".terraform": "terraform provider cache",
"coverage": "test coverage artifacts",
"htmlcov": "test coverage artifacts",
".cache": "generic application cache",
"cache": "generic application cache",
"cache2": "browser cache",
"gpucache": "browser gpu cache",
".docker": "docker data",
".vscode-server": "vs code remote server data",
"deriveddata": "xcode derived data",
"trash": "trash / recycle bin",
".local-share-trash": "trash / recycle bin",
}
VIRTUAL_FILESYSTEM_TYPES = frozenset({
"proc", "sysfs", "devtmpfs", "devpts", "tmpfs", "cgroup", "cgroup2",
"pstore", "bpf", "tracefs", "debugfs", "securityfs", "mqueue",
"hugetlbfs", "fusectl", "configfs", "autofs", "binfmt_misc", "efivarfs",
"overlay", "squashfs", "ramfs", "rpc_pipefs",
})
LOGGER = logging.getLogger("storageanalysis")
class AnalysisError(Exception):
pass
@dataclass(frozen=True)
class ScanConfig:
root: Path
cross_filesystems: bool
directory_timeout_seconds: float
max_directories_per_top_level: int
max_workers: int
min_duplicate_size_bytes: int
max_duplicate_groups_hashed: int
large_file_threshold_bytes: int
stale_days: int
top_directories_shown: int
top_files_shown: int
top_extensions_shown: int
reclaimable_recent_activity_days: int
extra_excluded_names: frozenset[str]
verbose: bool
@dataclass(frozen=True)
class GatewaySettings:
api_key: str | None
model: str
endpoint: str
temperature: float
max_output_tokens: int
request_timeout_seconds: float
@dataclass(frozen=True)
class AnalysisConfig:
scan_config: ScanConfig
gateway_settings: GatewaySettings
output_path: Path | None
use_llm: bool
verbose: bool
@dataclass
class DirFrame:
path: Path
depth: int
apparent_bytes: int = 0
disk_bytes: int = 0
file_count: int = 0
dir_count: int = 1
inaccessible_count: int = 0
last_modified: float = 0.0
@dataclass(frozen=True)
class ReclaimableCandidate:
path: Path
category: str
apparent_bytes: int
disk_bytes: int
file_count: int
last_modified: float
@dataclass(frozen=True)
class LargeFileRecord:
path: Path
size_bytes: int
mtime: float
atime: float
is_database: bool
@dataclass(frozen=True)
class DuplicateGroup:
size_bytes: int
sha256: str
paths: tuple[Path, ...]
@property
def reclaimable_bytes(self) -> int:
return self.size_bytes * (len(self.paths) - 1)
@dataclass
class TopLevelScanResult:
name: str
path: Path
apparent_bytes: int
disk_bytes: int
file_count: int
dir_count: int
inaccessible_count: int
timed_out: bool
capped: bool
elapsed_seconds: float
top_directories: list[DirFrame] = field(default_factory=list)
@dataclass(frozen=True)
class FilesystemFact:
mount_point: str
filesystem_type: str
total_bytes: int
used_bytes: int
free_bytes: int
@property
def used_percent(self) -> float:
return (self.used_bytes / self.total_bytes * 100) if self.total_bytes else 0.0
@dataclass
class AnalysisFacts:
generated_at: datetime
root_label: str
cross_filesystems: bool
running_as_user: str
running_as_root: bool
total_apparent_bytes: int
total_disk_bytes: int
total_files: int
total_directories: int
inaccessible_count: int
inaccessible_examples: list[tuple[Path, str]]
symlinks_skipped: int
boundary_skipped_mounts: list[Path]
timed_out_entries: list[str]
capped_entries: list[str]
hardlink_bytes_deduplicated: int
hardlink_files_deduplicated: int
median_top_level_disk_bytes: float
mean_top_level_disk_bytes: float
top_directories: list[DirFrame]
top_files: list[LargeFileRecord]
top_extensions: list[tuple[str, int, int]]
reclaimable_recent_activity_days: int
reclaimable_by_category: list[tuple[str, int, int, int]]
top_reclaimable_candidates: list[ReclaimableCandidate]
reclaimable_total_disk_bytes: int
reclaimable_recent_total_disk_bytes: int
reclaimable_recent_location_count: int
reclaimable_recent_examples: list[ReclaimableCandidate]
duplicate_groups: list[DuplicateGroup]
duplicate_total_reclaimable_bytes: int
duplicate_groups_skipped: int
duplicate_files_hashed: int
min_duplicate_size_bytes: int
stale_large_files: list[LargeFileRecord]
stale_days: int
large_file_threshold_bytes: int
protected_database_files: list[LargeFileRecord]
filesystems: list[FilesystemFact]
root_filesystem: FilesystemFact | None
scan_coverage_percent: float | None
def load_dotenv_if_present(*candidate_paths: Path) -> None:
for candidate in candidate_paths:
if not candidate.is_file():
continue
LOGGER.debug(f"loading environment overrides from {candidate}")
try:
with candidate.open("r", encoding="utf-8", errors="replace") as handle:
for raw_line in handle:
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
except OSError as error:
LOGGER.warning(f"could not read env file {candidate}: {error!r}")
def format_bytes(num_bytes: int) -> str:
value = float(max(0, num_bytes))
for unit in BYTE_UNITS:
if value < 1024.0:
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.2f} {unit}"
value /= 1024.0
return f"{value:.2f} PiB"
def format_age_days(timestamp: float, now: datetime) -> str:
delta = now - datetime.fromtimestamp(timestamp)
days = delta.days
if days < 1:
return "today"
if days == 1:
return "1 day ago"
return f"{days} days ago"
def classify_extension(filename: str) -> str:
suffix = Path(filename).suffix.lower()
return suffix if suffix else "(no extension)"
def is_database_file(filename: str) -> bool:
return Path(filename).suffix.lower() in DATABASE_FILE_EXTENSIONS
def reclaimable_candidate_reason(
candidate: ReclaimableCandidate, recent_activity_days: int, now: datetime
) -> str:
if candidate.last_modified <= 0:
activity_desc = "no file activity observed inside it (empty or entirely unreadable)"
else:
activity_desc = f"most recent file inside it was modified {format_age_days(candidate.last_modified, now)}"
cutoff = now.timestamp() - recent_activity_days * 86400
if candidate.last_modified >= cutoff:
verdict = f"EXCLUDED from recommendations (within the {recent_activity_days}-day recent-activity window)"
else:
verdict = f"RECOMMENDED for cleanup review (outside the {recent_activity_days}-day recent-activity window)"
return (
f"directory name '{candidate.path.name}' exact-matched the known category '{candidate.category}'; "
f"{activity_desc}; verdict: {verdict}"
)
def stale_file_reason(record: LargeFileRecord, threshold_bytes: int, stale_days: int, now: datetime) -> str:
return (
f"size {format_bytes(record.size_bytes)} >= large-file-threshold {format_bytes(threshold_bytes)}; "
f"modified {format_age_days(record.mtime, now)} and accessed {format_age_days(record.atime, now)}, "
f"both older than the {stale_days}-day stale threshold; not a database file, so it is eligible to "
"be listed as an archival/cleanup candidate"
)
def duplicate_group_reason(group: DuplicateGroup) -> str:
return (
f"{len(group.paths)} file(s) share identical size {format_bytes(group.size_bytes)} AND an identical "
f"full-file SHA-256 hash ({group.sha256[:12]}...); this is a byte-for-byte confirmed duplicate, not "
f"just a same-size coincidence; reclaimable = (copies - 1) x size = {format_bytes(group.reclaimable_bytes)}"
)
def database_file_reason(record: LargeFileRecord) -> str:
extension = record.path.suffix.lower()
return (
f"extension '{extension}' is present in the protected database-extension list -> permanently "
"excluded from duplicate detection and stale-file candidacy, and never eligible for a deletion/move "
"recommendation regardless of size, age, or redundancy"
)
class ScanState:
def __init__(self, config: ScanConfig) -> None:
self.config = config
self.lock = threading.Lock()
self.seen_inodes: set[tuple[int, int]] = set()
self.hardlink_bytes_deduplicated = 0
self.hardlink_files_deduplicated = 0
self.extension_apparent_bytes: Counter[str] = Counter()
self.extension_file_counts: Counter[str] = Counter()
self._large_file_heap: list[tuple[int, int, LargeFileRecord]] = []
self._large_file_counter = itertools.count()
self.large_file_heap_capacity = max(
LARGE_FILE_HEAP_MINIMUM_CAPACITY,
config.top_files_shown * LARGE_FILE_HEAP_CAPACITY_MULTIPLIER,
)
self.duplicate_size_buckets: dict[int, list[Path]] = {}
self.duplicate_candidates_truncated = 0
self.reclaimable_candidates: list[ReclaimableCandidate] = []
self.reclaimable_subtrees_truncated: list[Path] = []
self.inaccessible_paths: list[tuple[Path, str]] = []
self.inaccessible_count = 0
self.boundary_skipped_mounts: set[Path] = set()
self.symlinks_skipped = 0
def record_inaccessible(self, path: Path, error: OSError) -> None:
with self.lock:
self.inaccessible_count += 1
if len(self.inaccessible_paths) < INACCESSIBLE_EXAMPLES_TRACKED:
self.inaccessible_paths.append((path, repr(error)))
LOGGER.debug(f"inaccessible path={path} reason={error!r}")
def record_symlink_skip(self) -> None:
with self.lock:
self.symlinks_skipped += 1
def record_boundary_skip(self, path: Path) -> None:
with self.lock:
self.boundary_skipped_mounts.add(path)
LOGGER.info(f"mount boundary skip: {path} (different filesystem, use --cross-filesystems to include)")
def record_reclaimable(self, candidate: ReclaimableCandidate) -> None:
with self.lock:
self.reclaimable_candidates.append(candidate)
LOGGER.info(
f"reclaimable candidate: path={candidate.path} category={candidate.category} "
f"disk-bytes={candidate.disk_bytes} files={candidate.file_count}"
)
def record_reclaimable_truncated(self, path: Path) -> None:
with self.lock:
self.reclaimable_subtrees_truncated.append(path)
LOGGER.warning(f"reclaimable subtree sizing truncated (deadline/cap reached): {path}")
def record_file(self, entry: os.DirEntry, frame: DirFrame) -> None:
try:
stat_result = entry.stat(follow_symlinks=False)
except OSError as error:
frame.inaccessible_count += 1
self.record_inaccessible(Path(entry.path), error)
return
size_bytes = stat_result.st_size
actual_disk_bytes = stat_result.st_blocks * 512
path = Path(entry.path)
counted = True
if stat_result.st_nlink > 1:
inode_key = (stat_result.st_dev, stat_result.st_ino)
with self.lock:
if inode_key in self.seen_inodes:
counted = False
self.hardlink_bytes_deduplicated += actual_disk_bytes
self.hardlink_files_deduplicated += 1
else:
self.seen_inodes.add(inode_key)
frame.file_count += 1
if counted:
frame.apparent_bytes += size_bytes
frame.disk_bytes += actual_disk_bytes
extension = classify_extension(entry.name)
with self.lock:
self.extension_apparent_bytes[extension] += size_bytes
self.extension_file_counts[extension] += 1
if counted:
is_database = is_database_file(entry.name)
self._consider_large_file(path, size_bytes, stat_result.st_mtime, stat_result.st_atime, is_database)
if size_bytes >= self.config.min_duplicate_size_bytes and not is_database:
self._record_duplicate_candidate(size_bytes, path)
def _consider_large_file(
self, path: Path, size_bytes: int, mtime: float, atime: float, is_database: bool
) -> None:
record = LargeFileRecord(path=path, size_bytes=size_bytes, mtime=mtime, atime=atime, is_database=is_database)
with self.lock:
heapq.heappush(self._large_file_heap, (size_bytes, next(self._large_file_counter), record))
if len(self._large_file_heap) > self.large_file_heap_capacity:
heapq.heappop(self._large_file_heap)
def _record_duplicate_candidate(self, size_bytes: int, path: Path) -> None:
with self.lock:
bucket = self.duplicate_size_buckets.setdefault(size_bytes, [])
if len(bucket) < DUPLICATE_GROUP_MAX_MEMBERS_TRACKED:
bucket.append(path)
else:
self.duplicate_candidates_truncated += 1
def largest_files(self, count: int) -> list[LargeFileRecord]:
with self.lock:
ordered = sorted(self._large_file_heap, key=lambda item: item[0], reverse=True)
return [record for _, _, record in ordered[:count]]
def stale_large_files(
self, threshold_bytes: int, stale_days: int, count: int, now: datetime
) -> list[LargeFileRecord]:
cutoff = now.timestamp() - stale_days * 86400
with self.lock:
ordered = sorted(self._large_file_heap, key=lambda item: item[0], reverse=True)
candidates = [
record for _, _, record in ordered
if record.size_bytes >= threshold_bytes and record.atime < cutoff and record.mtime < cutoff
and not record.is_database
]
return candidates[:count]
def database_files(self, count: int) -> list[LargeFileRecord]:
with self.lock:
ordered = sorted(self._large_file_heap, key=lambda item: item[0], reverse=True)
candidates = [record for _, _, record in ordered if record.is_database]
return candidates[:count]
def duplicate_group_candidates(self, max_groups: int) -> tuple[list[tuple[int, list[Path]]], int]:
with self.lock:
buckets = [(size, list(paths)) for size, paths in self.duplicate_size_buckets.items() if len(paths) >= 2]
buckets.sort(key=lambda item: item[0] * (len(item[1]) - 1), reverse=True)
selected = buckets[:max_groups]
skipped = len(buckets) - len(selected)
return selected, skipped
def is_hard_excluded(entry_path: Path, lowered_name: str, config: ScanConfig) -> bool:
if lowered_name in HARD_EXCLUDED_DIRECTORY_NAMES:
return True
if lowered_name in config.extra_excluded_names:
return True
return entry_path in HARD_EXCLUDED_ABSOLUTE_PATHS
def sum_subtree_fast(top: Path, config: ScanConfig, state: ScanState, deadline: float, boundary_dev: int) -> DirFrame:
frame = DirFrame(path=top, depth=-1)
stack: list[Path] = [top]
processed = 0
while stack:
if time.monotonic() > deadline or processed >= RECLAIMABLE_SUBTREE_ENTRY_CAP:
state.record_reclaimable_truncated(top)
break
current = stack.pop()
try:
with os.scandir(current) as scanner:
entries = list(scanner)
except OSError:
frame.inaccessible_count += 1
continue
for entry in entries:
processed += 1
try:
if entry.is_symlink():
continue
if entry.is_dir(follow_symlinks=False):
entry_path = Path(entry.path)
lowered = entry.name.lower()
if is_hard_excluded(entry_path, lowered, config):
continue
try:
entry_dev = entry.stat(follow_symlinks=False).st_dev
except OSError:
frame.inaccessible_count += 1
continue
if entry_dev != boundary_dev and not config.cross_filesystems:
continue
frame.dir_count += 1
stack.append(entry_path)
elif entry.is_file(follow_symlinks=False):
try:
stat_result = entry.stat(follow_symlinks=False)
except OSError:
frame.inaccessible_count += 1
continue
size_bytes = stat_result.st_size
actual_disk_bytes = stat_result.st_blocks * 512
counted = True
if stat_result.st_nlink > 1:
inode_key = (stat_result.st_dev, stat_result.st_ino)
with state.lock:
if inode_key in state.seen_inodes:
counted = False
else:
state.seen_inodes.add(inode_key)
frame.file_count += 1
frame.last_modified = max(frame.last_modified, stat_result.st_mtime)
if counted:
frame.apparent_bytes += size_bytes
frame.disk_bytes += actual_disk_bytes
except OSError:
frame.inaccessible_count += 1
return frame
def discover_top_level_directories(config: ScanConfig) -> list[Path]:
directories: list[Path] = []
try:
with os.scandir(config.root) as scanner:
entries = list(scanner)
except OSError as error:
raise AnalysisError(f"cannot list root directory {config.root}: {error!r}") from error
try:
root_dev = os.stat(config.root, follow_symlinks=False).st_dev
except OSError as error:
raise AnalysisError(f"cannot stat root directory {config.root}: {error!r}") from error
entries.sort(key=lambda entry: entry.name.lower())
for entry in entries:
try:
if entry.is_symlink():
continue
if not entry.is_dir(follow_symlinks=False):
continue
entry_path = Path(entry.path)
lowered = entry.name.lower()
if is_hard_excluded(entry_path, lowered, config):
LOGGER.debug(f"root-scan skip dir={entry.name} reason=hard-excluded")
continue
entry_dev = entry.stat(follow_symlinks=False).st_dev
if entry_dev != root_dev and not config.cross_filesystems:
LOGGER.info(f"mount boundary skip at root: {entry.path} (different filesystem, use --cross-filesystems to include)")
continue
except OSError as error:
LOGGER.debug(f"root-scan skip entry={entry.path} reason={error!r}")
continue
directories.append(entry_path)
LOGGER.info(f"root-scan candidate directory discovered: {entry.name}")
return directories
def scan_top_level_directory(top_path: Path, config: ScanConfig, state: ScanState) -> TopLevelScanResult:
name = top_path.name
started = time.monotonic()
deadline = started + config.directory_timeout_seconds
try:
top_dev = os.stat(top_path, follow_symlinks=False).st_dev
except OSError as error:
LOGGER.warning(f"[{name}] cannot stat top-level directory, skipping: {error!r}")
return TopLevelScanResult(
name=name, path=top_path, apparent_bytes=0, disk_bytes=0, file_count=0,
dir_count=0, inaccessible_count=0, timed_out=False, capped=False, elapsed_seconds=0.0,
)
frames: dict[Path, DirFrame] = {}
parent_of: dict[Path, Path] = {}
discovery_order: list[Path] = []
stack: list[tuple[Path, int]] = [(top_path, 0)]
timed_out = False
capped = False
while stack:
if time.monotonic() > deadline:
timed_out = True
LOGGER.warning(f"[{name}] directory deadline reached, reporting partial results")
break
if len(frames) >= config.max_directories_per_top_level:
capped = True
LOGGER.warning(f"[{name}] directory cap reached ({config.max_directories_per_top_level}), reporting partial results")
break
current, depth = stack.pop()
frame = DirFrame(path=current, depth=depth)
frames[current] = frame
discovery_order.append(current)
try:
with os.scandir(current) as scanner:
entries = list(scanner)
except OSError as error:
frame.inaccessible_count += 1
state.record_inaccessible(current, error)
continue
for entry in entries:
try:
if entry.is_symlink():
state.record_symlink_skip()
continue
if entry.is_dir(follow_symlinks=False):
entry_path = Path(entry.path)
lowered = entry.name.lower()
if is_hard_excluded(entry_path, lowered, config):
continue
try:
entry_dev = entry.stat(follow_symlinks=False).st_dev
except OSError as stat_error:
frame.inaccessible_count += 1
state.record_inaccessible(entry_path, stat_error)
continue
if entry_dev != top_dev and not config.cross_filesystems:
state.record_boundary_skip(entry_path)
continue
category = RECLAIMABLE_DIRECTORY_NAMES.get(lowered)
if category is not None:
aggregate = sum_subtree_fast(entry_path, config, state, deadline, entry_dev)
frame.apparent_bytes += aggregate.apparent_bytes
frame.disk_bytes += aggregate.disk_bytes
frame.file_count += aggregate.file_count
frame.dir_count += aggregate.dir_count
frame.inaccessible_count += aggregate.inaccessible_count
state.record_reclaimable(ReclaimableCandidate(
path=entry_path, category=category,
apparent_bytes=aggregate.apparent_bytes, disk_bytes=aggregate.disk_bytes,
file_count=aggregate.file_count, last_modified=aggregate.last_modified,
))
continue
parent_of[entry_path] = current
stack.append((entry_path, depth + 1))
elif entry.is_file(follow_symlinks=False):
state.record_file(entry, frame)
except OSError as error:
frame.inaccessible_count += 1
state.record_inaccessible(Path(entry.path), error)
# discovery_order records each directory before its children are pushed, so its reverse is
# a valid postorder: every child's totals are already folded in by the time its parent is visited.
for node in reversed(discovery_order):
parent = parent_of.get(node)
if parent is None:
continue
parent_frame = frames.get(parent)
if parent_frame is None:
continue
child_frame = frames[node]
parent_frame.apparent_bytes += child_frame.apparent_bytes
parent_frame.disk_bytes += child_frame.disk_bytes
parent_frame.file_count += child_frame.file_count
parent_frame.dir_count += child_frame.dir_count
parent_frame.inaccessible_count += child_frame.inaccessible_count
root_frame = frames.get(top_path)
elapsed = time.monotonic() - started
top_directory_candidates = heapq.nlargest(
TOP_DIRECTORY_CANDIDATES_PER_TOP_LEVEL, frames.values(), key=lambda candidate: candidate.disk_bytes
)
LOGGER.info(
f"[{name}] scan finished elapsed={elapsed:.2f}s "
f"disk-bytes={root_frame.disk_bytes if root_frame else 0} "
f"files={root_frame.file_count if root_frame else 0} "
f"timed-out={timed_out} capped={capped}"
)
return TopLevelScanResult(
name=name, path=top_path,
apparent_bytes=root_frame.apparent_bytes if root_frame else 0,
disk_bytes=root_frame.disk_bytes if root_frame else 0,
file_count=root_frame.file_count if root_frame else 0,
dir_count=root_frame.dir_count if root_frame else 0,
inaccessible_count=root_frame.inaccessible_count if root_frame else 0,
timed_out=timed_out, capped=capped, elapsed_seconds=elapsed,
top_directories=top_directory_candidates,
)
def scan_root_level_files(config: ScanConfig, state: ScanState) -> TopLevelScanResult:
started = time.monotonic()
frame = DirFrame(path=config.root, depth=0)
try:
with os.scandir(config.root) as scanner:
entries = list(scanner)
except OSError as error:
LOGGER.warning(f"cannot list root directory for loose files: {error!r}")
entries = []
for entry in entries:
try:
if entry.is_symlink():
state.record_symlink_skip()
continue
if entry.is_file(follow_symlinks=False):
state.record_file(entry, frame)
except OSError as error:
state.record_inaccessible(Path(entry.path), error)
elapsed = time.monotonic() - started
return TopLevelScanResult(
name="(loose files directly under scan root)", path=config.root,
apparent_bytes=frame.apparent_bytes, disk_bytes=frame.disk_bytes,
file_count=frame.file_count, dir_count=0, inaccessible_count=frame.inaccessible_count,
timed_out=False, capped=False, elapsed_seconds=elapsed,
)
def log_scan_criteria(config: ScanConfig) -> None:
LOGGER.info(f"scan root: {config.root}")
LOGGER.info(
"criteria: this tool is strictly read-only, it never deletes, moves, renames, or "
"modifies any file or directory; it only measures and reports"
)
LOGGER.info(
"criteria: every regular file's true on-disk footprint is measured via st_blocks*512 "
"(actual allocated blocks), not just st_size (apparent size), because sparse files and "
"filesystem block rounding make these differ; both totals are reported"
)
LOGGER.info(
"criteria: files with more than one hard link are counted exactly once, keyed by "
"(device, inode), across the entire scan; without this, hard-linked backups or "
"deduplicated trees would inflate totals with bytes that are not actually duplicated on disk"
)
LOGGER.info(
f"criteria: scanning stays within the filesystem of {config.root} by default (mount "
f"boundaries are respected via st_dev); pass --cross-filesystems to also descend into "
"separately mounted drives and network shares"
)
LOGGER.info(
f"criteria: {len(RECLAIMABLE_DIRECTORY_NAMES)} well-known generated/cache/vendor directory "
"names (node_modules, __pycache__, venv, build/dist/target, package manager caches, browser "
"caches, trash, ...) are sized as a single aggregate and not descended into file-by-file, "
"both for speed and because their individual files are rarely relevant to a cleanup decision"
)
LOGGER.info(
f"criteria: each top-level entry under the root gets its own {config.directory_timeout_seconds:.1f}s "
f"soft deadline and a {config.max_directories_per_top_level} directory cap, checked cooperatively "
f"so one huge subtree cannot stall the others; all top-level entries scan concurrently across up "
f"to {config.max_workers} worker threads"
)
LOGGER.info(
"criteria: duplicate files are found in two passes: files of identical size are grouped in "
"memory first (cheap), then the largest-potential-reclaim groups are fully hashed with SHA-256 "
"to confirm true byte-for-byte duplicates before being reported"
)
LOGGER.info(
f"criteria: a large file is one at or above {format_bytes(config.large_file_threshold_bytes)}; "
f"it is additionally flagged as stale when both its last-modified and last-accessed time are "
f"older than {config.stale_days} day(s)"
)
LOGGER.info(
"criteria: directories are never skipped for being hidden (dotfiles/dotdirs) since caches, "
"trash, and application data live there and are frequently the largest reclaim opportunity; "
"only a small set of pseudo-filesystems (/proc, /sys, /dev, /run) and lost+found are always skipped"
)
LOGGER.info(
f"criteria: files of any known database format ({len(DATABASE_FILE_EXTENSIONS)} extensions "
"covering SQLite, MySQL/MariaDB, Access, Firebird, Redis, DBF, ...) are never recommended for "
"deletion, never included in duplicate-file detection, and never listed as a stale-file cleanup "
"candidate; they are only ever reported as factual, protected context"
)
LOGGER.info(
f"criteria: a reclaimable-category location is only recommended for cleanup when it was not "
f"modified within the last {config.reclaimable_recent_activity_days} day(s) "
f"({config.reclaimable_recent_activity_days // 7} week(s)); locations touched more recently are "
"presumed to be in active use and reported separately, excluded from recommendations"
)
LOGGER.info(
"criteria: every location this run could not read due to insufficient permissions is listed "
"explicitly with advice to skip it; this run does not guess at the contents of unreadable paths"
)
async def run_scan(config: ScanConfig) -> tuple[list[TopLevelScanResult], ScanState]:
if not config.root.is_dir():
raise AnalysisError(f"root is not a directory: {config.root}")
top_level_dirs = discover_top_level_directories(config)
state = ScanState(config)
if not top_level_dirs:
LOGGER.warning(f"no accessible subdirectories found under {config.root}, only loose files will be measured")
LOGGER.info(f"scan starting: {len(top_level_dirs)} top-level entr(y/ies) under {config.root}, workers={config.max_workers}")
started = time.monotonic()
loop = asyncio.get_running_loop()
with ThreadPoolExecutor(max_workers=config.max_workers) as executor:
results = await asyncio.gather(*(
loop.run_in_executor(executor, scan_top_level_directory, top_dir, config, state)
for top_dir in top_level_dirs
))
LOGGER.info(f"scan finished in {time.monotonic() - started:.2f}s across {len(results)} top-level entr(y/ies)")
return list(results), state
def hash_file(path: Path) -> str | None:
hasher = hashlib.sha256()
try:
with path.open("rb") as handle:
while True:
chunk = handle.read(DUPLICATE_HASH_CHUNK_BYTES)
if not chunk:
break
hasher.update(chunk)
except OSError as error:
LOGGER.debug(f"duplicate-hash skip path={path} reason={error!r}")
return None
return hasher.hexdigest()
async def find_confirmed_duplicates(
state: ScanState, config: ScanConfig
) -> tuple[list[DuplicateGroup], int, int]:
buckets, skipped_groups = state.duplicate_group_candidates(config.max_duplicate_groups_hashed)
if not buckets:
return [], skipped_groups, 0
LOGGER.info(f"hashing {len(buckets)} same-size group(s) to confirm true duplicates ({skipped_groups} lower-priority group(s) skipped)")
loop = asyncio.get_running_loop()
confirmed: list[DuplicateGroup] = []
hashed_files = 0
with ThreadPoolExecutor(max_workers=config.max_workers) as executor:
for size_bytes, paths in buckets:
hashes = await asyncio.gather(*(loop.run_in_executor(executor, hash_file, path) for path in paths))
hashed_files += len(paths)
by_hash: dict[str, list[Path]] = {}
for path, digest in zip(paths, hashes):
if digest is None:
continue
by_hash.setdefault(digest, []).append(path)
for digest, group_paths in by_hash.items():
if len(group_paths) >= 2:
confirmed.append(DuplicateGroup(size_bytes=size_bytes, sha256=digest, paths=tuple(group_paths)))
confirmed.sort(key=lambda group: group.reclaimable_bytes, reverse=True)
LOGGER.info(f"duplicate confirmation finished: {len(confirmed)} confirmed group(s), {hashed_files} file(s) hashed")
return confirmed, skipped_groups, hashed_files
def discover_real_filesystems() -> list[FilesystemFact]:
mounts_path = Path("/proc/mounts")
facts: list[FilesystemFact] = []
if not mounts_path.is_file():
return facts
seen_mount_points: set[str] = set()
try:
text = mounts_path.read_text(encoding="utf-8", errors="replace")
except OSError as error:
LOGGER.warning(f"could not read /proc/mounts: {error!r}")
return facts
for line in text.splitlines():
fields = line.split()
if len(fields) < 3:
continue
mount_point, filesystem_type = fields[1], fields[2]
if filesystem_type in VIRTUAL_FILESYSTEM_TYPES:
continue
if mount_point in seen_mount_points:
continue
try:
usage = shutil.disk_usage(mount_point)
except OSError as error:
LOGGER.debug(f"filesystem overview: cannot stat mount {mount_point}: {error!r}")
continue
seen_mount_points.add(mount_point)
facts.append(FilesystemFact(
mount_point=mount_point, filesystem_type=filesystem_type,
total_bytes=usage.total, used_bytes=usage.used, free_bytes=usage.free,
))
facts.sort(key=lambda fact: fact.used_bytes, reverse=True)
return facts
def build_facts(
results: list[TopLevelScanResult],
state: ScanState,
config: ScanConfig,
duplicate_groups: list[DuplicateGroup],
duplicate_groups_skipped: int,
duplicate_files_hashed: int,
filesystems: list[FilesystemFact],
) -> AnalysisFacts:
generated_at = datetime.now()
total_apparent = sum(result.apparent_bytes for result in results)
total_disk = sum(result.disk_bytes for result in results)
total_files = sum(result.file_count for result in results)
total_dirs = sum(result.dir_count for result in results)
timed_out = sorted(result.name for result in results if result.timed_out)
capped = sorted(result.name for result in results if result.capped)
disk_bytes_by_result = [result.disk_bytes for result in results if result.dir_count > 0]
median_top_level = statistics.median(disk_bytes_by_result) if disk_bytes_by_result else 0.0
mean_top_level = statistics.mean(disk_bytes_by_result) if disk_bytes_by_result else 0.0
all_directory_frames: list[DirFrame] = []
for result in results:
all_directory_frames.extend(result.top_directories)
top_directories = heapq.nlargest(
config.top_directories_shown, all_directory_frames, key=lambda candidate: candidate.disk_bytes
)
top_files = state.largest_files(config.top_files_shown)
top_extensions = [
(extension, size, state.extension_file_counts[extension])
for extension, size in state.extension_apparent_bytes.most_common(config.top_extensions_shown)
]
recent_activity_cutoff = generated_at.timestamp() - config.reclaimable_recent_activity_days * 86400
stale_candidates = [c for c in state.reclaimable_candidates if c.last_modified < recent_activity_cutoff]
recent_candidates = [c for c in state.reclaimable_candidates if c.last_modified >= recent_activity_cutoff]
reclaimable_grouped: dict[str, list[ReclaimableCandidate]] = {}
for candidate in stale_candidates:
reclaimable_grouped.setdefault(candidate.category, []).append(candidate)
reclaimable_by_category = sorted(
(
(category, sum(c.disk_bytes for c in items), sum(c.file_count for c in items), len(items))
for category, items in reclaimable_grouped.items()
),
key=lambda item: item[1], reverse=True,
)
reclaimable_total = sum(item[1] for item in reclaimable_by_category)
top_reclaimable_candidates = sorted(
stale_candidates, key=lambda candidate: candidate.disk_bytes, reverse=True
)[:RECLAIMABLE_CANDIDATES_SHOWN]
reclaimable_recent_total = sum(c.disk_bytes for c in recent_candidates)
reclaimable_recent_examples = sorted(
recent_candidates, key=lambda candidate: candidate.disk_bytes, reverse=True
)[:RECLAIMABLE_CANDIDATES_SHOWN]
duplicate_total_reclaimable = sum(group.reclaimable_bytes for group in duplicate_groups)
stale_files = state.stale_large_files(
config.large_file_threshold_bytes, config.stale_days, STALE_FILES_SHOWN, generated_at
)
protected_database_files = state.database_files(PROTECTED_DATABASE_FILES_SHOWN)
root_filesystem: FilesystemFact | None = None
scan_coverage_percent: float | None = None
try:
usage = shutil.disk_usage(config.root)
root_filesystem = FilesystemFact(
mount_point=str(config.root), filesystem_type="(scan root filesystem)",
total_bytes=usage.total, used_bytes=usage.used, free_bytes=usage.free,
)
if usage.used > 0:
scan_coverage_percent = min(100.0, total_disk / usage.used * 100)
except OSError as error:
LOGGER.warning(f"could not read disk usage for scan root: {error!r}")
running_as_root = os.geteuid() == 0
try:
running_as_user = getpass.getuser()
except (OSError, KeyError):
running_as_user = f"uid={os.geteuid()}"
return AnalysisFacts(
generated_at=generated_at,
root_label=str(config.root),
cross_filesystems=config.cross_filesystems,
running_as_user=running_as_user,
running_as_root=running_as_root,
total_apparent_bytes=total_apparent,
total_disk_bytes=total_disk,
total_files=total_files,
total_directories=total_dirs,
inaccessible_count=state.inaccessible_count,
inaccessible_examples=list(state.inaccessible_paths),
symlinks_skipped=state.symlinks_skipped,
boundary_skipped_mounts=sorted(state.boundary_skipped_mounts),
timed_out_entries=timed_out,
capped_entries=capped,
hardlink_bytes_deduplicated=state.hardlink_bytes_deduplicated,
hardlink_files_deduplicated=state.hardlink_files_deduplicated,
median_top_level_disk_bytes=median_top_level,
mean_top_level_disk_bytes=mean_top_level,
top_directories=top_directories,
top_files=top_files,
top_extensions=top_extensions,
reclaimable_recent_activity_days=config.reclaimable_recent_activity_days,
reclaimable_by_category=reclaimable_by_category,
top_reclaimable_candidates=top_reclaimable_candidates,
reclaimable_total_disk_bytes=reclaimable_total,
reclaimable_recent_total_disk_bytes=reclaimable_recent_total,
reclaimable_recent_location_count=len(recent_candidates),
reclaimable_recent_examples=reclaimable_recent_examples,
duplicate_groups=duplicate_groups,
duplicate_total_reclaimable_bytes=duplicate_total_reclaimable,
duplicate_groups_skipped=duplicate_groups_skipped,
duplicate_files_hashed=duplicate_files_hashed,
min_duplicate_size_bytes=config.min_duplicate_size_bytes,
stale_large_files=stale_files,
stale_days=config.stale_days,
large_file_threshold_bytes=config.large_file_threshold_bytes,
protected_database_files=protected_database_files,
filesystems=filesystems,
root_filesystem=root_filesystem,
scan_coverage_percent=scan_coverage_percent,
)
def facts_to_prompt_payload(facts: AnalysisFacts) -> dict[str, Any]:
def age(record: LargeFileRecord) -> str:
return format_age_days(record.mtime, facts.generated_at)
return {
"generated_at": facts.generated_at.isoformat(),
"root_label": facts.root_label,
"cross_filesystems": facts.cross_filesystems,
"running_as_user": facts.running_as_user,
"running_as_root": facts.running_as_root,
"totals": {
"total_apparent_bytes": facts.total_apparent_bytes,
"total_apparent_human": format_bytes(facts.total_apparent_bytes),
"total_disk_bytes": facts.total_disk_bytes,
"total_disk_human": format_bytes(facts.total_disk_bytes),
"total_files": facts.total_files,
"total_directories": facts.total_directories,
"symlinks_skipped": facts.symlinks_skipped,
"boundary_skipped_mounts": [str(path) for path in facts.boundary_skipped_mounts[:10]],
"timed_out_entries": facts.timed_out_entries,
"capped_entries": facts.capped_entries,
"hardlink_bytes_deduplicated_human": format_bytes(facts.hardlink_bytes_deduplicated),
"hardlink_files_deduplicated": facts.hardlink_files_deduplicated,
"median_top_level_disk_bytes_human": format_bytes(int(facts.median_top_level_disk_bytes)),
"mean_top_level_disk_bytes_human": format_bytes(int(facts.mean_top_level_disk_bytes)),
"scan_coverage_percent_of_filesystem_used": (
round(facts.scan_coverage_percent, 1) if facts.scan_coverage_percent is not None else None
),
},
"inaccessible_locations": {
"count": facts.inaccessible_count,
"advice": "skip these locations (or re-run as a user/root with read access to them)",
"examples": [
{"path": str(path), "reason": reason, "advice": "skip this location"}
for path, reason in facts.inaccessible_examples[:INACCESSIBLE_EXAMPLES_SHOWN_IN_PAYLOAD]
],
},
"top_directories": [
{"path": str(frame.path), "disk_bytes_human": format_bytes(frame.disk_bytes), "files": frame.file_count}
for frame in facts.top_directories
],
"top_files": [
{
"path": str(record.path), "size_human": format_bytes(record.size_bytes),
"last_modified": age(record),
"is_database": record.is_database,
}
for record in facts.top_files
],
"top_extensions_by_size": [
{"extension": extension, "total_size_human": format_bytes(size), "file_count": count}
for extension, size, count in facts.top_extensions
],
"protected_database_files": {
"note": (
"These are database files (any format). Never recommend deleting, moving, truncating, "
"or otherwise modifying them; mention size/location as factual context only, and suggest "
"database-level maintenance (VACUUM, archiving, purging old rows) if relevant."
),
"files": [
{
"path": str(record.path), "size_human": format_bytes(record.size_bytes),
"last_modified": age(record), "reason": database_file_reason(record),
}
for record in facts.protected_database_files
],
},
"reclaimable_space": {
"recent_activity_window_days": facts.reclaimable_recent_activity_days,
"recommended_for_cleanup": {
"note": "these locations were NOT modified within the recent-activity window; safe to recommend for review",
"total_disk_bytes_human": format_bytes(facts.reclaimable_total_disk_bytes),
"by_category": [
{
"category": category, "disk_bytes_human": format_bytes(disk_bytes),
"file_count": file_count, "location_count": location_count,
}
for category, disk_bytes, file_count, location_count in facts.reclaimable_by_category
],
"top_locations": [
{
"path": str(candidate.path), "category": candidate.category,
"disk_bytes_human": format_bytes(candidate.disk_bytes), "file_count": candidate.file_count,
"reason": reclaimable_candidate_reason(
candidate, facts.reclaimable_recent_activity_days, facts.generated_at
),
}
for candidate in facts.top_reclaimable_candidates
],
},
"excluded_recently_active": {
"note": (
"these locations WERE modified within the recent-activity window and are presumed to be "
"in active use; do not recommend cleanup for them"
),
"location_count": facts.reclaimable_recent_location_count,
"total_disk_bytes_human": format_bytes(facts.reclaimable_recent_total_disk_bytes),
"examples": [
{
"path": str(candidate.path), "category": candidate.category,
"disk_bytes_human": format_bytes(candidate.disk_bytes),
"reason": reclaimable_candidate_reason(
candidate, facts.reclaimable_recent_activity_days, facts.generated_at
),
}
for candidate in facts.reclaimable_recent_examples
],
},
},
"duplicate_files": {
"note": "database files are never included here, regardless of duplication",
"min_duplicate_size_human": format_bytes(facts.min_duplicate_size_bytes),
"confirmed_group_count": len(facts.duplicate_groups),
"groups_skipped_due_to_limit": facts.duplicate_groups_skipped,
"files_hashed_for_confirmation": facts.duplicate_files_hashed,
"total_reclaimable_bytes_human": format_bytes(facts.duplicate_total_reclaimable_bytes),
"top_groups": [
{
"size_human": format_bytes(group.size_bytes), "copies": len(group.paths),
"reclaimable_human": format_bytes(group.reclaimable_bytes),
"paths": [str(path) for path in group.paths[:DUPLICATE_GROUP_PATHS_SHOWN_IN_PAYLOAD]],
"paths_omitted": max(0, len(group.paths) - DUPLICATE_GROUP_PATHS_SHOWN_IN_PAYLOAD),
"reason": duplicate_group_reason(group),
}
for group in facts.duplicate_groups[:20]
],
},
"stale_large_files": {
"note": "database files are never included here, regardless of staleness; see protected_database_files instead",
"stale_after_days": facts.stale_days,
"large_file_threshold_human": format_bytes(facts.large_file_threshold_bytes),
"files": [
{
"path": str(record.path), "size_human": format_bytes(record.size_bytes),
"last_modified": age(record),
"reason": stale_file_reason(record, facts.large_file_threshold_bytes, facts.stale_days, facts.generated_at),
}
for record in facts.stale_large_files
],
},
"filesystem_overview": {
"root_filesystem": (
{
"mount_point": facts.root_filesystem.mount_point,
"total_human": format_bytes(facts.root_filesystem.total_bytes),
"used_human": format_bytes(facts.root_filesystem.used_bytes),
"free_human": format_bytes(facts.root_filesystem.free_bytes),
"used_percent": round(facts.root_filesystem.used_percent, 1),
}
if facts.root_filesystem is not None else None
),
"all_mounted_filesystems": [
{
"mount_point": fact.mount_point, "type": fact.filesystem_type,
"total_human": format_bytes(fact.total_bytes), "used_human": format_bytes(fact.used_bytes),
"used_percent": round(fact.used_percent, 1),
}
for fact in facts.filesystems
],
},
}
REPORT_SYSTEM_PROMPT = """\
You write critical, evidence-based disk-usage sweep reports from structured JSON facts \
produced by a read-only filesystem scanner. You are professional, scientific, and concise. \
Never use emoticons, conversational filler, or self-referential AI language. Base every \
claim strictly on the JSON facts supplied by the user; never invent paths, byte counts, or \
categories not present in the data. Be balanced and genuinely critical: state real space \
consumers and real reclaim opportunities, supported by concrete figures quoted from the \
facts. Do not soften findings to be polite, and do not manufacture urgency or reassurance \
the data does not support.
This tool is strictly read-only: it never deletes, moves, renames, or modifies any file. \
Every recommendation you write must be framed as something for the reader to manually \
review and decide on, never as an instruction to blindly execute a destructive command. \
When you mention a cleanup candidate (a cache, build artifact, duplicate file, or old \
large file), state what it is, how large it is, and why it is typically safe to review \
for removal, but stop short of telling the reader to run rm -rf or similar without \
first inspecting the contents themselves. This report may be consumed by automated \
cleanup tooling, so every rule below is a hard constraint, not a stylistic preference.
HARD RULE โ databases: never recommend deleting, moving, truncating, compacting, \
archiving-by-removal, or otherwise modifying any file listed under "protected_database_files" \
or flagged "is_database": true anywhere in the JSON, regardless of its size, age, or apparent \
redundancy, and regardless of database format (SQLite, MySQL/MariaDB, PostgreSQL, Access, \
Firebird, Redis RDB, DBF, or any other). You may state their path and size as neutral factual \
context. If you want to note a large database as a space consumer, phrase it as "review with \
the owning application (VACUUM/archiving/purging), not as a file to remove."
HARD RULE โ recent activity: the JSON separates reclaimable locations into \
"recommended_for_cleanup" (untouched within the recent-activity window) and \
"excluded_recently_active" (modified within that window, presumed in active use). Only ever \
name specific locations from "recommended_for_cleanup" as cleanup candidates. You may mention \
the excluded_recently_active total as a factual aggregate, but never name or recommend \
removing an individual location from it.
HARD RULE โ permissions: for every entry in "inaccessible_locations.examples", state plainly \
that the current user could not read it and advise the reader to skip that location (or re-run \
with a user/root that has access if they specifically need it included); never guess at what it \
might contain.
EVALUABILITY: every item under reclaimable_space, protected_database_files, duplicate_files, and \
stale_large_files carries a "reason" field explaining exactly why it was classified the way it \
was. When you name a specific location or file as a cleanup candidate (or explain why one was \
excluded), paraphrase or quote its "reason" field so the claim is traceable back to a concrete \
rule and measurement, not just an assertion. Do not write your own separate "rules" or "methodology" \
section โ a "Decision Rules Applied" appendix listing every rule and this run's exact threshold \
values is appended programmatically after your narrative; do not duplicate it.
Structure the report as Markdown with exactly these sections, in this order:
1. Start with one bold-lead-in paragraph summarizing the overall picture.
2. "### 1. Overall Disk Picture"
3. "### 2. Largest Space Consumers"
4. "### 3. Reclaimable Space Candidates"
5. "### 4. Structural Patterns and Risks"
6. "### 5. Broader Implications"
7. "### Potential Actions to Consider" as a numbered list
8. A final paragraph starting with "**Bottom Line**:"
Each of sections 2 through 5 must end with a short italicized conclusion sentence starting \
with "**Conclusion**:" or "**Key observation**:" that states the critical takeaway for that \
section, grounded in the numbers just discussed. Do not include a top-level Markdown title, \
do not repeat the raw JSON, and do not use emoji.
"""
def build_user_prompt(payload: dict[str, Any]) -> str:
return (
"Analyze the following read-only disk-usage sweep dataset and produce the report "
"described in the system prompt. Every number you cite must come from this JSON.\n\n"
"```json\n" + json.dumps(payload, indent=2, sort_keys=False) + "\n```\n"
)
def call_gateway_chat(settings: GatewaySettings, system_prompt: str, user_prompt: str) -> str:
if not settings.api_key:
raise AnalysisError("DEVPLACE_API_KEY is not set")
payload = {
"model": settings.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": settings.temperature,
"max_tokens": settings.max_output_tokens,
}
body = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
settings.endpoint,
data=body,
method="POST",
headers={
"Authorization": f"Bearer {settings.api_key}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=settings.request_timeout_seconds) as response:
raw_body = response.read()
gateway_headers = dict(response.headers)
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
raise AnalysisError(f"DevPlace gateway HTTP error {error.code}: {detail}") from error
except urllib.error.URLError as error:
raise AnalysisError(f"DevPlace gateway request failed: {error.reason!r}") from error
except TimeoutError as error:
raise AnalysisError(f"DevPlace gateway request timed out after {settings.request_timeout_seconds}s") from error
cost = gateway_headers.get("X-Gateway-Cost-USD")
total_tokens = gateway_headers.get("X-Gateway-Total-Tokens")
backend = gateway_headers.get("X-Gateway-Backend")
if cost is not None or total_tokens is not None:
LOGGER.info(f"gateway usage: backend={backend} total-tokens={total_tokens} cost-usd={cost}")
try:
parsed = json.loads(raw_body)
choice = parsed["choices"][0]
content = choice["message"]["content"]
except (json.JSONDecodeError, KeyError, IndexError, TypeError) as error:
raise AnalysisError(f"unexpected gateway response shape: {error!r}") from error
finish_reason = choice.get("finish_reason")
if finish_reason == "length":
usage = parsed.get("usage", {})
raise AnalysisError(
f"gateway response was truncated (finish_reason=length, usage={usage}); "
"raise --max-output-tokens or shorten the prompt"
)
if finish_reason not in (None, "stop"):
LOGGER.warning(f"gateway finish_reason was {finish_reason!r}, response may be incomplete")
return content.strip()
async def generate_llm_report(settings: GatewaySettings, payload: dict[str, Any]) -> str:
loop = asyncio.get_running_loop()
user_prompt = build_user_prompt(payload)
LOGGER.info(f"requesting narrative analysis from DevPlace gateway model={settings.model}")
started = time.monotonic()
with ThreadPoolExecutor(max_workers=1) as executor:
content = await loop.run_in_executor(
executor, call_gateway_chat, settings, REPORT_SYSTEM_PROMPT, user_prompt
)
LOGGER.info(f"gateway response received in {time.monotonic() - started:.2f}s ({len(content)} chars)")
return content
def build_fallback_report(facts: AnalysisFacts) -> str:
lines: list[str] = []
lines.append(
f"**{format_bytes(facts.total_disk_bytes)} of actual disk usage** were measured across "
f"**{facts.total_files} file(s)** in **{facts.total_directories} director(y/ies)** under "
f"`{facts.root_label}`. This is a deterministic, template-based analysis: DevPlace gateway "
"narrative generation was not used for this report. This tool is read-only and modified nothing."
)
lines.append("")
lines.append("### 1. Overall Disk Picture")
role_label = "root (superuser)" if facts.running_as_root else "a regular user"
lines.append(f"- Running as: `{facts.running_as_user}` ({role_label}).")
lines.append(
f"- Apparent size: {format_bytes(facts.total_apparent_bytes)}; actual on-disk size: "
f"{format_bytes(facts.total_disk_bytes)} (block-rounding and sparse files explain the difference)."
)
if facts.root_filesystem is not None:
lines.append(
f"- Scan root filesystem: {format_bytes(facts.root_filesystem.used_bytes)} used of "
f"{format_bytes(facts.root_filesystem.total_bytes)} total "
f"({facts.root_filesystem.used_percent:.1f}% full)."
)
if facts.scan_coverage_percent is not None:
lines.append(
f"- This scan directly measured {facts.scan_coverage_percent:.1f}% of the bytes the "
"filesystem reports as used; the remainder is outside the scan root, on other mounted "
"filesystems, or inaccessible."
)
if facts.hardlink_files_deduplicated:
lines.append(
f"- {facts.hardlink_files_deduplicated} hard-linked file(s) "
f"({format_bytes(facts.hardlink_bytes_deduplicated)}) were counted only once, avoiding "
"double counting."
)
if facts.inaccessible_count:
lines.append(
f"- {facts.inaccessible_count} location(s) could not be read by `{facts.running_as_user}` "
"(permission denied or transient errors); their contents are not reflected in totals above. "
"Skip these locations, or re-run as a user/root with read access if they must be included:"
)
for path, _ in facts.inaccessible_examples[:INACCESSIBLE_EXAMPLES_SHOWN_IN_PAYLOAD]:
lines.append(f" - `{path}` โ skip this location")
omitted_inaccessible = facts.inaccessible_count - min(
len(facts.inaccessible_examples), INACCESSIBLE_EXAMPLES_SHOWN_IN_PAYLOAD
)
if omitted_inaccessible > 0:
lines.append(f" - ... {omitted_inaccessible} more inaccessible location(s) omitted ...")
if facts.timed_out_entries or facts.capped_entries:
lines.append(
f"- {len(facts.timed_out_entries)} top-level entr(y/ies) hit the scan deadline and "
f"{len(facts.capped_entries)} hit the directory cap; true size for those is at least as "
"high as reported."
)
lines.append(
"**Conclusion**: " + (
"The measured on-disk footprint accounts for the large majority of what the filesystem "
"reports as used, giving high confidence in the figures below."
if (facts.scan_coverage_percent or 0) >= 80 else
"A meaningful share of used disk space falls outside this scan's direct coverage; treat "
"the figures below as a lower bound rather than the full picture."
)
)
lines.append("")
lines.append("### 2. Largest Space Consumers")
if facts.top_directories:
top_dirs = "; ".join(
f"{frame.path} ({format_bytes(frame.disk_bytes)})" for frame in facts.top_directories[:8]
)
lines.append(f"- Heaviest directories: {top_dirs}.")
if facts.top_files:
top_files = "; ".join(
f"{record.path} ({format_bytes(record.size_bytes)}{', database file โ protected' if record.is_database else ''})"
for record in facts.top_files[:8]
)
lines.append(f"- Heaviest individual files: {top_files}.")
if facts.top_extensions:
top_ext = ", ".join(
f"{extension} ({format_bytes(size)}, {count} file(s))"
for extension, size, count in facts.top_extensions[:10]
)
lines.append(f"- Space by file type: {top_ext}.")
lines.append(
"**Conclusion**: " + (
"Disk usage is concentrated in a small number of directories and file types rather than "
"spread evenly, which narrows where cleanup effort will have the most impact."
if facts.top_directories else
"No directories exceeded the reclaimable/tracking thresholds for this run."
)
)
lines.append("")
lines.append("### 3. Reclaimable Space Candidates")
if facts.reclaimable_by_category:
category_lines = "; ".join(
f"{category} ({format_bytes(disk_bytes)} across {location_count} location(s))"
for category, disk_bytes, _, location_count in facts.reclaimable_by_category[:10]
)
lines.append(
f"- Known generated/cache/vendor directories not modified in the last "
f"{facts.reclaimable_recent_activity_days} day(s) total "
f"{format_bytes(facts.reclaimable_total_disk_bytes)} and are recommended for review: "
f"{category_lines}."
)
else:
lines.append("- No well-known generated/cache/vendor directories were detected as safe to recommend.")
if facts.top_reclaimable_candidates:
lines.append("- Evidence for the top recommended candidates (evaluate each against its stated reason):")
for candidate in facts.top_reclaimable_candidates[:5]:
reason = reclaimable_candidate_reason(candidate, facts.reclaimable_recent_activity_days, facts.generated_at)
lines.append(f" - `{candidate.path}` ({format_bytes(candidate.disk_bytes)}): {reason}")
if facts.reclaimable_recent_location_count:
lines.append(
f"- {facts.reclaimable_recent_location_count} additional reclaimable-category location(s) "
f"totaling {format_bytes(facts.reclaimable_recent_total_disk_bytes)} were modified within the "
f"last {facts.reclaimable_recent_activity_days} day(s) and are presumed in active use; they are "
"excluded from cleanup recommendations. Evidence for the largest excluded location(s):"
)
for candidate in facts.reclaimable_recent_examples[:3]:
reason = reclaimable_candidate_reason(candidate, facts.reclaimable_recent_activity_days, facts.generated_at)
lines.append(f" - `{candidate.path}` ({format_bytes(candidate.disk_bytes)}): {reason}")
if facts.duplicate_groups:
lines.append(
f"- {len(facts.duplicate_groups)} confirmed duplicate-file group(s) "
f"({facts.duplicate_files_hashed} file(s) hash-verified, database files never included) could "
f"reclaim {format_bytes(facts.duplicate_total_reclaimable_bytes)} if redundant copies were removed."
)
for group in facts.duplicate_groups[:3]:
lines.append(f" - {duplicate_group_reason(group)}")
if facts.duplicate_groups_skipped:
lines.append(
f"- {facts.duplicate_groups_skipped} additional same-size group(s) were not "
"hash-verified due to the group limit for this run."
)
if facts.stale_large_files:
stale_names = ", ".join(
f"{record.path} ({format_bytes(record.size_bytes)}, unused {facts.stale_days}+ days)"
for record in facts.stale_large_files[:6]
)
lines.append(f"- Large non-database files untouched for {facts.stale_days}+ days: {stale_names}.")
top_stale = facts.stale_large_files[0]
lines.append(
f" - Evidence for `{top_stale.path}`: "
f"{stale_file_reason(top_stale, facts.large_file_threshold_bytes, facts.stale_days, facts.generated_at)}"
)
if facts.protected_database_files:
db_total = sum(record.size_bytes for record in facts.protected_database_files)
db_names = ", ".join(
f"{record.path} ({format_bytes(record.size_bytes)})" for record in facts.protected_database_files[:6]
)
lines.append(
f"- {len(facts.protected_database_files)} large database file(s) totaling {format_bytes(db_total)} "
f"were found and are excluded from every cleanup recommendation below: {db_names}. Use "
"database-level maintenance (VACUUM, archiving, purging old rows) instead of file deletion. "
f"Evidence: {database_file_reason(facts.protected_database_files[0])}"
)
lines.append(
"**Conclusion**: " + (
f"Reviewing generated/cache directories, confirmed duplicates, and stale large files "
f"together represents a real, non-destructive-to-decide reclaim opportunity of roughly "
f"{format_bytes(facts.reclaimable_total_disk_bytes + facts.duplicate_total_reclaimable_bytes)}."
if (facts.reclaimable_total_disk_bytes or facts.duplicate_total_reclaimable_bytes) else
"No significant reclaimable-space signals were detected in this dataset."
)
)
lines.append("")
lines.append("### 4. Structural Patterns and Risks")
scattered_categories = [item for item in facts.reclaimable_by_category if item[3] >= 3]
if scattered_categories:
scattered_names = ", ".join(f"{category} ({count}x)" for category, _, _, count in scattered_categories[:8])
lines.append(f"- Categories repeated across many locations (possible sprawl): {scattered_names}.")
if facts.boundary_skipped_mounts:
lines.append(
f"- {len(facts.boundary_skipped_mounts)} separately mounted filesystem(s) under the scan "
"root were not descended into; pass --cross-filesystems to include them."
)
if facts.symlinks_skipped:
lines.append(f"- {facts.symlinks_skipped} symbolic link(s) were encountered and skipped (never followed).")
lines.append(
"**Key observation**: " + (
"Repeated generated-directory categories across many locations point to per-project "
"tooling artifacts accumulating system-wide rather than a single large offender."
if scattered_categories else
"No strong sprawl signal was detected; reclaimable space is concentrated rather than scattered."
)
)
lines.append("")
lines.append("### 5. Broader Implications")
if facts.root_filesystem is not None:
lines.append(
f"- At {facts.root_filesystem.used_percent:.1f}% full "
f"({format_bytes(facts.root_filesystem.free_bytes)} free), the urgency of reclaiming space "
"depends on how close this is to the filesystem's practical operating threshold."
)
risk_signals = []
if facts.reclaimable_total_disk_bytes:
risk_signals.append("generated/cache sprawl")
if facts.duplicate_total_reclaimable_bytes:
risk_signals.append("duplicate data")
if facts.inaccessible_count:
risk_signals.append("incomplete coverage due to permissions")
lines.append(
"**Conclusion**: " + (
f"The dataset shows identifiable, low-risk reclaim opportunities ({', '.join(risk_signals)}) "
"that can reduce disk pressure without touching personal data."
if risk_signals else
"No material risk signals were detected in this dataset."
)
)
lines.append("")
lines.append("### Potential Actions to Consider")
suggestion_number = 1
if facts.reclaimable_by_category:
lines.append(
f"{suggestion_number}. Review the {len(facts.reclaimable_by_category)} reclaimable "
f"categor(y/ies) above (total {format_bytes(facts.reclaimable_total_disk_bytes)}); most "
"package/build caches regenerate automatically and are typically safe to clear after "
"confirming nothing is mid-build."
)
suggestion_number += 1
if facts.duplicate_groups:
lines.append(
f"{suggestion_number}. Inspect the confirmed duplicate-file groups and manually remove "
"redundant copies you no longer need, keeping at least one copy of each."
)
suggestion_number += 1
if facts.stale_large_files:
lines.append(
f"{suggestion_number}. Review the stale large non-database files list for archival candidates "
"(move to cold storage) rather than immediate deletion."
)
suggestion_number += 1
if facts.protected_database_files:
lines.append(
f"{suggestion_number}. Do not delete or move any of the "
f"{len(facts.protected_database_files)} database file(s) listed above; if they need to shrink, "
"use the owning application's own maintenance (VACUUM, archiving, purging old rows)."
)
suggestion_number += 1
if facts.reclaimable_recent_location_count:
lines.append(
f"{suggestion_number}. Leave the {facts.reclaimable_recent_location_count} recently-active "
f"reclaimable location(s) alone for now; revisit them in a future sweep once they have been "
f"untouched for {facts.reclaimable_recent_activity_days}+ days."
)
suggestion_number += 1
if facts.inaccessible_count:
lines.append(
f"{suggestion_number}. Skip the {facts.inaccessible_count} inaccessible location(s) listed "
f"above, or re-run as a user/root with read access to them for a more complete picture."
)
suggestion_number += 1
if facts.boundary_skipped_mounts:
lines.append(
f"{suggestion_number}. Re-run with --cross-filesystems if the "
f"{len(facts.boundary_skipped_mounts)} skipped mount(s) should also be swept."
)
suggestion_number += 1
lines.append(
f"{suggestion_number}. Re-run this sweep periodically to track whether reclaimable categories "
"are growing faster than they are being cleaned up."
)
lines.append("")
lines.append(
"**Bottom Line**: This is a deterministic summary generated without an LLM narrative pass; "
"figures above are exact given the input data and this scan's coverage, but the prose is "
"templated rather than reasoned. Re-run with LLM generation enabled and a valid DEVPLACE_API_KEY "
"for a fuller critical narrative. No files were modified in producing this report."
)
return "\n".join(lines)
def render_rules_appendix(facts: AnalysisFacts) -> str:
lines: list[str] = []
lines.append("### Decision Rules Applied")
lines.append(
"This section is generated deterministically by code, not by the narrative model above, so every "
"finding in this report can be independently checked against it. Each finding's `reason` field in "
"the underlying data cites the specific rule that produced it."
)
lines.append("")
lines.append(
"1. **Read-only, always.** This tool never deletes, moves, renames, or modifies any file or "
"directory. Every action in this run was a read/stat/hash operation only."
)
lines.append(
"2. **True on-disk size.** Bytes are counted via `st_blocks * 512` (actual allocated blocks), not "
f"just `st_size` (apparent size); this run measured {format_bytes(facts.total_disk_bytes)} actual "
f"vs {format_bytes(facts.total_apparent_bytes)} apparent."
)
lines.append(
"3. **Hard-link deduplication.** A file with more than one hard link is counted exactly once, keyed "
f"by (device, inode), across the whole scan. This run deduplicated "
f"{facts.hardlink_files_deduplicated} file(s) / {format_bytes(facts.hardlink_bytes_deduplicated)}."
)
lines.append(
f"4. **Filesystem boundary.** Subdirectories on a different device (`st_dev`) than the scan root are "
f"not descended into unless `--cross-filesystems` is passed (this run: "
f"{'enabled' if facts.cross_filesystems else 'disabled'}). "
f"{len(facts.boundary_skipped_mounts)} mount(s) were skipped this run."
)
lines.append(
f"5. **Reclaimable-category match.** A directory is sized as a single aggregate 'reclaimable "
f"candidate' ONLY if its name (case-insensitive, exact match) is one of "
f"{len(RECLAIMABLE_DIRECTORY_NAMES)} known generated/cache/vendor directory names (e.g. "
"`node_modules`, `__pycache__`, `venv`, `build`, `.cache`, `trash`); matching a name stops further "
"descent and the entire subtree's size is attributed to that category. No content inspection or "
"heuristic guessing is used โ only the directory name."
)
lines.append(
f"6. **Recent-activity exclusion.** A reclaimable candidate is only recommended for cleanup if no "
f"file inside it was modified within the last {facts.reclaimable_recent_activity_days} day(s) "
f"(~{facts.reclaimable_recent_activity_days // 7} week(s)); this is checked against the single most "
f"recent file modification time found anywhere inside that directory. This run excluded "
f"{facts.reclaimable_recent_location_count} location(s) totaling "
f"{format_bytes(facts.reclaimable_recent_total_disk_bytes)} for being touched more recently โ see "
"`reclaimable_space.excluded_recently_active` for the list."
)
lines.append(
f"7. **Database protection (hard rule).** Any file whose extension is one of "
f"{len(DATABASE_FILE_EXTENSIONS)} known database-format extensions (SQLite, MySQL/MariaDB, "
"PostgreSQL/Access-adjacent, Firebird, Redis RDB, DBF, and their WAL/SHM/journal siblings) is "
"excluded from duplicate detection and stale-file candidacy at the code level, and is never "
f"eligible for a removal/move recommendation regardless of size, age, or redundancy. "
f"{len(facts.protected_database_files)} such file(s) were found this run."
)
lines.append(
f"8. **Duplicate confirmation.** Files are grouped by exact byte size first (only sizes "
f">= {format_bytes(facts.min_duplicate_size_bytes)} are tracked); the highest-potential-reclaim "
"same-size groups are then fully SHA-256 hashed, and only groups where >=2 files share an identical "
f"hash are reported as confirmed duplicates. This run hashed {facts.duplicate_files_hashed} file(s) "
f"and confirmed {len(facts.duplicate_groups)} duplicate group(s); "
f"{facts.duplicate_groups_skipped} lower-priority same-size group(s) were not hash-verified due to "
"this run's group limit (so more duplicates may exist beyond what is reported)."
)
lines.append(
f"9. **Stale large file.** A non-database file is flagged stale only if its size is at or above "
f"{format_bytes(facts.large_file_threshold_bytes)} AND both its last-modified and last-accessed "
f"time are older than {facts.stale_days} day(s)."
)
lines.append(
f"10. **Permission handling.** Any path this run could not read (permission denied or a transient "
"error) is listed under inaccessible locations with explicit advice to skip it; this tool never "
f"estimates or guesses at the contents of a path it could not read. This run found "
f"{facts.inaccessible_count} such location(s), run as `{facts.running_as_user}` "
f"({'root' if facts.running_as_root else 'a regular user'})."
)
return "\n".join(lines)
def render_markdown_report(facts: AnalysisFacts, narrative: str) -> str:
role_label = "root" if facts.running_as_root else "regular user"
header = (
f"# Disk Usage Sweep Report\n\n"
f"Generated: {facts.generated_at:%Y-%m-%d %H:%M:%S} \n"
f"Scan root: {facts.root_label} \n"
f"Running as: {facts.running_as_user} ({role_label}) \n"
f"Cross-filesystem scan: {'yes' if facts.cross_filesystems else 'no'} \n\n"
"---\n\n"
)
return header + narrative.strip() + "\n\n---\n\n" + render_rules_appendix(facts) + "\n"
async def generate_report(config: AnalysisConfig) -> str:
results, state = await run_scan(config.scan_config)
results.append(scan_root_level_files(config.scan_config, state))
duplicate_groups, duplicate_groups_skipped, duplicate_files_hashed = await find_confirmed_duplicates(
state, config.scan_config
)
filesystems = discover_real_filesystems()
analysis_facts = build_facts(
results=results, state=state, config=config.scan_config,
duplicate_groups=duplicate_groups, duplicate_groups_skipped=duplicate_groups_skipped,
duplicate_files_hashed=duplicate_files_hashed, filesystems=filesystems,
)
narrative: str | None = None
if config.use_llm:
payload = facts_to_prompt_payload(analysis_facts)
try:
narrative = await generate_llm_report(config.gateway_settings, payload)
except AnalysisError as error:
LOGGER.error(f"LLM narrative generation failed, falling back to deterministic report: {error}")
if narrative is None:
narrative = build_fallback_report(analysis_facts)
return render_markdown_report(analysis_facts, narrative)
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Perform a read-only, high-accuracy disk-usage sweep and generate a critical, "
"reasoning-based narrative report: largest space consumers, reclaimable caches/build "
"artifacts, confirmed duplicate files, and stale large files. Never deletes anything, "
"never recommends removing database files, and never recommends cleanup of recently-active "
"locations. Works as any user, including root; pass '/' as root for a whole-disk sweep."
),
)
parser.add_argument(
"root", nargs="?", default=Path.cwd(), type=Path,
help="root directory to sweep (default: current working directory)",
)
parser.add_argument(
"--cross-filesystems", action="store_true",
help="also descend into separately mounted filesystems and network shares under root",
)
parser.add_argument(
"--output", type=Path, default=None,
help="write the Markdown report to this path instead of stdout",
)
parser.add_argument(
"--model", default=os.environ.get("DEVPLACE_MODEL", DEFAULT_GATEWAY_MODEL),
help="DevPlace gateway model id for narrative generation (default: %(default)s)",
)
parser.add_argument(
"--api-key", default=None,
help="DevPlace gateway API key (default: DEVPLACE_API_KEY environment variable)",
)
parser.add_argument(
"--no-llm", action="store_true",
help="skip DevPlace gateway narrative generation and emit a deterministic template report",
)
parser.add_argument(
"--exclude", action="append", default=[], metavar="NAME",
help="additional directory name to always skip (repeatable, case-insensitive)",
)
parser.add_argument("--dir-timeout", type=float, default=DEFAULT_DIRECTORY_TIMEOUT_SECONDS)
parser.add_argument("--max-directories", type=int, default=DEFAULT_MAX_DIRECTORIES_PER_TOP_LEVEL)
parser.add_argument("--workers", type=int, default=DEFAULT_MAX_WORKERS)
parser.add_argument("--top-dirs", type=int, default=DEFAULT_TOP_DIRECTORIES_SHOWN)
parser.add_argument("--top-files", type=int, default=DEFAULT_TOP_FILES_SHOWN)
parser.add_argument("--top-extensions", type=int, default=DEFAULT_TOP_EXTENSIONS_SHOWN)
parser.add_argument("--large-file-threshold-mb", type=float, default=DEFAULT_LARGE_FILE_THRESHOLD_MB)
parser.add_argument("--stale-days", type=int, default=DEFAULT_STALE_DAYS)
parser.add_argument(
"--reclaimable-recent-days", type=int, default=DEFAULT_RECLAIMABLE_RECENT_ACTIVITY_DAYS,
help=(
"reclaimable-category locations modified within this many days are treated as actively used "
"and excluded from cleanup recommendations (default: %(default)s, i.e. 14 weeks)"
),
)
parser.add_argument("--min-duplicate-size-mb", type=float, default=DEFAULT_MIN_DUPLICATE_SIZE_MB)
parser.add_argument("--max-duplicate-groups", type=int, default=DEFAULT_MAX_DUPLICATE_GROUPS_HASHED)
parser.add_argument("--temperature", type=float, default=DEFAULT_TEMPERATURE)
parser.add_argument("--max-output-tokens", type=int, default=DEFAULT_MAX_OUTPUT_TOKENS)
parser.add_argument("--request-timeout", type=float, default=DEFAULT_REQUEST_TIMEOUT_SECONDS)
parser.add_argument("--verbose", action="store_true")
return parser
def validate_args(args: argparse.Namespace) -> str | None:
if not Path(args.root).exists():
return f"root does not exist: {args.root}"
if args.dir_timeout <= 0:
return "--dir-timeout must be greater than 0"
if args.max_directories < 1:
return "--max-directories must be at least 1"
if args.workers < 1:
return "--workers must be at least 1"
if args.top_dirs < 1:
return "--top-dirs must be at least 1"
if args.top_files < 1:
return "--top-files must be at least 1"
if args.top_extensions < 1:
return "--top-extensions must be at least 1"
if args.large_file_threshold_mb <= 0:
return "--large-file-threshold-mb must be greater than 0"
if args.stale_days < 0:
return "--stale-days cannot be negative"
if args.reclaimable_recent_days < 0:
return "--reclaimable-recent-days cannot be negative"
if args.min_duplicate_size_mb <= 0:
return "--min-duplicate-size-mb must be greater than 0"
if args.max_duplicate_groups < 0:
return "--max-duplicate-groups cannot be negative"
if args.request_timeout <= 0:
return "--request-timeout must be greater than 0"
if args.max_output_tokens < 1:
return "--max-output-tokens must be at least 1"
return None
class ElapsedFormatter(logging.Formatter):
def __init__(self, start_time: float) -> None:
super().__init__()
self.start_time = start_time
def format(self, record: logging.LogRecord) -> str:
elapsed = time.monotonic() - self.start_time
return f"[{elapsed:7.3f}s] {record.levelname:<7} {record.getMessage()}"
def configure_logging(verbose: bool) -> None:
start_time = time.monotonic()
LOGGER.setLevel(logging.DEBUG if verbose else logging.INFO)
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(ElapsedFormatter(start_time))
LOGGER.handlers.clear()
LOGGER.addHandler(handler)
LOGGER.propagate = False
def write_report(report: str, output_path: Path | None) -> None:
if output_path is None:
print(report)
return
try:
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(report, encoding="utf-8")
except OSError as error:
raise AnalysisError(f"could not write report to {output_path}: {error!r}") from error
LOGGER.info(f"report written to {output_path}")
def main() -> int:
args = build_arg_parser().parse_args()
configure_logging(args.verbose)
validation_error = validate_args(args)
if validation_error is not None:
print(f"error: {validation_error}", file=sys.stderr)
return 2
load_dotenv_if_present(Path.cwd() / ".env", Path(__file__).resolve().parent / ".env")
api_key = args.api_key or os.environ.get("DEVPLACE_API_KEY")
scan_config = ScanConfig(
root=Path(args.root).resolve(),
cross_filesystems=args.cross_filesystems,
directory_timeout_seconds=args.dir_timeout,
max_directories_per_top_level=args.max_directories,
max_workers=args.workers,
min_duplicate_size_bytes=int(args.min_duplicate_size_mb * 1024 * 1024),
max_duplicate_groups_hashed=args.max_duplicate_groups,
large_file_threshold_bytes=int(args.large_file_threshold_mb * 1024 * 1024),
stale_days=args.stale_days,
top_directories_shown=args.top_dirs,
top_files_shown=args.top_files,
top_extensions_shown=args.top_extensions,
reclaimable_recent_activity_days=args.reclaimable_recent_days,
extra_excluded_names=frozenset(name.lower() for name in args.exclude),
verbose=args.verbose,
)
gateway_settings = GatewaySettings(
api_key=api_key,
model=args.model,
endpoint=DEFAULT_GATEWAY_ENDPOINT,
temperature=args.temperature,
max_output_tokens=args.max_output_tokens,
request_timeout_seconds=args.request_timeout,
)
config = AnalysisConfig(
scan_config=scan_config,
gateway_settings=gateway_settings,
output_path=args.output.resolve() if args.output else None,
use_llm=not args.no_llm and bool(api_key),
verbose=args.verbose,
)
if args.no_llm:
LOGGER.info("LLM narrative generation disabled via --no-llm")
elif not api_key:
LOGGER.warning("no DevPlace gateway API key available, falling back to deterministic report")
log_scan_criteria(scan_config)
try:
report = asyncio.run(generate_report(config))
except AnalysisError as error:
LOGGER.error(f"analysis failed: {error}")
return 1
except KeyboardInterrupt:
LOGGER.warning("interrupted by user")
return 130
except Exception as error:
LOGGER.error(f"unexpected top-level error, aborting cleanly: {error!r}")
return 1
try:
write_report(report, config.output_path)
except AnalysisError as error:
LOGGER.error(str(error))
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
Comments
No comments yet. Start the discussion.