diff --git a/src/ctx_monitor.py b/src/ctx_monitor.py index a9cbf52..de00ed9 100644 --- a/src/ctx_monitor.py +++ b/src/ctx_monitor.py @@ -91,11 +91,20 @@ _MONITOR_MUTATIONS_ENABLED = True _GRAPH_CACHE_KEY: tuple[Any, ...] | None = None _GRAPH_CACHE_VALUE: Any | None = None +_PACKAGED_GRAPH_EXPORT_ID_CACHE: str | None | bool = None _OVERLAY_INDEX_COVERAGE_CACHE_KEY: tuple[Any, ...] | None = None _OVERLAY_INDEX_COVERAGE_CACHE_VALUE: bool | None = None _SIDECAR_INDEX_CACHE_KEY: tuple[tuple[Path, float, int], ...] | None = None _SIDECAR_INDEX_CACHE_VALUE: dict[tuple[str, str], dict] | None = None +_SIDECAR_FILTER_CACHE_SIGNATURE: tuple[Any, ...] | None = None +_SIDECAR_FILTER_CACHE_VALUE: dict[tuple[Any, ...], list[dict[str, Any]]] = {} +_KPI_SUMMARY_CACHE_KEY: tuple[Any, ...] | None = None +_KPI_SUMMARY_CACHE_VALUE: Any | None = None +_KPI_SUMMARY_CACHE_AT = 0.0 _WIKI_INDEX_LIMIT_PER_TYPE = 500 +_SKILLS_PAGE_DEFAULT_LIMIT = 100 +_SKILLS_PAGE_MAX_LIMIT = 500 +_KPI_SUMMARY_CACHE_SECONDS = 30 _GRAPH_REPORT_RE = re.compile(r"Nodes:\s*([\d,]+)\s*\|\s*Edges:\s*([\d,]+)") _MAX_POST_BODY_BYTES = 64 * 1024 _DASHBOARD_INDEX_MEMBER = "graphify-out/dashboard-neighborhoods.sqlite3" @@ -1772,6 +1781,231 @@ def _all_sidecars() -> list[dict]: return list(_sidecar_index().values()) +def _skills_page_int( + value: str | None, + *, + default: int, + minimum: int = 1, + maximum: int | None = None, +) -> int: + try: + parsed = int(str(value or "").strip()) + except ValueError: + parsed = default + parsed = max(minimum, parsed) + if maximum is not None: + parsed = min(maximum, parsed) + return parsed + + +def _skills_query_values(raw: str | None, allowed: set[str]) -> set[str]: + values = { + item.strip() + for item in str(raw or "").split(",") + if item.strip() + } + return {item for item in values if item in allowed} + + +def _sidecar_sort_key(sidecar: dict) -> tuple[str, float, str]: + return ( + str(sidecar.get("grade") or "F"), + -float(sidecar.get("raw_score") or sidecar.get("score") or 0.0), + str(sidecar.get("slug") or ""), + ) + + +def _sidecar_card_payload(sidecar: dict) -> dict[str, Any]: + slug = str(sidecar.get("slug") or "") + entity_type = _sidecar_entity_type(sidecar) + return { + "slug": slug, + "grade": str(sidecar.get("grade") or "F"), + "type": entity_type, + "hard_floor": str(sidecar.get("hard_floor") or ""), + "raw_score": float(sidecar.get("raw_score") or sidecar.get("score") or 0.0), + "sidecar_href": f"/skill/{quote(slug)}?type={quote(entity_type)}", + "wiki_href": f"/wiki/{quote(slug)}?type={quote(entity_type)}", + "graph_href": f"/graph?slug={quote(slug)}&type={quote(entity_type)}", + } + + +def _sidecar_filter_signature(files: list[Path]) -> tuple[Any, ...]: + roots = (_sidecar_dir(), _sidecar_dir() / "mcp") + root_counts = { + root.resolve(): sum(1 for path in files if path.parent == root) + for root in roots + } + signature: list[tuple[str, int, int]] = [] + for root in roots: + if not root.is_dir(): + signature.append((str(root.resolve()), 0, 0)) + continue + stat = root.stat() + signature.append(( + str(root.resolve()), + int(getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))), + root_counts.get(root.resolve(), 0), + )) + return tuple(signature) + + +def _sidecar_candidate_files( + files: list[Path], + *, + q: str, + types: set[str], +) -> list[Path]: + q_lower = q.lower() + candidates = [ + path for path in files + if not q_lower or q_lower in path.stem.lower() + ] + if not types: + return candidates + if types == {"mcp-server"}: + return [path for path in candidates if path.parent.name == "mcp"] + if "mcp-server" not in types: + return [path for path in candidates if path.parent.name != "mcp"] + return candidates + + +def _filtered_sidecar_records( + files: list[Path], + *, + q: str, + types: set[str], + grades: set[str], + hide_floor: bool, +) -> list[dict[str, Any]]: + """Return cached filtered sidecar card records for /skills search.""" + global _SIDECAR_FILTER_CACHE_SIGNATURE, _SIDECAR_FILTER_CACHE_VALUE + + signature = _sidecar_filter_signature(files) + if _SIDECAR_FILTER_CACHE_SIGNATURE != signature: + _SIDECAR_FILTER_CACHE_SIGNATURE = signature + _SIDECAR_FILTER_CACHE_VALUE = {} + cache_key = ( + q.lower(), + tuple(sorted(types)), + tuple(sorted(grades)), + hide_floor, + ) + cached = _SIDECAR_FILTER_CACHE_VALUE.get(cache_key) + if cached is not None: + return cached + + records: list[dict[str, Any]] = [] + for path in _sidecar_candidate_files(files, q=q, types=types): + sidecar = _read_sidecar_file(path) + if sidecar is None: + continue + if not _sidecar_matches_filters( + sidecar, + q=q, + types=types, + grades=grades, + hide_floor=hide_floor, + ): + continue + records.append(_sidecar_card_payload(sidecar)) + records.sort(key=_sidecar_sort_key) + if len(_SIDECAR_FILTER_CACHE_VALUE) >= 32: + _SIDECAR_FILTER_CACHE_VALUE.clear() + _SIDECAR_FILTER_CACHE_VALUE[cache_key] = records + return records + + +def _sidecar_matches_filters( + sidecar: dict, + *, + q: str, + types: set[str], + grades: set[str], + hide_floor: bool, +) -> bool: + entity_type = _sidecar_entity_type(sidecar) + grade = str(sidecar.get("grade") or "F") + floor = str(sidecar.get("hard_floor") or "") + if types and entity_type not in types: + return False + if grades and grade not in grades: + return False + if hide_floor and floor: + return False + if q: + return q.lower() in str(sidecar.get("slug") or "").lower() + return True + + +def _sidecar_page_payload(qs: dict[str, str] | None = None) -> dict[str, Any]: + """Return a paginated sidecar payload for /skills and its JSON API.""" + qs = qs or {} + page = _skills_page_int(qs.get("page"), default=1) + limit = _skills_page_int( + qs.get("limit"), + default=_SKILLS_PAGE_DEFAULT_LIMIT, + maximum=_SKILLS_PAGE_MAX_LIMIT, + ) + q = str(qs.get("q") or "").strip() + types = _skills_query_values(qs.get("type"), set(_DASHBOARD_ENTITY_TYPES)) + grades = _skills_query_values(qs.get("grade"), {"A", "B", "C", "D", "F"}) + hide_floor = str(qs.get("hide_floor") or "").strip().lower() in { + "1", "true", "yes", "on", + } + + files = _sidecar_files() + catalog_total = len(files) + has_filters = bool(q or types or grades or hide_floor) + if has_filters: + sidecars = _filtered_sidecar_records( + files, + q=q, + types=types, + grades=grades, + hide_floor=hide_floor, + ) + total = len(sidecars) + start = (page - 1) * limit + page_sidecars = sidecars[start:start + limit] + else: + total = catalog_total + start = (page - 1) * limit + selected_files = files[start:start + limit] + page_sidecars = [ + sidecar + for path in selected_files + if (sidecar := _read_sidecar_file(path)) is not None + ] + if catalog_total <= limit: + page_sidecars.sort(key=_sidecar_sort_key) + + pages = max(1, math.ceil(total / limit)) if total else 1 + if page > pages: + page = pages + return _sidecar_page_payload({ + **qs, + "page": str(page), + "limit": str(limit), + }) + + return { + "items": [_sidecar_card_payload(sidecar) for sidecar in page_sidecars], + "total": total, + "catalog_total": catalog_total, + "page": page, + "limit": limit, + "pages": pages, + "has_next": page < pages, + "has_prev": page > 1, + "filtered": has_filters, + "q": q, + "types": sorted(types), + "grades": sorted(grades), + "hide_floor": hide_floor, + } + + # ─── Aggregations ──────────────────────────────────────────────────────────── @@ -2623,6 +2857,30 @@ def _dashboard_graph_index_archives() -> list[Path]: return archives +def _packaged_graph_export_id() -> str | None: + global _PACKAGED_GRAPH_EXPORT_ID_CACHE + if isinstance(_PACKAGED_GRAPH_EXPORT_ID_CACHE, bool): + return None + if isinstance(_PACKAGED_GRAPH_EXPORT_ID_CACHE, str): + return _PACKAGED_GRAPH_EXPORT_ID_CACHE + module_root = Path(__file__).resolve().parent.parent + try: + data = json.loads( + (module_root / "graph" / "communities.json").read_text( + encoding="utf-8", + ) + ) + except (OSError, json.JSONDecodeError): + _PACKAGED_GRAPH_EXPORT_ID_CACHE = False + return None + export_id = data.get("export_id") if isinstance(data, dict) else None + if isinstance(export_id, str) and export_id.strip(): + _PACKAGED_GRAPH_EXPORT_ID_CACHE = export_id.strip() + return export_id.strip() + _PACKAGED_GRAPH_EXPORT_ID_CACHE = False + return None + + def _archive_graph_export_id(archive: Path) -> str | None: try: with tarfile.open(archive, "r:gz") as tar: @@ -2652,13 +2910,20 @@ def _ensure_dashboard_graph_index() -> Path | None: target.unlink() except OSError: return None - if (_wiki_dir() / "graphify-out" / "graph-report.md").is_file(): + + manifest_export_id = _dashboard_graph_manifest_export_id() + packaged_export_id = _packaged_graph_export_id() + if ( + manifest_export_id is not None + and packaged_export_id is not None + and manifest_export_id != packaged_export_id + ): return None archives = _dashboard_graph_index_archives() if not archives: return None - if _dashboard_graph_manifest_export_id() is None: + if manifest_export_id is None: return None target.parent.mkdir(parents=True, exist_ok=True) @@ -2672,8 +2937,7 @@ def _ensure_dashboard_graph_index() -> Path | None: except OSError: return None for archive in archives: - archive_export_id = _archive_graph_export_id(archive) - manifest_export_id = _dashboard_graph_manifest_export_id() + archive_export_id = packaged_export_id or _archive_graph_export_id(archive) if manifest_export_id and archive_export_id and archive_export_id != manifest_export_id: continue try: @@ -2992,6 +3256,15 @@ def _graph_neighborhood( ) if indexed is not None: return indexed + manifest_export_id = _dashboard_graph_manifest_export_id() + packaged_export_id = _packaged_graph_export_id() + if ( + not _dashboard_graph_index_path().is_file() + and manifest_export_id is not None + and packaged_export_id is not None + and manifest_export_id != packaged_export_id + ): + return {"nodes": [], "edges": [], "center": None} try: G = _load_dashboard_graph() except Exception: # noqa: BLE001 — graph is advisory; blank on error @@ -3418,17 +3691,9 @@ def _render_session_detail(session_id: str) -> str: return _layout(f"Session {session_id}", body) -def _render_skills() -> str: - sidecars = _all_sidecars() - sidecars.sort(key=lambda s: (s.get("grade", "F"), -s.get("raw_score", 0.0))) - - # Sidebar stats for the filter UI. - grade_counts = {"A": 0, "B": 0, "C": 0, "D": 0, "F": 0} - type_counts = {entity_type: 0 for entity_type in _DASHBOARD_ENTITY_TYPES} - for sc in sidecars: - grade_counts[sc.get("grade", "F")] = grade_counts.get(sc.get("grade", "F"), 0) + 1 - st = _sidecar_entity_type(sc) - type_counts[st] = type_counts.get(st, 0) + 1 +def _render_skills(qs: dict[str, str] | None = None) -> str: + payload = _sidecar_page_payload(qs) + sidecars = payload["items"] cards = "".join( f"
{len(sidecars)} sidecars · click any card to drill in.
" - "{payload['catalog_total']} sidecars · click any card to drill in.
" + + pagination + + "