Spaces:
Build error
Build error
Download headroom/memory/budget.py from minhtudragon/headroom_3: direct link, hf CLI and curl.
- Browser
- Download file 9.67 kB
-
https://huggingface.co/spaces/minhtudragon/headroom_3/resolve/823b8dc3bb2c57bc3f7b6cabcbacaa82ef77d077/headroom/memory/budget.py
- Command line
-
hf download hf://spaces/minhtudragon/headroom_3@823b8dc3bb2c57bc3f7b6cabcbacaa82ef77d077/headroom/memory/budget.py
-
curl -L -o budget.py https://huggingface.co/spaces/minhtudragon/headroom_3/resolve/823b8dc3bb2c57bc3f7b6cabcbacaa82ef77d077/headroom/memory/budget.py
9.67 kB
| """Memory file budget manager — token-optimized memory file maintenance. | |
| Manages the token budget for agent memory files: | |
| - Ranks memories by importance × recency × access frequency | |
| - Prunes memories that fall below budget threshold | |
| - Detects stale memories referencing deleted/renamed entities | |
| - Merges similar memories to save tokens | |
| This is Headroom's superpower applied to itself — the proxy that optimizes | |
| LLM context also optimizes its own memory files for minimum token consumption. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import re | |
| import subprocess | |
| import time | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from headroom.memory.writers.base import MemoryEntry, _estimate_tokens | |
| logger = logging.getLogger(__name__) | |
| class BudgetConfig: | |
| """Configuration for memory budget management.""" | |
| # Token budgets per agent type | |
| agent_budgets: dict[str, int] = field( | |
| default_factory=lambda: { | |
| "claude": 2000, # Claude Code: 200-line MEMORY.md limit | |
| "cursor": 3000, # Cursor: .mdc rules loaded on match | |
| "codex": 3000, # Codex: AGENTS.md merged | |
| "aider": 2000, # Aider: read files | |
| "gemini": 3000, # Gemini: GEMINI.md | |
| "generic": 3000, # Default | |
| } | |
| ) | |
| # Staleness detection | |
| staleness_decay_rate: float = 0.1 # Importance decays ~10% per day | |
| staleness_min_importance: float = 0.15 # Below this = prunable | |
| staleness_check_git: bool = True # Check git for deleted files | |
| # Deduplication | |
| similarity_merge_threshold: float = 0.85 # Cosine sim above = merge | |
| class BudgetReport: | |
| """Report from a budget optimization pass.""" | |
| total_memories: int = 0 | |
| kept: int = 0 | |
| pruned_staleness: int = 0 | |
| pruned_budget: int = 0 | |
| merged: int = 0 | |
| tokens_before: int = 0 | |
| tokens_after: int = 0 | |
| def tokens_saved(self) -> int: | |
| return self.tokens_before - self.tokens_after | |
| class MemoryBudgetManager: | |
| """Manages token budgets for agent memory files. | |
| Responsible for deciding which memories to keep, prune, or merge | |
| to stay within token budgets while maximizing information density. | |
| """ | |
| def __init__( | |
| self, | |
| project_path: Path | None = None, | |
| config: BudgetConfig | None = None, | |
| ) -> None: | |
| self._project_path = project_path or Path.cwd() | |
| self._config = config or BudgetConfig() | |
| self._git_files_cache: set[str] | None = None | |
| def optimize( | |
| self, | |
| memories: list[MemoryEntry], | |
| agent_type: str = "generic", | |
| ) -> tuple[list[MemoryEntry], BudgetReport]: | |
| """Optimize a set of memories for an agent's token budget. | |
| Returns the optimized list and a report of what changed. | |
| Args: | |
| memories: All candidate memories. | |
| agent_type: Target agent type (determines budget). | |
| Returns: | |
| Tuple of (optimized memories, report). | |
| """ | |
| report = BudgetReport(total_memories=len(memories)) | |
| report.tokens_before = sum(_estimate_tokens(m.content) for m in memories) | |
| budget = self._config.agent_budgets.get(agent_type, 3000) | |
| # Step 1: Apply temporal decay to importance scores | |
| decayed = self._apply_decay(memories) | |
| # Step 2: Detect and flag stale memories | |
| fresh, stale = self._detect_staleness(decayed) | |
| report.pruned_staleness = len(stale) | |
| # Step 3: Merge similar memories | |
| merged = self._merge_similar(fresh) | |
| report.merged = len(fresh) - len(merged) | |
| # Step 4: Rank by score and apply budget | |
| ranked = sorted(merged, key=lambda m: m.score, reverse=True) | |
| budgeted: list[MemoryEntry] = [] | |
| tokens_used = 0 | |
| for m in ranked: | |
| entry_tokens = _estimate_tokens(m.content) + 10 | |
| if tokens_used + entry_tokens > budget: | |
| report.pruned_budget += 1 | |
| continue | |
| tokens_used += entry_tokens | |
| budgeted.append(m) | |
| report.kept = len(budgeted) | |
| report.tokens_after = tokens_used | |
| return budgeted, report | |
| def _apply_decay(self, memories: list[MemoryEntry]) -> list[MemoryEntry]: | |
| """Apply temporal decay to importance scores.""" | |
| now = time.time() | |
| rate = self._config.staleness_decay_rate | |
| min_importance = self._config.staleness_min_importance | |
| result: list[MemoryEntry] = [] | |
| for m in memories: | |
| age_days = (now - m.created_at) / 86400 | |
| # Exponential decay: importance × e^(-rate × age) | |
| import math | |
| decayed_importance = m.importance * math.exp(-rate * age_days) | |
| # Access count counteracts decay | |
| if m.access_count > 0: | |
| access_boost = min(0.3, m.access_count * 0.05) | |
| decayed_importance = min(1.0, decayed_importance + access_boost) | |
| if decayed_importance >= min_importance: | |
| m.importance = decayed_importance | |
| result.append(m) | |
| return result | |
| def _detect_staleness( | |
| self, | |
| memories: list[MemoryEntry], | |
| ) -> tuple[list[MemoryEntry], list[MemoryEntry]]: | |
| """Separate fresh memories from stale ones. | |
| A memory is stale if it references files/paths that no longer exist. | |
| Uses git ls-files when available, falls back to filesystem checks. | |
| """ | |
| git_files = self._get_git_files() if self._config.staleness_check_git else set() | |
| fresh: list[MemoryEntry] = [] | |
| stale: list[MemoryEntry] = [] | |
| for m in memories: | |
| if self._is_stale(m, git_files): | |
| stale.append(m) | |
| else: | |
| fresh.append(m) | |
| return fresh, stale | |
| def _is_stale(self, memory: MemoryEntry, git_files: set[str]) -> bool: | |
| """Check if a memory references entities that no longer exist.""" | |
| # Check entity_refs for file paths | |
| for ref in memory.entity_refs: | |
| if ref.startswith("/") or ref.startswith("./"): | |
| # Absolute or relative path — check if file exists | |
| if ref.startswith("./"): | |
| ref = ref[2:] | |
| # Normalize to relative | |
| try: | |
| rel = str(Path(ref).relative_to(self._project_path)) | |
| except ValueError: | |
| rel = ref | |
| if rel not in git_files and not Path(ref).exists(): | |
| return True | |
| # Check content for backtick-quoted file paths | |
| path_refs = re.findall(r"`([/\w.]+(?:/[\w.]+)+)`", memory.content) | |
| for path_ref in path_refs: | |
| # Only flag if it looks like a full path and is missing | |
| if "/" in path_ref: | |
| try: | |
| rel = str(Path(path_ref).relative_to(self._project_path)) | |
| except ValueError: | |
| rel = path_ref | |
| if rel not in git_files and not Path(path_ref).exists(): | |
| return True | |
| return False | |
| def _get_git_files(self) -> set[str]: | |
| """Get set of tracked files in the git repo.""" | |
| if self._git_files_cache is not None: | |
| return self._git_files_cache | |
| try: | |
| result = subprocess.run( | |
| ["git", "ls-files"], | |
| capture_output=True, | |
| text=True, | |
| cwd=self._project_path, | |
| timeout=5, | |
| ) | |
| if result.returncode == 0: | |
| self._git_files_cache = set(result.stdout.strip().split("\n")) | |
| else: | |
| self._git_files_cache = set() | |
| except (subprocess.TimeoutExpired, FileNotFoundError): | |
| self._git_files_cache = set() | |
| return self._git_files_cache | |
| def _merge_similar(self, memories: list[MemoryEntry]) -> list[MemoryEntry]: | |
| """Merge memories with very similar content. | |
| Uses simple text overlap heuristic (no embeddings required). | |
| """ | |
| if len(memories) <= 1: | |
| return memories | |
| merged: list[MemoryEntry] = [] | |
| used: set[int] = set() | |
| for i, m1 in enumerate(memories): | |
| if i in used: | |
| continue | |
| # Find similar memories | |
| group = [m1] | |
| for j, m2 in enumerate(memories[i + 1 :], start=i + 1): | |
| if j in used: | |
| continue | |
| if ( | |
| self._text_similarity(m1.content, m2.content) | |
| > self._config.similarity_merge_threshold | |
| ): | |
| group.append(m2) | |
| used.add(j) | |
| if len(group) == 1: | |
| merged.append(m1) | |
| else: | |
| # Merge: keep highest-importance content, combine entity refs | |
| best = max(group, key=lambda m: m.importance) | |
| all_entities = set() | |
| for m in group: | |
| all_entities.update(m.entity_refs) | |
| best.entity_refs = list(all_entities) | |
| best.access_count = max(m.access_count for m in group) | |
| merged.append(best) | |
| return merged | |
| def _text_similarity(a: str, b: str) -> float: | |
| """Simple Jaccard similarity on word sets.""" | |
| words_a = set(a.lower().split()) | |
| words_b = set(b.lower().split()) | |
| if not words_a or not words_b: | |
| return 0.0 | |
| intersection = words_a & words_b | |
| union = words_a | words_b | |
| return len(intersection) / len(union) | |