headroom-pc 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. headroom/__init__.py +3 -0
  2. headroom/__main__.py +8 -0
  3. headroom/alerts.py +125 -0
  4. headroom/analyze.py +555 -0
  5. headroom/autostart.py +312 -0
  6. headroom/cleanup.py +773 -0
  7. headroom/cli.py +1250 -0
  8. headroom/config.py +512 -0
  9. headroom/dashboard.py +628 -0
  10. headroom/downloads.py +227 -0
  11. headroom/dupes.py +196 -0
  12. headroom/explain.py +196 -0
  13. headroom/firstrun.py +98 -0
  14. headroom/formatutil.py +31 -0
  15. headroom/history.py +605 -0
  16. headroom/instance.py +115 -0
  17. headroom/logsetup.py +47 -0
  18. headroom/mcp_prompts.py +23 -0
  19. headroom/mcp_server.py +38 -0
  20. headroom/mcp_tools.py +175 -0
  21. headroom/notify.py +129 -0
  22. headroom/pet.py +1334 -0
  23. headroom/pets/__init__.py +1 -0
  24. headroom/pets/draw.py +544 -0
  25. headroom/pets/midnight.png +0 -0
  26. headroom/pets/roomy.png +0 -0
  27. headroom/pets/tower.png +0 -0
  28. headroom/plans.py +108 -0
  29. headroom/plugins.py +152 -0
  30. headroom/probe.py +197 -0
  31. headroom/processes.py +77 -0
  32. headroom/profiles/__init__.py +1 -0
  33. headroom/profiles/developer.rules +64 -0
  34. headroom/profiles/gamer.rules +36 -0
  35. headroom/profiles/student.rules +34 -0
  36. headroom/py.typed +0 -0
  37. headroom/render.py +457 -0
  38. headroom/review.py +353 -0
  39. headroom/rules.py +424 -0
  40. headroom/safety.py +211 -0
  41. headroom/scan.py +82 -0
  42. headroom/schedule.py +104 -0
  43. headroom/selfcheck.py +78 -0
  44. headroom/service.py +120 -0
  45. headroom/startup.py +525 -0
  46. headroom/tracker.py +317 -0
  47. headroom/walk.py +230 -0
  48. headroom/watch.py +324 -0
  49. headroom/weekly.py +154 -0
  50. headroom_pc-0.1.0.dist-info/METADATA +193 -0
  51. headroom_pc-0.1.0.dist-info/RECORD +55 -0
  52. headroom_pc-0.1.0.dist-info/WHEEL +5 -0
  53. headroom_pc-0.1.0.dist-info/entry_points.txt +3 -0
  54. headroom_pc-0.1.0.dist-info/licenses/LICENSE +21 -0
  55. headroom_pc-0.1.0.dist-info/top_level.txt +1 -0
