butler/tools/_file_cache.py
Rama main · réplica de lectura del repositorio original.
"""Shared per-(path, mtime) JSON file cache for tools.
Why:
Tools like ``expense``/``contacts``/``habits`` repeatedly call ``_load_all()``
within a single agent turn (e.g. ``summary`` + ``list`` + ``breakdown``).
Each call does ``glob("*.json")`` + ``read_text`` + ``json.loads`` on every
file. With N records and K calls per turn that's K×N disk reads.
How:
Cache parsed JSON by ``(path, mtime_ns)``.
- Hit on identical mtime → skip read+parse.
- Mtime change → natural invalidation (handles cross-process writes).
- LRU eviction by ``OrderedDict`` (default cap 256).
- Errors (missing/invalid JSON) return ``None`` and are NOT cached
(so a fix-and-retry next call still works).
"""
from __future__ import annotations
import json
import threading
from collections import OrderedDict
from pathlib import Path
from typing import Any
_FILE_CACHE_MAX = 256
# key: (str(path), mtime_ns) → value: parsed json (dict | list)
_FILE_CACHE: "OrderedDict[tuple[str, int], Any]" = OrderedDict()
_CACHE_LOCK = threading.Lock()
def read_json_cached(path: Path) -> Any | None:
"""Read + parse a JSON file with mtime-keyed LRU cache.
Returns parsed JSON on success, ``None`` if the file is missing,
inaccessible, or contains invalid JSON. Errors are not cached.
"""
try:
st = path.stat()
except OSError:
return None
key = (str(path), st.st_mtime_ns)
with _CACHE_LOCK:
cached = _FILE_CACHE.get(key)
if cached is not None:
_FILE_CACHE.move_to_end(key)
return cached
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
with _CACHE_LOCK:
_FILE_CACHE[key] = data
_FILE_CACHE.move_to_end(key)
while len(_FILE_CACHE) > _FILE_CACHE_MAX:
_FILE_CACHE.popitem(last=False)
return data
def clear_cache() -> None:
"""Drop all cached entries. Mainly for tests."""
with _CACHE_LOCK:
_FILE_CACHE.clear()