personal memory agent
0
fork

Configure Feed

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

Merge branch 'hopper-jb6txe4e-dirty-days-banner'

+154
+11
apps/agents/routes.py
··· 19 19 from think.facets import get_facets 20 20 from think.models import calc_agent_cost 21 21 from think.muse import get_muse_configs, get_output_path 22 + from think.utils import dirty_days 22 23 23 24 agents_bp = Blueprint( 24 25 "app:agents", ··· 629 630 agents = _get_agents_for_day(today, facet_filter=None) 630 631 failed_count = sum(1 for a in agents if a.get("failed")) 631 632 return jsonify({"count": failed_count}) 633 + 634 + 635 + @agents_bp.route("/api/dirty-days") 636 + def api_dirty_days() -> Any: 637 + """Return journal days with pending reprocessing.""" 638 + today = date.today().strftime("%Y%m%d") 639 + try: 640 + return jsonify(dirty_days(exclude={today})) 641 + except Exception: 642 + return jsonify([])
+53
apps/agents/workspace.html
··· 851 851 margin-bottom: 1rem; 852 852 } 853 853 854 + .dirty-banner { 855 + background: #fff8e1; 856 + border: 1px solid #ffe082; 857 + border-radius: 6px; 858 + padding: 0.75rem 1rem; 859 + margin-bottom: 1rem; 860 + font-size: 0.9rem; 861 + color: #5d4037; 862 + } 863 + 864 + .dirty-banner-title { 865 + font-weight: 600; 866 + margin-bottom: 0.25rem; 867 + } 868 + 869 + .dirty-banner a { 870 + color: #e65100; 871 + text-decoration: none; 872 + font-weight: 500; 873 + } 874 + 875 + .dirty-banner a:hover { 876 + text-decoration: underline; 877 + } 878 + 854 879 @keyframes spin { 855 880 to { transform: rotate(360deg); } 856 881 } ··· 865 890 866 891 <!-- Card Grid View --> 867 892 <div id="grid-view" style="display: none;"> 893 + <div id="dirty-banner" class="dirty-banner" style="display: none;"></div> 868 894 <div id="day-summary" class="day-summary" style="display: none;"></div> 869 895 <div id="agent-groups"></div> 870 896 <div id="empty-state" class="empty-state" style="display: none;"> ··· 1089 1115 document.getElementById('grid-view').style.display = 'none'; 1090 1116 document.getElementById('list-view').style.display = 'none'; 1091 1117 document.getElementById('run-detail-view').style.display = 'none'; 1118 + } 1119 + 1120 + function loadDirtyBanner() { 1121 + const banner = document.getElementById('dirty-banner'); 1122 + const now = new Date(); 1123 + const today = String(now.getFullYear()) + String(now.getMonth() + 1).padStart(2, '0') + String(now.getDate()).padStart(2, '0'); 1124 + if (currentDay !== today || window.selectedFacet) { 1125 + banner.style.display = 'none'; 1126 + return; 1127 + } 1128 + fetch('api/dirty-days') 1129 + .then(r => r.json()) 1130 + .then(days => { 1131 + if (!days.length) { 1132 + banner.style.display = 'none'; 1133 + return; 1134 + } 1135 + const links = days.map(d => { 1136 + const label = d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6); 1137 + return `<a href="${d}">${label}</a>`; 1138 + }).join(', '); 1139 + banner.innerHTML = `<div class="dirty-banner-title">Days with pending reprocessing</div>${links}`; 1140 + banner.style.display = 'block'; 1141 + }) 1142 + .catch(() => { banner.style.display = 'none'; }); 1092 1143 } 1093 1144 1094 1145 async function loadAgents() { ··· 2083 2134 2084 2135 // Listen for facet changes 2085 2136 window.addEventListener('facet.switch', () => { 2137 + loadDirtyBanner(); 2086 2138 loadAgents(); 2087 2139 }); 2088 2140 2089 2141 // Initial load 2142 + loadDirtyBanner(); 2090 2143 loadAgents(); 2091 2144 })(); 2092 2145 </script>
+43
tests/test_dirty_days.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for dirty_days() utility.""" 5 + 6 + import time 7 + 8 + import think.utils 9 + from think.utils import dirty_days 10 + 11 + 12 + def test_dirty_days_fixture(monkeypatch): 13 + """20250101 has stream.updated but no daily.updated — should be dirty.""" 14 + monkeypatch.setenv("JOURNAL_PATH", "tests/fixtures/journal") 15 + monkeypatch.setattr(think.utils, "_journal_path_cache", None) 16 + days = dirty_days() 17 + assert "20250101" in days 18 + 19 + 20 + def test_dirty_days_exclude(monkeypatch): 21 + """Excluded days should not appear in results.""" 22 + monkeypatch.setenv("JOURNAL_PATH", "tests/fixtures/journal") 23 + monkeypatch.setattr(think.utils, "_journal_path_cache", None) 24 + days = dirty_days(exclude={"20250101"}) 25 + assert "20250101" not in days 26 + 27 + 28 + def test_dirty_days_clean(tmp_path, monkeypatch): 29 + """Day with daily.updated newer than stream.updated is not dirty.""" 30 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 31 + day_dir = tmp_path / "20260101" / "health" 32 + day_dir.mkdir(parents=True) 33 + (day_dir / "stream.updated").touch() 34 + time.sleep(0.05) 35 + (day_dir / "daily.updated").touch() 36 + assert dirty_days() == [] 37 + 38 + 39 + def test_dirty_days_no_stream(tmp_path, monkeypatch): 40 + """Day without stream.updated is not dirty (no stream data).""" 41 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 42 + (tmp_path / "20260101").mkdir() 43 + assert dirty_days() == []
+12
think/dream.py
··· 1441 1441 if args.activity and not args.facet: 1442 1442 parser.error("--activity requires --facet") 1443 1443 1444 + # Auto-enable refresh for dirty days (full daily runs only) 1445 + if not args.refresh and not args.segment and not args.segments: 1446 + health_dir = day_dir / "health" 1447 + stream_marker = health_dir / "stream.updated" 1448 + daily_marker = health_dir / "daily.updated" 1449 + if stream_marker.is_file() and ( 1450 + not daily_marker.is_file() 1451 + or stream_marker.stat().st_mtime > daily_marker.stat().st_mtime 1452 + ): 1453 + args.refresh = True 1454 + logging.info("Day %s has pending stream data, enabling refresh", day) 1455 + 1444 1456 if args.activity and not args.day: 1445 1457 parser.error("--activity requires --day") 1446 1458
+35
think/utils.py
··· 212 212 return days 213 213 214 214 215 + def dirty_days(exclude: set[str] | None = None) -> list[str]: 216 + """Return journal days with pending stream data not yet processed daily. 217 + 218 + A day is "dirty" when it has a ``health/stream.updated`` marker that is 219 + newer than its ``health/daily.updated`` marker (or daily.updated is missing). 220 + Days without ``stream.updated`` are skipped entirely. 221 + 222 + Parameters 223 + ---------- 224 + exclude : set of str, optional 225 + Day strings (YYYYMMDD) to skip. 226 + 227 + Returns 228 + ------- 229 + list of str 230 + Sorted list of dirty day strings. 231 + """ 232 + days = day_dirs() 233 + dirty: list[str] = [] 234 + for name, path in days.items(): 235 + if exclude and name in exclude: 236 + continue 237 + stream = os.path.join(path, "health", "stream.updated") 238 + if not os.path.isfile(stream): 239 + continue 240 + daily = os.path.join(path, "health", "daily.updated") 241 + if not os.path.isfile(daily): 242 + dirty.append(name) 243 + continue 244 + if os.path.getmtime(stream) > os.path.getmtime(daily): 245 + dirty.append(name) 246 + dirty.sort() 247 + return dirty 248 + 249 + 215 250 def segment_path(day: str, segment: str, stream: str) -> Path: 216 251 """Return absolute path for a segment directory within a stream. 217 252