headroom/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Headroom watches RAM and disk, reviews files, and cleans only known-safe caches."""
2
+
3
+ __version__ = "0.1.0"
headroom/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from headroom.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
headroom/alerts.py ADDED
@@ -0,0 +1,125 @@
1
+ """Thresholds for sustained RAM pressure and low free space.
2
+
3
+ RAM must stay above the configured percent for the full sustained window.
4
+ A single spike does not notify. Disk alerts fire when a watched drive crosses
5
+ into a worse state, and they do not repeat while it stays there.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from headroom.config import Config
13
+ from headroom.formatutil import format_bytes, format_duration, format_percent
14
+ from headroom.probe import Sample
15
+
16
+ _RANK = {"ok": 0, "warn": 1, "critical": 2}
17
+
18
+
19
+ @dataclass
20
+ class AlertState:
21
+ ram_high_since: float | None = None
22
+ ram_alerted: bool = False
23
+ disk_level: dict[str, str] = field(default_factory=dict)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Alert:
28
+ level: str
29
+ code: str
30
+ title: str
31
+ message: str
32
+
33
+
34
+ def disk_pressure(free_bytes: int, config: Config) -> str:
35
+ """Return ``ok``, ``warn``, or ``critical``. Equal to a line is still ok."""
36
+ if free_bytes < config.disk_critical_free_bytes:
37
+ return "critical"
38
+ if free_bytes < config.disk_warn_free_bytes:
39
+ return "warn"
40
+ return "ok"
41
+
42
+
43
+ def evaluate(sample: Sample, state: AlertState, config: Config, now: float) -> list[Alert]:
44
+ """Update ``state`` and return alerts that should be delivered now."""
45
+ alerts: list[Alert] = []
46
+ alerts.extend(_ram_alerts(sample, state, config, now))
47
+ alerts.extend(_disk_alerts(sample, state, config))
48
+ return alerts
49
+
50
+
51
+ def _ram_alerts(
52
+ sample: Sample,
53
+ state: AlertState,
54
+ config: Config,
55
+ now: float,
56
+ ) -> list[Alert]:
57
+ percent = sample.memory.ram_percent
58
+ if percent > config.ram_percent:
59
+ if state.ram_high_since is None:
60
+ state.ram_high_since = now
61
+ elapsed = now - state.ram_high_since
62
+ if elapsed >= config.ram_sustained_seconds and not state.ram_alerted:
63
+ state.ram_alerted = True
64
+ window = format_duration(config.ram_sustained_seconds)
65
+ return [
66
+ Alert(
67
+ level="warning",
68
+ code="ram_sustained",
69
+ title="Headroom: RAM is high",
70
+ message=(
71
+ f"RAM has stayed above {format_percent(config.ram_percent)} "
72
+ f"for {window} (now {format_percent(percent)}). "
73
+ "Run `headroom top` to see which processes are using it."
74
+ ),
75
+ )
76
+ ]
77
+ return []
78
+ state.ram_high_since = None
79
+ state.ram_alerted = False
80
+ return []
81
+
82
+
83
+ def _disk_alerts(sample: Sample, state: AlertState, config: Config) -> list[Alert]:
84
+ alerts: list[Alert] = []
85
+ seen: set[str] = set()
86
+ watched = set(config.drives)
87
+ for disk in sample.disks:
88
+ if disk.key not in watched or disk.key in seen:
89
+ continue
90
+ seen.add(disk.key)
91
+ level = disk_pressure(disk.free, config)
92
+ previous = state.disk_level.get(disk.key, "ok")
93
+ state.disk_level[disk.key] = level
94
+ if _RANK[level] <= _RANK[previous]:
95
+ continue
96
+ if level == "critical":
97
+ alerts.append(
98
+ Alert(
99
+ level="critical",
100
+ code="disk_critical",
101
+ title=f"Headroom: {disk.key} is critically low",
102
+ message=(
103
+ f"{disk.key} has {format_bytes(disk.free)} free, under the "
104
+ f"{format_bytes(config.disk_critical_free_bytes)} critical line. "
105
+ "Run `headroom clean --dry-run` to preview safe cleanup."
106
+ ),
107
+ )
108
+ )
109
+ elif level == "warn":
110
+ alerts.append(
111
+ Alert(
112
+ level="warning",
113
+ code="disk_low",
114
+ title=f"Headroom: {disk.key} is low on space",
115
+ message=(
116
+ f"{disk.key} has {format_bytes(disk.free)} free, under the "
117
+ f"{format_bytes(config.disk_warn_free_bytes)} warning line."
118
+ ),
119
+ )
120
+ )
121
+ for key in list(state.disk_level):
122
+ if key not in seen and key in watched:
123
+ # The drive disappeared. Forget it so a later return can alert again.
124
+ del state.disk_level[key]
125
+ return alerts
headroom/analyze.py ADDED
@@ -0,0 +1,555 @@
1
+ """Read-only categorization of files the user might care about.
2
+
3
+ The scan never deletes, moves, or writes inside the folders it reads. It stores
4
+ its report under Headroom's own state directory.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+ from pathlib import Path, PurePosixPath
15
+
16
+ from headroom.formatutil import format_bytes
17
+ from headroom.rules import CATEGORIES, state_dir
18
+ from headroom.walk import classify_path, list_child_names, measure_tree
19
+
20
+ STALE_DAYS = 180
21
+ RECENT_DAYS = 14
22
+ MAX_RECORDS = 8000
23
+ _HASH_LIMIT = 8 * 1024 * 1024
24
+
25
+ CATEGORY_LABELS = {
26
+ "school": "School and documents",
27
+ "code": "Code projects",
28
+ "games": "Games",
29
+ "media": "Media",
30
+ "downloads": "Downloads",
31
+ "installers": "Installers",
32
+ "duplicates": "Duplicates",
33
+ "caches": "Caches",
34
+ "old": "Old or unopened",
35
+ }
36
+
37
+ _IMPORTANCE = {
38
+ "school": 80,
39
+ "code": 85,
40
+ "games": 75,
41
+ "media": 55,
42
+ "downloads": 35,
43
+ "installers": 20,
44
+ "duplicates": 15,
45
+ "caches": 10,
46
+ "old": 25,
47
+ }
48
+ _CACHE_DIRS = {
49
+ "node_modules",
50
+ "__pycache__",
51
+ ".next",
52
+ "bower_components",
53
+ "shadercache",
54
+ "gpucache",
55
+ "dxcache",
56
+ "appcache",
57
+ "htmlcache",
58
+ }
59
+ _GAME_PARTS = {
60
+ "steamapps",
61
+ "epic games",
62
+ "gog games",
63
+ "gog galaxy",
64
+ "riot games",
65
+ "battlenet",
66
+ }
67
+ _PROJECT_FILES = {
68
+ ".git",
69
+ "package.json",
70
+ "pyproject.toml",
71
+ "cargo.toml",
72
+ "go.mod",
73
+ "pom.xml",
74
+ "composer.json",
75
+ "setup.py",
76
+ }
77
+ _CODE_EXT = {
78
+ ".py", ".pyi", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".rs", ".go",
79
+ ".java", ".c", ".h", ".hpp", ".cpp", ".cs", ".rb", ".php", ".swift", ".kt",
80
+ ".scala",
81
+ }
82
+ _MEDIA_EXT = {
83
+ ".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".mp4", ".mov", ".mkv",
84
+ ".avi", ".mp3", ".wav", ".flac", ".m4a",
85
+ }
86
+ _DOC_EXT = {
87
+ ".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".txt", ".rtf",
88
+ ".odt", ".odp", ".ods",
89
+ }
90
+ _INSTALLER_EXT = {".msi", ".iso", ".dmg", ".pkg"}
91
+ _SCHOOL_HINTS = (
92
+ "syllabus",
93
+ "homework",
94
+ "assignment",
95
+ "lecture",
96
+ "essay",
97
+ "course",
98
+ "semester",
99
+ )
100
+ _FOLDER_NAMES = (
101
+ "Documents",
102
+ "Desktop",
103
+ "Downloads",
104
+ "Pictures",
105
+ "Videos",
106
+ "Music",
107
+ "Saved Games",
108
+ "Games",
109
+ "source",
110
+ "projects",
111
+ "dev",
112
+ "code",
113
+ )
114
+
115
+
116
+ @dataclass
117
+ class FileRecord:
118
+ path: str
119
+ size: int
120
+ accessed: float
121
+ category: str
122
+ importance: int
123
+ is_dir: bool = False
124
+ stale: bool = False
125
+ duplicate_of: str | None = None
126
+ base_category: str = ""
127
+
128
+ def to_json(self) -> dict[str, object]:
129
+ return {
130
+ "path": self.path,
131
+ "size": self.size,
132
+ "accessed": self.accessed,
133
+ "category": self.category,
134
+ "importance": self.importance,
135
+ "is_dir": self.is_dir,
136
+ "stale": self.stale,
137
+ "duplicate_of": self.duplicate_of,
138
+ "base_category": self.base_category or self.category,
139
+ }
140
+
141
+ @classmethod
142
+ def from_json(cls, data: dict[str, object]) -> FileRecord:
143
+ base = str(data.get("base_category") or data["category"])
144
+ return cls(
145
+ path=str(data["path"]),
146
+ size=int(data["size"]),
147
+ accessed=float(data["accessed"]),
148
+ category=str(data["category"]),
149
+ importance=int(data["importance"]),
150
+ is_dir=bool(data.get("is_dir", False)),
151
+ stale=bool(data.get("stale", False)),
152
+ duplicate_of=None if data.get("duplicate_of") is None else str(data["duplicate_of"]),
153
+ base_category=base,
154
+ )
155
+
156
+
157
+ @dataclass
158
+ class CategoryTotal:
159
+ count: int = 0
160
+ bytes: int = 0
161
+
162
+
163
+ @dataclass
164
+ class Analysis:
165
+ scanned_at: float
166
+ roots: tuple[str, ...]
167
+ totals: dict[str, CategoryTotal] = field(default_factory=dict)
168
+ records: tuple[FileRecord, ...] = ()
169
+ errors: int = 0
170
+ skipped_links: int = 0
171
+ capped: bool = False
172
+ stale_count: int = 0
173
+ stale_bytes: int = 0
174
+
175
+ def to_json(self) -> dict[str, object]:
176
+ return {
177
+ "scanned_at": self.scanned_at,
178
+ "roots": list(self.roots),
179
+ "totals": {
180
+ key: {"count": total.count, "bytes": total.bytes}
181
+ for key, total in self.totals.items()
182
+ },
183
+ "records": [record.to_json() for record in self.records],
184
+ "errors": self.errors,
185
+ "skipped_links": self.skipped_links,
186
+ "capped": self.capped,
187
+ "stale_count": self.stale_count,
188
+ "stale_bytes": self.stale_bytes,
189
+ }
190
+
191
+ @classmethod
192
+ def from_json(cls, data: dict[str, object]) -> Analysis:
193
+ totals: dict[str, CategoryTotal] = {}
194
+ raw_totals = data.get("totals") or {}
195
+ if isinstance(raw_totals, dict):
196
+ for key, value in raw_totals.items():
197
+ if isinstance(value, dict):
198
+ totals[str(key)] = CategoryTotal(
199
+ count=int(value.get("count", 0)),
200
+ bytes=int(value.get("bytes", 0)),
201
+ )
202
+ raw_records = data.get("records") or []
203
+ records = tuple(
204
+ FileRecord.from_json(item)
205
+ for item in raw_records
206
+ if isinstance(item, dict)
207
+ )
208
+ roots = data.get("roots") or []
209
+ return cls(
210
+ scanned_at=float(data.get("scanned_at", 0)),
211
+ roots=tuple(str(item) for item in roots) if isinstance(roots, list) else (),
212
+ totals=totals,
213
+ records=records,
214
+ errors=int(data.get("errors", 0)),
215
+ skipped_links=int(data.get("skipped_links", 0)),
216
+ capped=bool(data.get("capped", False)),
217
+ stale_count=int(data.get("stale_count", 0)),
218
+ stale_bytes=int(data.get("stale_bytes", 0)),
219
+ )
220
+
221
+
222
+ def importance(category: str, accessed: float, now: float) -> int:
223
+ """0 to 100. Recent files score higher. Old files score lower."""
224
+ score = _IMPORTANCE.get(category, _IMPORTANCE["old"])
225
+ age_days = (now - accessed) / 86400
226
+ if age_days <= RECENT_DAYS:
227
+ score += 10
228
+ elif age_days >= STALE_DAYS:
229
+ score -= 15
230
+ return max(0, min(100, score))
231
+
232
+
233
+ def classify_entry(path: str, *, is_dir: bool, inside_project: bool) -> str:
234
+ """Pick one category id from the path. The caller marks duplicates later."""
235
+ posix = path.replace("\\", "/")
236
+ parts = [part.casefold() for part in PurePosixPath(posix).parts]
237
+ name = parts[-1] if parts else ""
238
+ ext = "" if is_dir else PurePosixPath(name).suffix.casefold()
239
+ joined = "/".join(parts)
240
+ if any(part in _CACHE_DIRS for part in parts):
241
+ return "caches"
242
+ if "saved games" in joined or any(part in _GAME_PARTS for part in parts):
243
+ return "games"
244
+ if ext in _INSTALLER_EXT or (
245
+ ext == ".exe" and any(token in name for token in ("setup", "install"))
246
+ ):
247
+ return "installers"
248
+ if inside_project or ext in _CODE_EXT:
249
+ return "code"
250
+ if ext in _MEDIA_EXT:
251
+ return "media"
252
+ if any(hint in joined for hint in _SCHOOL_HINTS):
253
+ return "school"
254
+ if "downloads" in parts:
255
+ return "downloads"
256
+ if ext in _DOC_EXT or any(part in {"documents", "desktop"} for part in parts):
257
+ return "school"
258
+ return "old"
259
+
260
+
261
+ def default_roots(home: Path | None = None, env: dict[str, str] | None = None) -> list[Path]:
262
+ """User folders that usually hold personal files, plus a few known caches."""
263
+ home_dir = Path.home() if home is None else home
264
+ environ = os.environ if env is None else env
265
+ roots = [home_dir / name for name in _FOLDER_NAMES if (home_dir / name).is_dir()]
266
+ local = environ.get("LOCALAPPDATA")
267
+ if local:
268
+ base = Path(local)
269
+ for relative in (
270
+ "NVIDIA/DXCache",
271
+ "D3DSCache",
272
+ "Steam/appcache",
273
+ "Steam/htmlcache",
274
+ "NVIDIA Corporation/NV_Cache",
275
+ ):
276
+ candidate = base / relative
277
+ if candidate.is_dir():
278
+ roots.append(candidate)
279
+ dot_cache = home_dir / ".cache"
280
+ if dot_cache.is_dir():
281
+ roots.append(dot_cache)
282
+ if not roots:
283
+ roots.append(home_dir)
284
+ return roots
285
+
286
+
287
+ def analysis_path(directory: Path | None = None) -> Path:
288
+ return (state_dir() if directory is None else directory) / "analysis.json"
289
+
290
+
291
+ def save_analysis(analysis: Analysis, directory: Path | None = None) -> Path:
292
+ path = analysis_path(directory)
293
+ path.parent.mkdir(parents=True, exist_ok=True)
294
+ path.write_text(json.dumps(analysis.to_json()), encoding="utf-8")
295
+ return path
296
+
297
+
298
+ def load_analysis(directory: Path | None = None) -> Analysis | None:
299
+ path = analysis_path(directory)
300
+ if not path.is_file():
301
+ return None
302
+ data = json.loads(path.read_text(encoding="utf-8"))
303
+ if not isinstance(data, dict):
304
+ return None
305
+ return Analysis.from_json(data)
306
+
307
+
308
+ def scan(
309
+ roots: list[Path],
310
+ *,
311
+ now: float,
312
+ skip_under: Path | None = None,
313
+ max_records: int = MAX_RECORDS,
314
+ ) -> Analysis:
315
+ """Walk ``roots`` without following links. The tree is not modified."""
316
+ totals = {key: CategoryTotal() for key in CATEGORIES}
317
+ seen: list[FileRecord] = []
318
+ errors = 0
319
+ skipped_links = 0
320
+ stale_count = 0
321
+ stale_bytes = 0
322
+ skip = skip_under
323
+ for root in roots:
324
+ kind = classify_path(root)
325
+ if kind == "link":
326
+ skipped_links += 1
327
+ continue
328
+ if kind == "file":
329
+ _note_file(root, inside_project=False, now=now, totals=totals, seen=seen)
330
+ continue
331
+ if kind != "dir":
332
+ errors += 1
333
+ continue
334
+ stack: list[tuple[Path, bool]] = [(root, False)]
335
+ while stack:
336
+ current, inherited = stack.pop()
337
+ if skip is not None and _is_under(current, skip):
338
+ continue
339
+ try:
340
+ names = list_child_names(current)
341
+ except OSError:
342
+ errors += 1
343
+ continue
344
+ if names is None:
345
+ skipped_links += 1
346
+ continue
347
+ project = inherited or any(_project_marker(name) for name in names)
348
+ for name in names:
349
+ child = current / name
350
+ if skip is not None and _is_under(child, skip):
351
+ continue
352
+ child_kind = classify_path(child)
353
+ if child_kind == "link":
354
+ skipped_links += 1
355
+ continue
356
+ if child_kind == "dir" and name.casefold() in _CACHE_DIRS:
357
+ added = _note_bundle(child, now=now, totals=totals, seen=seen)
358
+ if not added:
359
+ errors += 1
360
+ continue
361
+ if child_kind == "dir":
362
+ stack.append((child, project))
363
+ continue
364
+ if child_kind == "file":
365
+ if not _note_file(child, inside_project=project, now=now, totals=totals, seen=seen):
366
+ errors += 1
367
+ continue
368
+ if child_kind == "error":
369
+ errors += 1
370
+ for record in seen:
371
+ if record.stale:
372
+ stale_count += 1
373
+ stale_bytes += record.size
374
+ capped = len(seen) > max_records
375
+ if capped:
376
+ seen.sort(key=lambda item: (-item.size, item.path))
377
+ seen = seen[:max_records]
378
+ _mark_duplicates(seen, now)
379
+ seen.sort(key=lambda item: (-item.size, item.path))
380
+ return Analysis(
381
+ scanned_at=now,
382
+ roots=tuple(str(root) for root in roots),
383
+ totals=totals,
384
+ records=tuple(seen),
385
+ errors=errors,
386
+ skipped_links=skipped_links,
387
+ capped=capped,
388
+ stale_count=stale_count,
389
+ stale_bytes=stale_bytes,
390
+ )
391
+
392
+
393
+ def format_analysis(analysis: Analysis) -> str:
394
+ when = datetime.fromtimestamp(analysis.scanned_at).strftime("%Y-%m-%d %H:%M:%S")
395
+ lines = [
396
+ f"Headroom analysis {when}",
397
+ "Read-only. Nothing was moved or deleted.",
398
+ "",
399
+ f"{'CATEGORY':<24} {'FILES':>8} {'SIZE':>12}",
400
+ ]
401
+ for key in CATEGORIES:
402
+ total = analysis.totals.get(key) or CategoryTotal()
403
+ if total.count == 0:
404
+ continue
405
+ lines.append(
406
+ f"{CATEGORY_LABELS[key]:<24} {total.count:8d} {format_bytes(total.bytes):>12}"
407
+ )
408
+ if not any(total.count for total in analysis.totals.values()):
409
+ lines.append("No files were read.")
410
+ lines.append("")
411
+ lines.append(
412
+ f"Old or unopened (last opened about {STALE_DAYS} days ago or more): "
413
+ f"{analysis.stale_count} files, {format_bytes(analysis.stale_bytes)}."
414
+ )
415
+ if analysis.skipped_links:
416
+ lines.append(f"Skipped links: {analysis.skipped_links}. Their targets were not read.")
417
+ if analysis.errors:
418
+ lines.append(f"Unreadable entries: {analysis.errors}.")
419
+ if analysis.capped:
420
+ lines.append(
421
+ f"The review list keeps the largest {len(analysis.records)} items. Totals include the rest."
422
+ )
423
+ notable = [record for record in analysis.records if record.duplicate_of or record.importance >= 70]
424
+ if notable:
425
+ lines.append("")
426
+ lines.append("Higher-importance items and duplicate copies:")
427
+ for record in notable[:12]:
428
+ extra = ""
429
+ if record.duplicate_of:
430
+ extra = f" duplicate of {record.duplicate_of}"
431
+ lines.append(
432
+ f" {format_bytes(record.size):>10} {record.importance:3d} "
433
+ f"{CATEGORY_LABELS.get(record.category, record.category):<22} {record.path}{extra}"
434
+ )
435
+ lines.append("")
436
+ lines.append("Suggestions stay suggestions until you review them.")
437
+ return "\n".join(lines) + "\n"
438
+
439
+
440
+ def _note_file(path: Path, *, inside_project: bool, now: float, totals: dict, seen: list) -> bool:
441
+ try:
442
+ stat_result = path.lstat()
443
+ size = int(stat_result.st_size)
444
+ accessed = float(stat_result.st_atime or stat_result.st_mtime or now)
445
+ except OSError:
446
+ return False
447
+ _append(str(path), size, accessed, str(path), inside_project, False, now, totals, seen)
448
+ return True
449
+
450
+
451
+ def _note_bundle(path: Path, *, now: float, totals: dict, seen: list) -> bool:
452
+ measured = measure_tree(path)
453
+ try:
454
+ stat_result = path.lstat()
455
+ accessed = float(stat_result.st_atime or stat_result.st_mtime or now)
456
+ except OSError:
457
+ accessed = now
458
+ _append(str(path), measured.bytes, accessed, str(path), False, True, now, totals, seen)
459
+ return True
460
+
461
+
462
+ def _append(
463
+ text: str,
464
+ size: int,
465
+ accessed: float,
466
+ display: str,
467
+ inside_project: bool,
468
+ is_dir: bool,
469
+ now: float,
470
+ totals: dict[str, CategoryTotal],
471
+ seen: list[FileRecord],
472
+ ) -> None:
473
+ category = classify_entry(display, is_dir=is_dir, inside_project=inside_project)
474
+ age_days = (now - accessed) / 86400
475
+ stale = age_days >= STALE_DAYS
476
+ record = FileRecord(
477
+ path=display,
478
+ size=max(0, size),
479
+ accessed=accessed,
480
+ category=category,
481
+ importance=importance(category, accessed, now),
482
+ is_dir=is_dir,
483
+ stale=stale,
484
+ base_category=category,
485
+ )
486
+ bucket = totals.setdefault(category, CategoryTotal())
487
+ bucket.count += 1
488
+ bucket.bytes += record.size
489
+ seen.append(record)
490
+
491
+
492
+ def _mark_duplicates(records: list[FileRecord], now: float) -> None:
493
+ by_size: dict[int, list[FileRecord]] = {}
494
+ for record in records:
495
+ if record.is_dir or record.size <= 0:
496
+ continue
497
+ by_size.setdefault(record.size, []).append(record)
498
+ for group in by_size.values():
499
+ if len(group) < 2:
500
+ continue
501
+ group.sort(key=lambda item: item.path)
502
+ hashed: dict[str, str] = {}
503
+ for record in group:
504
+ digest = _digest(Path(record.path))
505
+ if digest is None:
506
+ continue
507
+ original = hashed.get(digest)
508
+ if original is None:
509
+ hashed[digest] = record.path
510
+ continue
511
+ record.duplicate_of = original
512
+ record.base_category = record.base_category or record.category
513
+ record.category = "duplicates"
514
+ record.importance = importance("duplicates", record.accessed, now)
515
+
516
+
517
+ def _digest(path: Path) -> str | None:
518
+ flags = os.O_RDONLY
519
+ nofollow = getattr(os, "O_NOFOLLOW", 0)
520
+ noatime = getattr(os, "O_NOATIME", 0)
521
+ if nofollow:
522
+ flags |= nofollow
523
+ fd = None
524
+ try:
525
+ try:
526
+ fd = os.open(path, flags | noatime) if noatime else os.open(path, flags)
527
+ except OSError:
528
+ fd = os.open(path, flags)
529
+ hasher = hashlib.sha256()
530
+ remaining = _HASH_LIMIT
531
+ while remaining > 0:
532
+ chunk = os.read(fd, min(65536, remaining))
533
+ if not chunk:
534
+ break
535
+ hasher.update(chunk)
536
+ remaining -= len(chunk)
537
+ return hasher.hexdigest()
538
+ except OSError:
539
+ return None
540
+ finally:
541
+ if fd is not None:
542
+ os.close(fd)
543
+
544
+
545
+ def _project_marker(name: str) -> bool:
546
+ folded = name.casefold()
547
+ return folded in _PROJECT_FILES or folded.endswith(".sln")
548
+
549
+
550
+ def _is_under(path: Path, parent: Path) -> bool:
551
+ try:
552
+ path.relative_to(parent)
553
+ except ValueError:
554
+ return False
555
+ return True