personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'hopper-y3lglkdn-storage-warnings'

+327 -8
+5
apps/settings/routes.py
··· 20 20 from think.providers.google import validate_vertex_credentials 21 21 from think.retention import ( 22 22 _human_bytes, 23 + check_storage_health, 23 24 compute_storage_summary, 24 25 load_retention_config, 25 26 purge, 26 27 ) 27 28 from think.streams import list_streams 28 29 from think.utils import get_config as get_journal_config 30 + from think.utils import get_journal 29 31 30 32 logger = logging.getLogger(__name__) 31 33 ··· 2155 2157 try: 2156 2158 summary = compute_storage_summary() 2157 2159 config = load_retention_config() 2160 + journal_path = get_journal() 2161 + warnings = check_storage_health(summary, journal_path) 2158 2162 try: 2159 2163 streams = list_streams() 2160 2164 except Exception: ··· 2180 2184 }, 2181 2185 }, 2182 2186 "streams": [{"name": s.get("name", "")} for s in streams], 2187 + "warnings": warnings, 2183 2188 } 2184 2189 ) 2185 2190 except Exception:
+14 -7
apps/settings/workspace.html
··· 2621 2621 <section class="settings-section" id="section-storage" role="tabpanel" aria-labelledby="tab-storage"> 2622 2622 <h2>storage</h2> 2623 2623 <p class="settings-section-desc">Manage raw media retention and view storage usage.</p> 2624 + <div id="storageWarnings"></div> 2624 2625 2625 2626 <form class="settings-form" onsubmit="return false;"> 2626 2627 ··· 5792 5793 } 5793 5794 5794 5795 function populateStorage(data) { 5796 + // Render storage warnings 5797 + const warningsEl = document.getElementById('storageWarnings'); 5798 + warningsEl.innerHTML = ''; 5799 + if (data.warnings && data.warnings.length > 0) { 5800 + data.warnings.forEach(w => { 5801 + const div = document.createElement('div'); 5802 + div.className = 'provider-key-warning'; 5803 + div.innerHTML = '<span>&#9888;</span> <span>' + escapeHtml(w.message) + '</span>'; 5804 + warningsEl.appendChild(div); 5805 + }); 5806 + } 5807 + 5795 5808 const s = data.summary; 5796 5809 document.getElementById('storageSummaryRaw').textContent = s.raw_media_human; 5797 5810 document.getElementById('storageSummaryDerived').textContent = s.derived_human; ··· 6055 6068 6056 6069 closeCleanupModal(); 6057 6070 6058 - // Refresh storage summary 6059 - if (result.summary) { 6060 - storageData.summary = result.summary; 6061 - populateStorage(storageData); 6062 - } else { 6063 - loadStorage(); 6064 - } 6071 + loadStorage(); 6065 6072 } catch (err) { 6066 6073 notifyError('Cleanup Failed', err.message); 6067 6074 } finally {
+160
tests/test_retention.py
··· 6 6 import hashlib 7 7 import json 8 8 import os 9 + import shutil 9 10 from datetime import datetime 10 11 11 12 from think.retention import ( 12 13 RetentionConfig, 13 14 RetentionPolicy, 15 + StorageSummary, 14 16 _human_bytes, 17 + check_storage_health, 15 18 get_raw_media_files, 16 19 is_raw_media, 17 20 is_segment_complete, ··· 483 486 def test_large(self): 484 487 result = _human_bytes(12_400_000_000) 485 488 assert "GB" in result 489 + 490 + 491 + class TestCheckStorageHealth: 492 + """Tests for check_storage_health threshold evaluation.""" 493 + 494 + def _make_summary(self, raw_media_bytes=0, derived_bytes=0): 495 + return StorageSummary( 496 + raw_media_bytes=raw_media_bytes, 497 + derived_bytes=derived_bytes, 498 + total_segments=10, 499 + segments_with_raw=5, 500 + segments_purged=3, 501 + ) 502 + 503 + def test_no_warnings_when_healthy(self, tmp_path, monkeypatch): 504 + """No warnings when disk is below threshold and raw media GB is null.""" 505 + usage_type = type(shutil.disk_usage(tmp_path)) 506 + monkeypatch.setattr( 507 + "shutil.disk_usage", 508 + lambda path: usage_type(1000, 500, 500), # 50% used 509 + ) 510 + config = { 511 + "retention": { 512 + "storage_warning_disk_percent": 80, 513 + "storage_warning_raw_media_gb": None, 514 + } 515 + } 516 + summary = self._make_summary() 517 + warnings = check_storage_health(summary, tmp_path, config=config) 518 + assert warnings == [] 519 + 520 + def test_disk_percent_exceeded(self, tmp_path, monkeypatch): 521 + """Warning when disk usage exceeds threshold.""" 522 + config = { 523 + "retention": { 524 + "storage_warning_disk_percent": 1, 525 + } 526 + } 527 + summary = self._make_summary() 528 + warnings = check_storage_health(summary, tmp_path, config=config) 529 + assert len(warnings) == 1 530 + assert warnings[0]["type"] == "disk_percent" 531 + assert warnings[0]["level"] == "warning" 532 + assert warnings[0]["current"] >= 1 533 + assert warnings[0]["threshold"] == 1 534 + assert "retention settings" in warnings[0]["message"] 535 + assert "Clean Up Now" in warnings[0]["message"] 536 + 537 + def test_disk_percent_not_exceeded(self, tmp_path, monkeypatch): 538 + """No warning when disk is well below threshold.""" 539 + config = { 540 + "retention": { 541 + "storage_warning_disk_percent": 100, 542 + } 543 + } 544 + summary = self._make_summary() 545 + warnings = check_storage_health(summary, tmp_path, config=config) 546 + assert warnings == [] 547 + 548 + def test_raw_media_gb_exceeded(self, tmp_path, monkeypatch): 549 + """Warning when raw media exceeds GB threshold.""" 550 + raw_bytes = int(5.5 * 1024**3) 551 + config = { 552 + "retention": { 553 + "storage_warning_disk_percent": None, 554 + "storage_warning_raw_media_gb": 5.0, 555 + } 556 + } 557 + summary = self._make_summary(raw_media_bytes=raw_bytes) 558 + warnings = check_storage_health(summary, tmp_path, config=config) 559 + assert len(warnings) == 1 560 + assert warnings[0]["type"] == "raw_media_gb" 561 + assert warnings[0]["level"] == "warning" 562 + assert warnings[0]["current"] >= 5.0 563 + assert warnings[0]["threshold"] == 5.0 564 + assert "retention settings" in warnings[0]["message"] 565 + 566 + def test_raw_media_gb_not_exceeded(self, tmp_path, monkeypatch): 567 + """No warning when raw media is below threshold.""" 568 + raw_bytes = int(2.0 * 1024**3) 569 + config = { 570 + "retention": { 571 + "storage_warning_disk_percent": None, 572 + "storage_warning_raw_media_gb": 5.0, 573 + } 574 + } 575 + summary = self._make_summary(raw_media_bytes=raw_bytes) 576 + warnings = check_storage_health(summary, tmp_path, config=config) 577 + assert warnings == [] 578 + 579 + def test_both_thresholds_exceeded(self, tmp_path, monkeypatch): 580 + """Both warnings when both thresholds exceeded.""" 581 + raw_bytes = int(10 * 1024**3) 582 + config = { 583 + "retention": { 584 + "storage_warning_disk_percent": 1, 585 + "storage_warning_raw_media_gb": 5.0, 586 + } 587 + } 588 + summary = self._make_summary(raw_media_bytes=raw_bytes) 589 + warnings = check_storage_health(summary, tmp_path, config=config) 590 + assert len(warnings) == 2 591 + types = {w["type"] for w in warnings} 592 + assert types == {"disk_percent", "raw_media_gb"} 593 + 594 + def test_null_thresholds_disables_checks(self, tmp_path, monkeypatch): 595 + """Both thresholds null means no warnings ever.""" 596 + raw_bytes = int(100 * 1024**3) 597 + config = { 598 + "retention": { 599 + "storage_warning_disk_percent": None, 600 + "storage_warning_raw_media_gb": None, 601 + } 602 + } 603 + summary = self._make_summary(raw_media_bytes=raw_bytes) 604 + warnings = check_storage_health(summary, tmp_path, config=config) 605 + assert warnings == [] 606 + 607 + def test_exact_threshold_triggers(self, tmp_path, monkeypatch): 608 + """Warning triggers at exactly the threshold (>=, not >).""" 609 + raw_bytes = int(5.0 * 1024**3) 610 + config = { 611 + "retention": { 612 + "storage_warning_disk_percent": None, 613 + "storage_warning_raw_media_gb": 5.0, 614 + } 615 + } 616 + summary = self._make_summary(raw_media_bytes=raw_bytes) 617 + warnings = check_storage_health(summary, tmp_path, config=config) 618 + assert len(warnings) == 1 619 + assert warnings[0]["type"] == "raw_media_gb" 620 + 621 + def test_missing_retention_section_uses_defaults(self, tmp_path, monkeypatch): 622 + """Missing retention section falls back to defaults (80% disk, null raw media).""" 623 + config = {} 624 + summary = self._make_summary() 625 + warnings = check_storage_health(summary, tmp_path, config=config) 626 + for w in warnings: 627 + assert w["type"] != "raw_media_gb" 628 + 629 + def test_warning_dict_structure(self, tmp_path, monkeypatch): 630 + """Each warning has all required keys.""" 631 + config = { 632 + "retention": { 633 + "storage_warning_disk_percent": 1, 634 + "storage_warning_raw_media_gb": 0.001, 635 + } 636 + } 637 + raw_bytes = int(1 * 1024**3) 638 + summary = self._make_summary(raw_media_bytes=raw_bytes) 639 + warnings = check_storage_health(summary, tmp_path, config=config) 640 + for w in warnings: 641 + assert "level" in w 642 + assert "type" in w 643 + assert "message" in w 644 + assert "current" in w 645 + assert "threshold" in w
+30
think/dream.py
··· 2504 2504 stats_cmd.append("--verbose") 2505 2505 run_command(stats_cmd, day) 2506 2506 2507 + # Check storage health and emit warnings 2508 + try: 2509 + from think.retention import check_storage_health, compute_storage_summary 2510 + from think.callosum import callosum_send 2511 + 2512 + storage_summary = compute_storage_summary() 2513 + journal_path = get_journal() 2514 + storage_warnings = check_storage_health(storage_summary, journal_path) 2515 + for warning in storage_warnings: 2516 + callosum_send( 2517 + "storage", 2518 + "warning", 2519 + level=warning["level"], 2520 + type=warning["type"], 2521 + message=warning["message"], 2522 + current=warning["current"], 2523 + threshold=warning["threshold"], 2524 + ) 2525 + if storage_warnings: 2526 + callosum_send( 2527 + "notification", 2528 + "show", 2529 + title="Storage Warning", 2530 + message=storage_warnings[0]["message"], 2531 + icon="💾", 2532 + action="/app/settings#storage", 2533 + ) 2534 + except Exception: 2535 + logging.debug("Storage health check failed in post-phase", exc_info=True) 2536 + 2507 2537 # Touch daily.updated marker after daily schedule completion 2508 2538 try: 2509 2539 health_dir = day_path(day) / "health"
+3 -1
think/journal_default.json
··· 35 35 "retention": { 36 36 "raw_media": "keep", 37 37 "raw_media_days": null, 38 - "per_stream": {} 38 + "per_stream": {}, 39 + "storage_warning_disk_percent": 80, 40 + "storage_warning_raw_media_gb": null 39 41 } 40 42 }
+75
think/retention.py
··· 18 18 import hashlib 19 19 import json 20 20 import logging 21 + import shutil 21 22 from dataclasses import dataclass, field 22 23 from datetime import datetime 23 24 from pathlib import Path ··· 247 248 summary.derived_bytes += f.stat().st_size 248 249 249 250 return summary 251 + 252 + 253 + def check_storage_health( 254 + summary: StorageSummary, 255 + journal_path: str | Path, 256 + config: dict | None = None, 257 + ) -> list[dict]: 258 + """Check storage health against configured thresholds. 259 + 260 + Parameters 261 + ---------- 262 + summary 263 + Pre-computed storage summary (avoids recomputation). 264 + journal_path 265 + Journal root path, used for disk usage check. 266 + config 267 + Full journal config dict. Loaded via get_config() if not provided. 268 + 269 + Returns 270 + ------- 271 + list[dict] 272 + List of warning dicts. Empty when healthy. 273 + """ 274 + if config is None: 275 + from think.utils import get_config 276 + 277 + config = get_config() 278 + 279 + retention = config.get("retention", {}) 280 + warnings = [] 281 + 282 + # Check disk usage percentage 283 + disk_threshold = retention.get("storage_warning_disk_percent", 80) 284 + if disk_threshold is not None: 285 + try: 286 + usage = shutil.disk_usage(str(journal_path)) 287 + disk_percent = round(usage.used / usage.total * 100, 1) 288 + if disk_percent >= disk_threshold: 289 + warnings.append( 290 + { 291 + "level": "warning", 292 + "type": "disk_percent", 293 + "message": ( 294 + f"Disk is {disk_percent}% full (threshold: {disk_threshold}%). " 295 + "Consider adjusting retention settings or running Clean Up Now " 296 + "to free space." 297 + ), 298 + "current": disk_percent, 299 + "threshold": disk_threshold, 300 + } 301 + ) 302 + except OSError: 303 + pass 304 + 305 + # Check raw media size 306 + raw_media_gb_threshold = retention.get("storage_warning_raw_media_gb") 307 + if raw_media_gb_threshold is not None: 308 + raw_media_gb = round(summary.raw_media_bytes / (1024**3), 2) 309 + if raw_media_gb >= raw_media_gb_threshold: 310 + warnings.append( 311 + { 312 + "level": "warning", 313 + "type": "raw_media_gb", 314 + "message": ( 315 + f"Raw media is {raw_media_gb} GB (threshold: {raw_media_gb_threshold} GB). " 316 + "Consider adjusting retention settings or running Clean Up Now " 317 + "to free space." 318 + ), 319 + "current": raw_media_gb, 320 + "threshold": raw_media_gb_threshold, 321 + } 322 + ) 323 + 324 + return warnings 250 325 251 326 252 327 # ---------------------------------------------------------------------------
+40
think/tools/call.py
··· 953 953 @app.command(name="storage-summary") 954 954 def storage_summary( 955 955 json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 956 + check: bool = typer.Option( 957 + False, "--check", help="Check storage health thresholds." 958 + ), 956 959 ) -> None: 957 960 """Show journal storage summary.""" 958 961 from think.retention import compute_storage_summary 959 962 960 963 summary = compute_storage_summary() 964 + 965 + if check: 966 + from think.retention import check_storage_health 967 + from think.utils import get_journal 968 + 969 + journal_path = get_journal() 970 + warnings = check_storage_health(summary, journal_path) 971 + 972 + if json_output: 973 + typer.echo( 974 + json.dumps( 975 + { 976 + "raw_media_bytes": summary.raw_media_bytes, 977 + "derived_bytes": summary.derived_bytes, 978 + "total_segments": summary.total_segments, 979 + "segments_with_raw": summary.segments_with_raw, 980 + "segments_purged": summary.segments_purged, 981 + "warnings": warnings, 982 + }, 983 + indent=2, 984 + ) 985 + ) 986 + else: 987 + typer.echo(f"Raw media: {summary.raw_media_human}") 988 + typer.echo(f"AI-processed content: {summary.derived_human}") 989 + typer.echo( 990 + f"Segments: {summary.total_segments} total, " 991 + f"{summary.segments_with_raw} with raw media, " 992 + f"{summary.segments_purged} purged" 993 + ) 994 + if warnings: 995 + typer.echo("") 996 + for w in warnings: 997 + typer.echo(f"⚠ {w['message']}") 998 + else: 999 + typer.echo("\nAll storage thresholds OK.") 1000 + return 961 1001 962 1002 if json_output: 963 1003 typer.echo(