Sync ctx 333622a
Browse filesGitHub commit: 333622a56de0a4acad72146b82611401bc076c69
- README.md +1 -1
- docs/catalog.md +2 -0
- docs/dashboard.md +11 -8
- docs/index.md +1 -1
- docs/knowledge-graph.md +2 -2
- src/backup_mirror.py +0 -6
- src/ctx/adapters/claude_code/install/mcp_install.py +22 -2
- src/ctx/adapters/generic/ctx_core_tools.py +0 -19
- src/ctx/core/entity_types.py +54 -0
- src/ctx/core/wiki/wiki_sync.py +17 -27
- src/ctx/dashboard_docs.py +810 -0
- src/ctx/dashboard_entities.py +378 -0
- src/ctx/dashboard_graph.py +158 -0
- src/ctx_monitor.py +166 -1055
- src/harness_install.py +2 -1
- src/tests/test_ctx_monitor.py +41 -7
- src/tests/test_ctx_monitor_3type.py +3 -0
- src/tests/test_security_hardening.py +120 -0
- src/tests/test_update_repo_stats.py +5 -9
- src/tests/test_wiki_sync.py +3 -0
- src/toolbox.py +0 -16
- src/update_repo_stats.py +80 -0
README.md
CHANGED
|
@@ -18,7 +18,7 @@ tags:
|
|
| 18 |
[](LICENSE)
|
| 19 |
[](https://python.org)
|
| 20 |
[](https://pypi.org/project/claude-ctx/)
|
| 21 |
-
[](https://stevesolun.github.io/ctx/knowledge-graph/)
|
| 23 |
[](https://stevesolun.github.io/ctx/catalog/?type=skill)
|
| 24 |
[](https://stevesolun.github.io/ctx/catalog/?type=agent)
|
|
|
|
| 18 |
[](LICENSE)
|
| 19 |
[](https://python.org)
|
| 20 |
[](https://pypi.org/project/claude-ctx/)
|
| 21 |
+
[](https://github.com/stevesolun/ctx/actions/workflows/test.yml)
|
| 22 |
[](https://stevesolun.github.io/ctx/knowledge-graph/)
|
| 23 |
[](https://stevesolun.github.io/ctx/catalog/?type=skill)
|
| 24 |
[](https://stevesolun.github.io/ctx/catalog/?type=agent)
|
docs/catalog.md
CHANGED
|
@@ -44,6 +44,7 @@ It is public and always reachable. The full live catalog runs locally inside
|
|
| 44 |
<p id="ctx-catalog-count" class="ctx-catalog-muted"></p>
|
| 45 |
|
| 46 |
<div id="ctx-catalog-grid" class="ctx-catalog-grid">
|
|
|
|
| 47 |
<article class="ctx-catalog-card" data-type="skill" data-search="skill prompt workflow testing code review frontend backend security research">
|
| 48 |
<span class="ctx-catalog-pill">Skills</span>
|
| 49 |
<h3>Skills</h3>
|
|
@@ -149,6 +150,7 @@ It is public and always reachable. The full live catalog runs locally inside
|
|
| 149 |
<a class="md-button" href="./?type=harness&q=tool+access">Filter tiles</a>
|
| 150 |
<a class="md-button" href="../dashboard/#catalog-badge-links">Open full catalog locally</a>
|
| 151 |
</article>
|
|
|
|
| 152 |
</div>
|
| 153 |
</div>
|
| 154 |
|
|
|
|
| 44 |
<p id="ctx-catalog-count" class="ctx-catalog-muted"></p>
|
| 45 |
|
| 46 |
<div id="ctx-catalog-grid" class="ctx-catalog-grid">
|
| 47 |
+
<!-- ctx-catalog:begin -->
|
| 48 |
<article class="ctx-catalog-card" data-type="skill" data-search="skill prompt workflow testing code review frontend backend security research">
|
| 49 |
<span class="ctx-catalog-pill">Skills</span>
|
| 50 |
<h3>Skills</h3>
|
|
|
|
| 150 |
<a class="md-button" href="./?type=harness&q=tool+access">Filter tiles</a>
|
| 151 |
<a class="md-button" href="../dashboard/#catalog-badge-links">Open full catalog locally</a>
|
| 152 |
</article>
|
| 153 |
+
<!-- ctx-catalog:end -->
|
| 154 |
</div>
|
| 155 |
</div>
|
| 156 |
|
docs/dashboard.md
CHANGED
|
@@ -16,7 +16,8 @@ ctx-monitor serve --host 0.0.0.0 --port 8888 # LAN read-only with startup token
|
|
| 16 |
Zero Python dependencies added by the dashboard. Everything runs on
|
| 17 |
stdlib `http.server`, using daemon request threads so a live
|
| 18 |
`/api/events.stream` client cannot block normal dashboard or JSON API
|
| 19 |
-
requests. The graph page uses a built-in
|
|
|
|
| 20 |
third-party JavaScript.
|
| 21 |
|
| 22 |
## Usage
|
|
@@ -130,10 +131,12 @@ frontmatter values are visibly marked as truncated.
|
|
| 130 |
|
| 131 |
### Explore the knowledge graph — `/graph`
|
| 132 |
|
| 133 |
-
The graph tab is a
|
| 134 |
-
skill/agent/MCP/harness graph. Imported skills are normal
|
| 135 |
-
nodes in the graph. Harness nodes are browsable and filterable here;
|
| 136 |
-
install/update actions remain in `ctx-harness-install`.
|
|
|
|
|
|
|
| 137 |
When you arrive with no
|
| 138 |
slug selected, the page shows:
|
| 139 |
|
|
@@ -146,9 +149,9 @@ slug selected, the page shows:
|
|
| 146 |
- the graph list panel itself, which activates as soon as you pick a
|
| 147 |
seed
|
| 148 |
|
| 149 |
-
Inside the graph
|
| 150 |
-
|
| 151 |
-
|
| 152 |
|
| 153 |
The JSON endpoint still includes blended graph edge weights, combining
|
| 154 |
semantic similarity, explicit tag overlap, and slug-token overlap where
|
|
|
|
| 16 |
Zero Python dependencies added by the dashboard. Everything runs on
|
| 17 |
stdlib `http.server`, using daemon request threads so a live
|
| 18 |
`/api/events.stream` client cannot block normal dashboard or JSON API
|
| 19 |
+
requests. The graph page uses a built-in dependency-free SVG neighborhood
|
| 20 |
+
renderer with zoom, drag, hover details, and a list fallback. It does not load
|
| 21 |
third-party JavaScript.
|
| 22 |
|
| 23 |
## Usage
|
|
|
|
| 131 |
|
| 132 |
### Explore the knowledge graph — `/graph`
|
| 133 |
|
| 134 |
+
The graph tab is a dependency-free interactive SVG view over the
|
| 135 |
+
dashboard-supported skill/agent/MCP/harness graph. Imported skills are normal
|
| 136 |
+
`skill` nodes in the graph. Harness nodes are browsable and filterable here;
|
| 137 |
+
install/update actions remain in `ctx-harness-install`. If the SVG renderer
|
| 138 |
+
cannot initialize, the dashboard falls back to a list view over the same graph
|
| 139 |
+
payload.
|
| 140 |
When you arrive with no
|
| 141 |
slug selected, the page shows:
|
| 142 |
|
|
|
|
| 149 |
- the graph list panel itself, which activates as soon as you pick a
|
| 150 |
seed
|
| 151 |
|
| 152 |
+
Inside the graph view, entity pills identify the node type. The focus node has
|
| 153 |
+
`depth=0` in the page data, and neighbor rows remain filterable by entity type
|
| 154 |
+
and shared tag/token text.
|
| 155 |
|
| 156 |
The JSON endpoint still includes blended graph edge weights, combining
|
| 157 |
semantic similarity, explicit tag overlap, and slug-token overlap where
|
docs/index.md
CHANGED
|
@@ -202,7 +202,7 @@ ones are flagged. New ones self-ingest.
|
|
| 202 |
---
|
| 203 |
|
| 204 |
**v1.0.11** — MIT, CI-matrixed (Ubuntu 3.12 plus Windows/macOS 3.11/3.12),
|
| 205 |
-
4,
|
| 206 |
`ctx-monitor` (local dashboard with graph + wiki + load/unload for
|
| 207 |
skills, agents, and MCP servers, plus Harness Setup for user-owned LLMs),
|
| 208 |
`ctx-incremental-attach`, `ctx-incremental-shadow`, `ctx-dedup-check`
|
|
|
|
| 202 |
---
|
| 203 |
|
| 204 |
**v1.0.11** — MIT, CI-matrixed (Ubuntu 3.12 plus Windows/macOS 3.11/3.12),
|
| 205 |
+
4,058 tests collected. Ships console scripts including `ctx-init`,
|
| 206 |
`ctx-monitor` (local dashboard with graph + wiki + load/unload for
|
| 207 |
skills, agents, and MCP servers, plus Harness Setup for user-owned LLMs),
|
| 208 |
`ctx-incremental-attach`, `ctx-incremental-shadow`, `ctx-dedup-check`
|
docs/knowledge-graph.md
CHANGED
|
@@ -148,8 +148,8 @@ quality clusters for the recommendation use case.
|
|
| 148 |
ctx-monitor serve # http://127.0.0.1:8765
|
| 149 |
```
|
| 150 |
|
| 151 |
-
Then open `/graph?slug=<entity-slug>&type=<entity-type>` for
|
| 152 |
-
|
| 153 |
`/api/graph/<slug>.json?type=<entity-type>&hops=1&limit=40` for the
|
| 154 |
dashboard-shaped JSON. The `type` query is optional for unique slugs and
|
| 155 |
recommended for duplicate slugs such as `langgraph`. See the
|
|
|
|
| 148 |
ctx-monitor serve # http://127.0.0.1:8765
|
| 149 |
```
|
| 150 |
|
| 151 |
+
Then open `/graph?slug=<entity-slug>&type=<entity-type>` for the
|
| 152 |
+
dashboard's interactive SVG neighborhood view, or
|
| 153 |
`/api/graph/<slug>.json?type=<entity-type>&hops=1&limit=40` for the
|
| 154 |
dashboard-shaped JSON. The `type` query is optional for unique slugs and
|
| 155 |
recommended for duplicate slugs such as `langgraph`. See the
|
src/backup_mirror.py
CHANGED
|
@@ -212,12 +212,6 @@ def _iter_memory_files() -> Iterable[tuple[str, Path]]:
|
|
| 212 |
# ── Hashing + atomic copy ───────────────────────────────────────────────────
|
| 213 |
|
| 214 |
|
| 215 |
-
def _sha256_bytes(data: bytes) -> str:
|
| 216 |
-
h = hashlib.sha256()
|
| 217 |
-
h.update(data)
|
| 218 |
-
return h.hexdigest()
|
| 219 |
-
|
| 220 |
-
|
| 221 |
def _sha256_file(path: Path) -> str:
|
| 222 |
# Reject symlinks before reading so verify never hashes a file the
|
| 223 |
# attacker has pointed out of the snapshot.
|
|
|
|
| 212 |
# ── Hashing + atomic copy ───────────────────────────────────────────────────
|
| 213 |
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
def _sha256_file(path: Path) -> str:
|
| 216 |
# Reject symlinks before reading so verify never hashes a file the
|
| 217 |
# attacker has pointed out of the snapshot.
|
src/ctx/adapters/claude_code/install/mcp_install.py
CHANGED
|
@@ -223,6 +223,26 @@ def _json_config_card_summary(parsed_config: object) -> str:
|
|
| 223 |
return "; ".join(parts) if parts else "empty object"
|
| 224 |
|
| 225 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
@dataclass(frozen=True)
|
| 227 |
class InstallResult:
|
| 228 |
slug: str
|
|
@@ -415,8 +435,8 @@ def install_mcp(
|
|
| 415 |
|
| 416 |
if existing_status == "installed" and not force:
|
| 417 |
skipped_extra: dict[str, str] = {}
|
| 418 |
-
install_cmd = fm.get("install_cmd")
|
| 419 |
-
if
|
| 420 |
skipped_extra["command"] = install_cmd
|
| 421 |
record_install(
|
| 422 |
slug,
|
|
|
|
| 223 |
return "; ".join(parts) if parts else "empty object"
|
| 224 |
|
| 225 |
|
| 226 |
+
def _safe_manifest_command(command: object) -> str | None:
|
| 227 |
+
"""Return command text safe enough to persist in advisory manifests."""
|
| 228 |
+
if not isinstance(command, str) or not command.strip():
|
| 229 |
+
return None
|
| 230 |
+
try:
|
| 231 |
+
tokens = _split_install_command(command)
|
| 232 |
+
except ValueError:
|
| 233 |
+
return None
|
| 234 |
+
if not tokens:
|
| 235 |
+
return None
|
| 236 |
+
if _find_inline_secret_arg(tokens) is not None:
|
| 237 |
+
return None
|
| 238 |
+
executable = _normalized_executable(tokens[0])
|
| 239 |
+
if executable not in _ALLOWED_CMD_EXECS:
|
| 240 |
+
return None
|
| 241 |
+
if _rejects_banned_args(tokens) is not None:
|
| 242 |
+
return None
|
| 243 |
+
return _redact_output(command)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
@dataclass(frozen=True)
|
| 247 |
class InstallResult:
|
| 248 |
slug: str
|
|
|
|
| 435 |
|
| 436 |
if existing_status == "installed" and not force:
|
| 437 |
skipped_extra: dict[str, str] = {}
|
| 438 |
+
install_cmd = _safe_manifest_command(fm.get("install_cmd"))
|
| 439 |
+
if install_cmd:
|
| 440 |
skipped_extra["command"] = install_cmd
|
| 441 |
record_install(
|
| 442 |
slug,
|
src/ctx/adapters/generic/ctx_core_tools.py
CHANGED
|
@@ -40,7 +40,6 @@ from __future__ import annotations
|
|
| 40 |
import hashlib
|
| 41 |
import json
|
| 42 |
import logging
|
| 43 |
-
from dataclasses import dataclass
|
| 44 |
from pathlib import Path
|
| 45 |
from typing import Any, Callable
|
| 46 |
|
|
@@ -68,24 +67,6 @@ FileSignature = tuple[int, int, str]
|
|
| 68 |
GraphSignature = tuple[FileSignature | None, FileSignature | None]
|
| 69 |
|
| 70 |
|
| 71 |
-
@dataclass(frozen=True)
|
| 72 |
-
class BundleEntry:
|
| 73 |
-
"""One row of a recommendation result.
|
| 74 |
-
|
| 75 |
-
``score`` is the raw graph-walk weight; ``normalized_score`` is
|
| 76 |
-
that value divided by the top score in the same result set (so
|
| 77 |
-
the highest-ranked entry is always 1.0 — lets a caller apply a
|
| 78 |
-
0.0-1.0 cutoff without knowing the absolute graph scale).
|
| 79 |
-
"""
|
| 80 |
-
|
| 81 |
-
name: str
|
| 82 |
-
entity_type: str # 'skill' | 'agent' | 'mcp-server'
|
| 83 |
-
score: float
|
| 84 |
-
normalized_score: float
|
| 85 |
-
shared_tags: tuple[str, ...]
|
| 86 |
-
via: tuple[str, ...]
|
| 87 |
-
|
| 88 |
-
|
| 89 |
class CtxCoreToolbox:
|
| 90 |
"""ctx-core recommendation and lifecycle surface for harness tools.
|
| 91 |
|
|
|
|
| 40 |
import hashlib
|
| 41 |
import json
|
| 42 |
import logging
|
|
|
|
| 43 |
from pathlib import Path
|
| 44 |
from typing import Any, Callable
|
| 45 |
|
|
|
|
| 67 |
GraphSignature = tuple[FileSignature | None, FileSignature | None]
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
class CtxCoreToolbox:
|
| 71 |
"""ctx-core recommendation and lifecycle surface for harness tools.
|
| 72 |
|
src/ctx/core/entity_types.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
from pathlib import Path
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
ENTITY_TYPES: tuple[str, ...] = (
|
|
@@ -48,6 +49,48 @@ RELATED_SECTION_FOR_ENTITY_TYPE: dict[str, str] = {
|
|
| 48 |
"harness": "## Related Harnesses",
|
| 49 |
}
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
def mcp_shard(slug: str) -> str:
|
| 53 |
"""Return the shard segment for an MCP slug."""
|
|
@@ -71,6 +114,17 @@ def entity_page_path(wiki: Path, entity_type: str, slug: str) -> Path | None:
|
|
| 71 |
return wiki / relpath if relpath is not None else None
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
def entity_wikilink(entity_type: str, slug: str) -> str | None:
|
| 75 |
"""Return an Obsidian-style wikilink for an entity."""
|
| 76 |
relpath = entity_relpath(entity_type, slug)
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
from pathlib import Path
|
| 6 |
+
from typing import Collection
|
| 7 |
|
| 8 |
|
| 9 |
ENTITY_TYPES: tuple[str, ...] = (
|
|
|
|
| 49 |
"harness": "## Related Harnesses",
|
| 50 |
}
|
| 51 |
|
| 52 |
+
ENTITY_TYPE_ALIASES: dict[str, str] = {
|
| 53 |
+
"skills": "skill",
|
| 54 |
+
"skill": "skill",
|
| 55 |
+
"agents": "agent",
|
| 56 |
+
"agent": "agent",
|
| 57 |
+
"mcp": "mcp-server",
|
| 58 |
+
"mcp-server": "mcp-server",
|
| 59 |
+
"mcp-servers": "mcp-server",
|
| 60 |
+
"plugins": "plugin",
|
| 61 |
+
"plugin": "plugin",
|
| 62 |
+
"harness": "harness",
|
| 63 |
+
"harnesses": "harness",
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def normalize_entity_type(
|
| 68 |
+
raw: object,
|
| 69 |
+
*,
|
| 70 |
+
allowed: Collection[str] = ENTITY_TYPES,
|
| 71 |
+
) -> str | None:
|
| 72 |
+
"""Return a canonical entity type for user/API aliases."""
|
| 73 |
+
if raw is None:
|
| 74 |
+
return None
|
| 75 |
+
value = str(raw).strip()
|
| 76 |
+
if not value:
|
| 77 |
+
return None
|
| 78 |
+
normalized = ENTITY_TYPE_ALIASES.get(value.lower(), value)
|
| 79 |
+
return normalized if normalized in allowed else None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def entity_source_specs(
|
| 83 |
+
entity_types: Collection[str] = RECOMMENDABLE_ENTITY_TYPES,
|
| 84 |
+
) -> tuple[tuple[str, str, bool], ...]:
|
| 85 |
+
"""Return ``(subject_type, entity_type, recursive)`` wiki source specs."""
|
| 86 |
+
specs: list[tuple[str, str, bool]] = []
|
| 87 |
+
for entity_type in RECOMMENDABLE_ENTITY_TYPES:
|
| 88 |
+
if entity_type not in entity_types:
|
| 89 |
+
continue
|
| 90 |
+
subject_type = SUBJECT_TYPE_FOR_ENTITY_TYPE[entity_type]
|
| 91 |
+
specs.append((subject_type, entity_type, entity_type == "mcp-server"))
|
| 92 |
+
return tuple(specs)
|
| 93 |
+
|
| 94 |
|
| 95 |
def mcp_shard(slug: str) -> str:
|
| 96 |
"""Return the shard segment for an MCP slug."""
|
|
|
|
| 114 |
return wiki / relpath if relpath is not None else None
|
| 115 |
|
| 116 |
|
| 117 |
+
def entity_index_link(subject_type: str, slug: str) -> str | None:
|
| 118 |
+
"""Return the extensionless wiki index target for a subject/slug pair."""
|
| 119 |
+
entity_type = ENTITY_TYPE_FOR_SUBJECT_TYPE.get(subject_type)
|
| 120 |
+
if entity_type is None:
|
| 121 |
+
return None
|
| 122 |
+
if entity_type == "mcp-server" and not slug:
|
| 123 |
+
return f"entities/{subject_type}/{mcp_shard(slug)}/"
|
| 124 |
+
relpath = entity_relpath(entity_type, slug)
|
| 125 |
+
return relpath.with_suffix("").as_posix() if relpath is not None else None
|
| 126 |
+
|
| 127 |
+
|
| 128 |
def entity_wikilink(entity_type: str, slug: str) -> str | None:
|
| 129 |
"""Return an Obsidian-style wikilink for an entity."""
|
| 130 |
relpath = entity_relpath(entity_type, slug)
|
src/ctx/core/wiki/wiki_sync.py
CHANGED
|
@@ -19,6 +19,12 @@ import sys
|
|
| 19 |
from datetime import datetime, timezone
|
| 20 |
from pathlib import Path
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
from ctx.core.wiki.wiki_utils import SAFE_NAME_RE, get_field as _find_field
|
| 23 |
from ctx.utils._file_lock import file_lock
|
| 24 |
from ctx.utils._fs_utils import atomic_write_json, atomic_write_text
|
|
@@ -168,16 +174,8 @@ def _sanitize_yaml_value(value: str) -> str:
|
|
| 168 |
return sanitized.strip()
|
| 169 |
|
| 170 |
|
| 171 |
-
_SUBJECT_TYPE_FOR_ENTITY_TYPE
|
| 172 |
-
|
| 173 |
-
"agent": "agents",
|
| 174 |
-
"mcp-server": "mcp-servers",
|
| 175 |
-
"plugin": "plugins",
|
| 176 |
-
"harness": "harnesses",
|
| 177 |
-
}
|
| 178 |
-
_ENTITY_TYPE_FOR_SUBJECT_TYPE: dict[str, str] = {
|
| 179 |
-
value: key for key, value in _SUBJECT_TYPE_FOR_ENTITY_TYPE.items()
|
| 180 |
-
}
|
| 181 |
|
| 182 |
|
| 183 |
def _subject_type_for_manifest_entry(entry: dict) -> str:
|
|
@@ -303,18 +301,11 @@ Detected and loaded by skill-router.
|
|
| 303 |
|
| 304 |
|
| 305 |
|
| 306 |
-
# Section header used for each subject type in index.md.
|
| 307 |
-
#
|
| 308 |
-
#
|
| 309 |
-
# compat
|
| 310 |
-
|
| 311 |
-
_INDEX_SECTION_FOR_SUBJECT: dict[str, str] = {
|
| 312 |
-
"skills": "## Skills",
|
| 313 |
-
"agents": "## Agents",
|
| 314 |
-
"mcp-servers": "## MCP Servers",
|
| 315 |
-
"plugins": "## Plugins",
|
| 316 |
-
"harnesses": "## Harnesses",
|
| 317 |
-
}
|
| 318 |
|
| 319 |
|
| 320 |
def _entity_index_link(subject_type: str, slug: str) -> str:
|
|
@@ -325,11 +316,10 @@ def _entity_index_link(subject_type: str, slug: str) -> str:
|
|
| 325 |
to keep ``ls`` and Obsidian fast at the projected ~12k+ scale —
|
| 326 |
so their links must include the shard segment.
|
| 327 |
"""
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
return f"entities/{subject_type}/{slug}"
|
| 333 |
|
| 334 |
|
| 335 |
def update_index(
|
|
|
|
| 19 |
from datetime import datetime, timezone
|
| 20 |
from pathlib import Path
|
| 21 |
|
| 22 |
+
from ctx.core.entity_types import (
|
| 23 |
+
ENTITY_TYPE_FOR_SUBJECT_TYPE,
|
| 24 |
+
INDEX_SECTION_FOR_SUBJECT,
|
| 25 |
+
SUBJECT_TYPE_FOR_ENTITY_TYPE,
|
| 26 |
+
entity_index_link,
|
| 27 |
+
)
|
| 28 |
from ctx.core.wiki.wiki_utils import SAFE_NAME_RE, get_field as _find_field
|
| 29 |
from ctx.utils._file_lock import file_lock
|
| 30 |
from ctx.utils._fs_utils import atomic_write_json, atomic_write_text
|
|
|
|
| 174 |
return sanitized.strip()
|
| 175 |
|
| 176 |
|
| 177 |
+
_SUBJECT_TYPE_FOR_ENTITY_TYPE = SUBJECT_TYPE_FOR_ENTITY_TYPE
|
| 178 |
+
_ENTITY_TYPE_FOR_SUBJECT_TYPE = ENTITY_TYPE_FOR_SUBJECT_TYPE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
|
| 181 |
def _subject_type_for_manifest_entry(entry: dict) -> str:
|
|
|
|
| 301 |
|
| 302 |
|
| 303 |
|
| 304 |
+
# Section header used for each subject type in index.md. The canonical map
|
| 305 |
+
# lives in ctx.core.entity_types so wiki sync, graphify, and dashboard routing
|
| 306 |
+
# stay aligned. ``"skills"`` stays first so the default keyword arg for
|
| 307 |
+
# backward compat keeps writing into the same section it always did.
|
| 308 |
+
_INDEX_SECTION_FOR_SUBJECT = INDEX_SECTION_FOR_SUBJECT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
|
| 311 |
def _entity_index_link(subject_type: str, slug: str) -> str:
|
|
|
|
| 316 |
to keep ``ls`` and Obsidian fast at the projected ~12k+ scale —
|
| 317 |
so their links must include the shard segment.
|
| 318 |
"""
|
| 319 |
+
target = entity_index_link(subject_type, slug)
|
| 320 |
+
if target is None:
|
| 321 |
+
raise ValueError(f"unknown subject_type {subject_type!r}")
|
| 322 |
+
return target
|
|
|
|
| 323 |
|
| 324 |
|
| 325 |
def update_index(
|
src/ctx/dashboard_docs.py
ADDED
|
@@ -0,0 +1,810 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dashboard documentation rendering for ``ctx-monitor``.
|
| 2 |
+
|
| 3 |
+
This module owns the local docs index, MkDocs-flavored Markdown rendering,
|
| 4 |
+
sanitization, link rewriting, and docs HTML cache. ``ctx_monitor`` supplies
|
| 5 |
+
page chrome and asset readers; the docs implementation stays isolated here.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import hashlib
|
| 11 |
+
import html
|
| 12 |
+
import json
|
| 13 |
+
import re
|
| 14 |
+
from pathlib import Path, PurePosixPath
|
| 15 |
+
from typing import Any, Callable, Sequence
|
| 16 |
+
from urllib.parse import quote, unquote
|
| 17 |
+
|
| 18 |
+
from ctx.utils._fs_utils import atomic_write_text as _atomic_write_text
|
| 19 |
+
|
| 20 |
+
PUBLIC_DOCS_URL = "https://stevesolun.github.io/ctx/"
|
| 21 |
+
|
| 22 |
+
LayoutRenderer = Callable[[str, str], str]
|
| 23 |
+
AssetReader = Callable[[str], str]
|
| 24 |
+
InlineScriptRenderer = Callable[[str], str]
|
| 25 |
+
MarkdownRenderer = Callable[[str, str], str]
|
| 26 |
+
FallbackMarkdownRenderer = Callable[[str], str]
|
| 27 |
+
|
| 28 |
+
_DOCS_RENDER_CACHE_KEY: tuple[Any, ...] | None = None
|
| 29 |
+
_DOCS_RENDER_CACHE_VALUE: str | None = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def reset_docs_render_cache() -> None:
|
| 33 |
+
"""Clear the in-process docs render cache."""
|
| 34 |
+
global _DOCS_RENDER_CACHE_KEY, _DOCS_RENDER_CACHE_VALUE
|
| 35 |
+
_DOCS_RENDER_CACHE_KEY = None
|
| 36 |
+
_DOCS_RENDER_CACHE_VALUE = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _truncate_text(value: str, limit: int) -> tuple[str, bool]:
|
| 40 |
+
if limit <= 0 or len(value) <= limit:
|
| 41 |
+
return value, False
|
| 42 |
+
if limit <= 3:
|
| 43 |
+
return value[:limit], True
|
| 44 |
+
return value[: limit - 3].rstrip() + "...", True
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _slugish(value: str) -> str:
|
| 48 |
+
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def docs_roots(current_dir: Path, package_root: Path) -> list[Path]:
|
| 52 |
+
roots: list[Path] = []
|
| 53 |
+
for root in (current_dir, package_root):
|
| 54 |
+
if root not in roots and (root / "docs").is_dir():
|
| 55 |
+
roots.append(root)
|
| 56 |
+
return roots
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def docs_cache_files(roots: Sequence[Path]) -> list[Path]:
|
| 60 |
+
files: list[Path] = []
|
| 61 |
+
seen: set[Path] = set()
|
| 62 |
+
for root in roots:
|
| 63 |
+
candidates = [root / "README.md", root / "graph" / "README.md", root / "mkdocs.yml"]
|
| 64 |
+
docs_dir = root / "docs"
|
| 65 |
+
if docs_dir.is_dir():
|
| 66 |
+
candidates.extend(sorted(docs_dir.rglob("*.md")))
|
| 67 |
+
for path in candidates:
|
| 68 |
+
if not path.is_file():
|
| 69 |
+
continue
|
| 70 |
+
try:
|
| 71 |
+
resolved = path.resolve()
|
| 72 |
+
except OSError:
|
| 73 |
+
resolved = path
|
| 74 |
+
if resolved in seen:
|
| 75 |
+
continue
|
| 76 |
+
seen.add(resolved)
|
| 77 |
+
files.append(path)
|
| 78 |
+
return files
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def docs_render_cache_key(
|
| 82 |
+
roots: Sequence[Path],
|
| 83 |
+
*,
|
| 84 |
+
asset_text: AssetReader,
|
| 85 |
+
cache_salt_paths: Sequence[Path] = (),
|
| 86 |
+
cache_extra: Sequence[Any] = (),
|
| 87 |
+
) -> tuple[Any, ...]:
|
| 88 |
+
parts: list[Any] = []
|
| 89 |
+
for path in (Path(__file__), *cache_salt_paths):
|
| 90 |
+
try:
|
| 91 |
+
stat = path.stat()
|
| 92 |
+
parts.append(("source", str(path.resolve()), stat.st_mtime_ns, stat.st_size))
|
| 93 |
+
except OSError:
|
| 94 |
+
parts.append(("source", str(path), None, None))
|
| 95 |
+
for item in cache_extra:
|
| 96 |
+
parts.append(("extra", repr(item)))
|
| 97 |
+
for asset_name in ("monitor.css", "monitor-docs.js"):
|
| 98 |
+
try:
|
| 99 |
+
asset_hash = hashlib.sha256(asset_text(asset_name).encode("utf-8")).hexdigest()
|
| 100 |
+
except Exception:
|
| 101 |
+
asset_hash = ""
|
| 102 |
+
parts.append(("asset", asset_name, asset_hash))
|
| 103 |
+
for path in docs_cache_files(roots):
|
| 104 |
+
try:
|
| 105 |
+
stat = path.stat()
|
| 106 |
+
path_name = str(path.resolve())
|
| 107 |
+
parts.append((path_name, stat.st_mtime_ns, stat.st_size))
|
| 108 |
+
except OSError:
|
| 109 |
+
continue
|
| 110 |
+
return tuple(parts)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def docs_render_disk_cache_path(claude_dir: Path) -> Path:
|
| 114 |
+
return claude_dir / ".ctx-monitor-docs-cache.json"
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _disk_cache_token(cache_key: tuple[Any, ...]) -> str:
|
| 118 |
+
return json.dumps(cache_key, separators=(",", ":"), sort_keys=True)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _read_disk_cache_payload(path: Path, cache_token: str) -> dict[str, Any] | None:
|
| 122 |
+
try:
|
| 123 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 124 |
+
except (OSError, json.JSONDecodeError):
|
| 125 |
+
return None
|
| 126 |
+
if not isinstance(data, dict):
|
| 127 |
+
return None
|
| 128 |
+
if data.get("schema_version") != 1 or data.get("cache_token") != cache_token:
|
| 129 |
+
return None
|
| 130 |
+
return data
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _write_disk_cache_payload(
|
| 134 |
+
path: Path,
|
| 135 |
+
cache_token: str,
|
| 136 |
+
payload: dict[str, Any],
|
| 137 |
+
*,
|
| 138 |
+
sort_keys: bool = False,
|
| 139 |
+
) -> None:
|
| 140 |
+
try:
|
| 141 |
+
_atomic_write_text(
|
| 142 |
+
path,
|
| 143 |
+
json.dumps(
|
| 144 |
+
{
|
| 145 |
+
"schema_version": 1,
|
| 146 |
+
"cache_token": cache_token,
|
| 147 |
+
**payload,
|
| 148 |
+
},
|
| 149 |
+
ensure_ascii=False,
|
| 150 |
+
sort_keys=sort_keys,
|
| 151 |
+
) + "\n",
|
| 152 |
+
encoding="utf-8",
|
| 153 |
+
)
|
| 154 |
+
except (OSError, TypeError, ValueError):
|
| 155 |
+
return
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _read_html_disk_cache(path: Path, cache_token: str) -> str | None:
|
| 159 |
+
data = _read_disk_cache_payload(path, cache_token)
|
| 160 |
+
if data is None:
|
| 161 |
+
return None
|
| 162 |
+
html_text = data.get("html")
|
| 163 |
+
return html_text if isinstance(html_text, str) else None
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _write_html_disk_cache(path: Path, cache_token: str, html_text: str) -> None:
|
| 167 |
+
_write_disk_cache_payload(path, cache_token, {"html": html_text})
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def doc_title(text: str, fallback: str) -> str:
|
| 171 |
+
for line in text.splitlines():
|
| 172 |
+
match = re.match(r"^#\s+(.+?)\s*$", line)
|
| 173 |
+
if match:
|
| 174 |
+
return match.group(1).strip()
|
| 175 |
+
return fallback
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def doc_summary(text: str) -> str:
|
| 179 |
+
in_frontmatter = text.startswith("---\n")
|
| 180 |
+
for block in re.split(r"\n\s*\n", text):
|
| 181 |
+
chunk = block.strip()
|
| 182 |
+
if not chunk:
|
| 183 |
+
continue
|
| 184 |
+
if in_frontmatter:
|
| 185 |
+
if chunk == "---" or chunk.endswith("\n---"):
|
| 186 |
+
in_frontmatter = False
|
| 187 |
+
continue
|
| 188 |
+
if chunk.startswith("#") or chunk.startswith("```") or chunk.startswith("<!--"):
|
| 189 |
+
continue
|
| 190 |
+
summary = re.sub(r"\s+", " ", chunk)
|
| 191 |
+
summary, _truncated = _truncate_text(summary, 180)
|
| 192 |
+
return summary
|
| 193 |
+
return ""
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def strip_doc_frontmatter(text: str) -> str:
|
| 197 |
+
if not text.startswith("---\n"):
|
| 198 |
+
return text
|
| 199 |
+
parts = text.split("---", 2)
|
| 200 |
+
return parts[2].lstrip() if len(parts) == 3 else text
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def doc_anchor(value: str) -> str:
|
| 204 |
+
return _slugish(value) or "docs"
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def docs_index_entries(roots: Sequence[Path]) -> list[dict[str, Any]]:
|
| 208 |
+
entries: list[dict[str, Any]] = []
|
| 209 |
+
seen: set[str] = set()
|
| 210 |
+
for root in roots:
|
| 211 |
+
candidates = [root / "README.md", root / "graph" / "README.md"]
|
| 212 |
+
docs_dir = root / "docs"
|
| 213 |
+
if docs_dir.is_dir():
|
| 214 |
+
candidates.extend(sorted(docs_dir.rglob("*.md")))
|
| 215 |
+
for path in candidates:
|
| 216 |
+
if not path.is_file():
|
| 217 |
+
continue
|
| 218 |
+
rel = path.relative_to(root).as_posix()
|
| 219 |
+
if rel == "docs/SKILL.md":
|
| 220 |
+
continue
|
| 221 |
+
if rel in seen:
|
| 222 |
+
continue
|
| 223 |
+
seen.add(rel)
|
| 224 |
+
try:
|
| 225 |
+
text = path.read_text(encoding="utf-8", errors="replace")
|
| 226 |
+
except OSError:
|
| 227 |
+
continue
|
| 228 |
+
text = strip_doc_frontmatter(text)
|
| 229 |
+
title = doc_title(text, path.stem.replace("-", " ").title())
|
| 230 |
+
entries.append({
|
| 231 |
+
"title": title,
|
| 232 |
+
"path": rel,
|
| 233 |
+
"summary": doc_summary(text),
|
| 234 |
+
"body": text,
|
| 235 |
+
})
|
| 236 |
+
return sorted(entries, key=lambda row: str(row["path"]))
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def docs_nav_from_mkdocs(
|
| 240 |
+
root: Path,
|
| 241 |
+
entries_by_path: dict[str, dict[str, Any]],
|
| 242 |
+
) -> list[dict[str, Any]]:
|
| 243 |
+
mkdocs_path = root / "mkdocs.yml"
|
| 244 |
+
if not mkdocs_path.is_file():
|
| 245 |
+
return []
|
| 246 |
+
try:
|
| 247 |
+
import yaml # type: ignore[import-untyped]
|
| 248 |
+
|
| 249 |
+
data = yaml.load(mkdocs_path.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) or {}
|
| 250 |
+
except Exception:
|
| 251 |
+
return []
|
| 252 |
+
raw_nav = data.get("nav")
|
| 253 |
+
if not isinstance(raw_nav, list):
|
| 254 |
+
return []
|
| 255 |
+
|
| 256 |
+
def pages_from_nav(value: Any) -> list[tuple[str, str]]:
|
| 257 |
+
if isinstance(value, str):
|
| 258 |
+
return [(Path(value).stem.replace("-", " ").title(), f"docs/{value}")]
|
| 259 |
+
if isinstance(value, list):
|
| 260 |
+
pages: list[tuple[str, str]] = []
|
| 261 |
+
for child in value:
|
| 262 |
+
pages.extend(pages_from_nav(child))
|
| 263 |
+
return pages
|
| 264 |
+
if isinstance(value, dict):
|
| 265 |
+
pages = []
|
| 266 |
+
for label, child in value.items():
|
| 267 |
+
if isinstance(child, str):
|
| 268 |
+
pages.append((str(label), f"docs/{child}"))
|
| 269 |
+
else:
|
| 270 |
+
pages.extend(pages_from_nav(child))
|
| 271 |
+
return pages
|
| 272 |
+
return []
|
| 273 |
+
|
| 274 |
+
tabs: list[dict[str, Any]] = []
|
| 275 |
+
for item in raw_nav:
|
| 276 |
+
if not isinstance(item, dict):
|
| 277 |
+
continue
|
| 278 |
+
for label, value in item.items():
|
| 279 |
+
pages = [
|
| 280 |
+
{**entries_by_path[path], "nav_title": page_label}
|
| 281 |
+
for page_label, path in pages_from_nav(value)
|
| 282 |
+
if path in entries_by_path
|
| 283 |
+
]
|
| 284 |
+
if pages:
|
| 285 |
+
tabs.append({
|
| 286 |
+
"label": str(label),
|
| 287 |
+
"slug": doc_anchor(str(label)),
|
| 288 |
+
"pages": pages,
|
| 289 |
+
})
|
| 290 |
+
return tabs
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def docs_tabs(entries: list[dict[str, Any]], roots: Sequence[Path]) -> list[dict[str, Any]]:
|
| 294 |
+
entries_by_path = {str(entry["path"]): entry for entry in entries}
|
| 295 |
+
tabs: list[dict[str, Any]] = []
|
| 296 |
+
used: set[str] = set()
|
| 297 |
+
for root in roots:
|
| 298 |
+
tabs = docs_nav_from_mkdocs(root, entries_by_path)
|
| 299 |
+
if tabs:
|
| 300 |
+
break
|
| 301 |
+
for tab in tabs:
|
| 302 |
+
for page in tab["pages"]:
|
| 303 |
+
used.add(str(page["path"]))
|
| 304 |
+
|
| 305 |
+
repo_pages = [
|
| 306 |
+
entries_by_path[path]
|
| 307 |
+
for path in ("README.md", "graph/README.md")
|
| 308 |
+
if path in entries_by_path
|
| 309 |
+
]
|
| 310 |
+
if repo_pages:
|
| 311 |
+
tabs.append({"label": "Repo", "slug": "repo", "pages": repo_pages})
|
| 312 |
+
used.update(str(page["path"]) for page in repo_pages)
|
| 313 |
+
|
| 314 |
+
other_pages = [
|
| 315 |
+
entry
|
| 316 |
+
for entry in entries
|
| 317 |
+
if entry["path"] not in used and entry["path"] != "docs/SKILL.md"
|
| 318 |
+
]
|
| 319 |
+
if other_pages:
|
| 320 |
+
tabs.append({"label": "Other", "slug": "other", "pages": other_pages})
|
| 321 |
+
if not tabs and entries:
|
| 322 |
+
tabs.append({"label": "Docs", "slug": "docs", "pages": entries})
|
| 323 |
+
return tabs
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def docs_heading_text(raw: str) -> str:
|
| 327 |
+
raw = re.sub(r"\s+\{#[^}]+\}\s*$", "", raw.strip())
|
| 328 |
+
raw = re.sub(r"`([^`]+)`", r"\1", raw)
|
| 329 |
+
raw = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", raw)
|
| 330 |
+
raw = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", raw)
|
| 331 |
+
raw = re.sub(r"[*_~]+", "", raw)
|
| 332 |
+
return re.sub(r"\s+", " ", raw).strip()
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def docs_heading_id(page_anchor: str, title: str, seen: dict[str, int]) -> str:
|
| 336 |
+
base = f"{page_anchor}-{doc_anchor(title)}"
|
| 337 |
+
count = seen.get(base, 0)
|
| 338 |
+
seen[base] = count + 1
|
| 339 |
+
return base if count == 0 else f"{base}_{count}"
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def docs_heading_items(markdown_text: str, page_anchor: str) -> list[dict[str, Any]]:
|
| 343 |
+
headings: list[dict[str, Any]] = []
|
| 344 |
+
seen: dict[str, int] = {}
|
| 345 |
+
in_fence = False
|
| 346 |
+
for line in markdown_text.splitlines():
|
| 347 |
+
stripped = line.strip()
|
| 348 |
+
if stripped.startswith(("```", "~~~")):
|
| 349 |
+
in_fence = not in_fence
|
| 350 |
+
continue
|
| 351 |
+
if in_fence:
|
| 352 |
+
continue
|
| 353 |
+
match = re.match(r"^(#{1,4})\s+(.+?)\s*#*\s*$", line)
|
| 354 |
+
if not match:
|
| 355 |
+
continue
|
| 356 |
+
level = len(match.group(1))
|
| 357 |
+
title = docs_heading_text(match.group(2))
|
| 358 |
+
if not title:
|
| 359 |
+
continue
|
| 360 |
+
heading_id = docs_heading_id(page_anchor, title, seen)
|
| 361 |
+
if level >= 2:
|
| 362 |
+
headings.append({"level": level, "title": title, "id": heading_id})
|
| 363 |
+
return headings
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def docs_page_anchor(tab_slug: str, path: str) -> str:
|
| 367 |
+
return f"doc-{tab_slug}-{doc_anchor(path)}"
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def normalise_doc_path(path: str) -> str:
|
| 371 |
+
parts: list[str] = []
|
| 372 |
+
for part in PurePosixPath(path).parts:
|
| 373 |
+
if part in ("", "."):
|
| 374 |
+
continue
|
| 375 |
+
if part == "..":
|
| 376 |
+
if parts:
|
| 377 |
+
parts.pop()
|
| 378 |
+
continue
|
| 379 |
+
parts.append(part)
|
| 380 |
+
return PurePosixPath(*parts).as_posix() if parts else ""
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def resolve_docs_link_path(
|
| 384 |
+
source_path: str,
|
| 385 |
+
href_path: str,
|
| 386 |
+
page_anchors: dict[str, tuple[str, str]],
|
| 387 |
+
) -> str | None:
|
| 388 |
+
base = PurePosixPath(source_path).parent
|
| 389 |
+
resolved = normalise_doc_path((base / href_path).as_posix())
|
| 390 |
+
candidates = [resolved]
|
| 391 |
+
if not resolved.endswith(".md"):
|
| 392 |
+
stripped = resolved.rstrip("/")
|
| 393 |
+
candidates.extend([f"{stripped}.md", f"{stripped}/index.md"])
|
| 394 |
+
for candidate in candidates:
|
| 395 |
+
if candidate in page_anchors:
|
| 396 |
+
return candidate
|
| 397 |
+
return None
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def docs_dashboard_link(
|
| 401 |
+
source_path: str,
|
| 402 |
+
source_tab: str,
|
| 403 |
+
source_anchor: str,
|
| 404 |
+
href: str,
|
| 405 |
+
page_anchors: dict[str, tuple[str, str]],
|
| 406 |
+
) -> tuple[str, str, str] | None:
|
| 407 |
+
href = html.unescape(href).strip()
|
| 408 |
+
lowered = href.lower()
|
| 409 |
+
if (
|
| 410 |
+
not href
|
| 411 |
+
or lowered.startswith(("http://", "https://", "mailto:", "tel:", "javascript:"))
|
| 412 |
+
or href.startswith("/")
|
| 413 |
+
):
|
| 414 |
+
return None
|
| 415 |
+
href_path, sep, fragment = href.partition("#")
|
| 416 |
+
if not href_path:
|
| 417 |
+
target_tab = source_tab
|
| 418 |
+
target_anchor = source_anchor
|
| 419 |
+
else:
|
| 420 |
+
resolved_path = resolve_docs_link_path(source_path, href_path, page_anchors)
|
| 421 |
+
if resolved_path is None:
|
| 422 |
+
return None
|
| 423 |
+
target_tab, target_anchor = page_anchors[resolved_path]
|
| 424 |
+
if sep and fragment:
|
| 425 |
+
fragment_anchor = doc_anchor(unquote(fragment))
|
| 426 |
+
if not fragment_anchor.startswith(target_anchor):
|
| 427 |
+
fragment_anchor = f"{target_anchor}-{fragment_anchor}"
|
| 428 |
+
return f"#{fragment_anchor}", target_tab, fragment_anchor
|
| 429 |
+
return f"#{target_anchor}", target_tab, target_anchor
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def rewrite_docs_links(
|
| 433 |
+
rendered_html: str,
|
| 434 |
+
source_path: str,
|
| 435 |
+
source_tab: str,
|
| 436 |
+
source_anchor: str,
|
| 437 |
+
page_anchors: dict[str, tuple[str, str]],
|
| 438 |
+
) -> str:
|
| 439 |
+
def replace_link(match: re.Match[str]) -> str:
|
| 440 |
+
before = match.group(1)
|
| 441 |
+
href = match.group(2)
|
| 442 |
+
after = match.group(3)
|
| 443 |
+
resolved = docs_dashboard_link(source_path, source_tab, source_anchor, href, page_anchors)
|
| 444 |
+
if resolved is None:
|
| 445 |
+
return match.group(0)
|
| 446 |
+
dashboard_href, target_tab, target_anchor = resolved
|
| 447 |
+
return (
|
| 448 |
+
f"<a{before}href=\"{html.escape(dashboard_href, quote=True)}\""
|
| 449 |
+
f" data-doc-tab=\"{html.escape(target_tab, quote=True)}\""
|
| 450 |
+
f" data-doc-target=\"{html.escape(target_anchor, quote=True)}\"{after}>"
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
return re.sub(r"<a([^>]*?)href=\"([^\"]+)\"([^>]*)>", replace_link, rendered_html)
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def render_docs_markdown(
|
| 457 |
+
markdown_text: str,
|
| 458 |
+
page_anchor: str,
|
| 459 |
+
*,
|
| 460 |
+
fallback_renderer: FallbackMarkdownRenderer,
|
| 461 |
+
) -> str:
|
| 462 |
+
"""Render repo docs with MkDocs-like Markdown support when available."""
|
| 463 |
+
markdown_text = re.sub(r":octicons-arrow-right-24:", "->", markdown_text)
|
| 464 |
+
markdown_text = re.sub(r":octicons-[a-z0-9-]+:", "", markdown_text)
|
| 465 |
+
try:
|
| 466 |
+
import markdown as markdown_lib # type: ignore[import-untyped]
|
| 467 |
+
|
| 468 |
+
rendered = str(markdown_lib.markdown(
|
| 469 |
+
markdown_text,
|
| 470 |
+
extensions=[
|
| 471 |
+
"admonition",
|
| 472 |
+
"attr_list",
|
| 473 |
+
"def_list",
|
| 474 |
+
"fenced_code",
|
| 475 |
+
"footnotes",
|
| 476 |
+
"md_in_html",
|
| 477 |
+
"tables",
|
| 478 |
+
"toc",
|
| 479 |
+
"pymdownx.details",
|
| 480 |
+
"pymdownx.superfences",
|
| 481 |
+
"pymdownx.tabbed",
|
| 482 |
+
"pymdownx.tasklist",
|
| 483 |
+
"pymdownx.inlinehilite",
|
| 484 |
+
],
|
| 485 |
+
extension_configs={
|
| 486 |
+
"toc": {
|
| 487 |
+
"permalink": True,
|
| 488 |
+
"slugify": lambda value, separator: f"{page_anchor}-{doc_anchor(value)}",
|
| 489 |
+
},
|
| 490 |
+
"pymdownx.tabbed": {"alternate_style": True},
|
| 491 |
+
"pymdownx.tasklist": {"custom_checkbox": True},
|
| 492 |
+
},
|
| 493 |
+
output_format="html5",
|
| 494 |
+
))
|
| 495 |
+
return sanitize_docs_html(rendered)
|
| 496 |
+
except Exception:
|
| 497 |
+
return fallback_renderer(markdown_text)
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
def sanitize_docs_html(rendered_html: str) -> str:
|
| 501 |
+
"""Remove active HTML from local docs before embedding in the dashboard."""
|
| 502 |
+
dangerous_blocks = (
|
| 503 |
+
"script",
|
| 504 |
+
"style",
|
| 505 |
+
"iframe",
|
| 506 |
+
"object",
|
| 507 |
+
"embed",
|
| 508 |
+
"form",
|
| 509 |
+
"textarea",
|
| 510 |
+
"select",
|
| 511 |
+
)
|
| 512 |
+
dangerous_tags = (
|
| 513 |
+
"base",
|
| 514 |
+
"button",
|
| 515 |
+
"input",
|
| 516 |
+
"link",
|
| 517 |
+
"meta",
|
| 518 |
+
)
|
| 519 |
+
|
| 520 |
+
def escape_match(match: re.Match[str]) -> str:
|
| 521 |
+
return html.escape(match.group(0))
|
| 522 |
+
|
| 523 |
+
def parse_attrs(tag_html: str) -> dict[str, str | None] | None:
|
| 524 |
+
attrs: dict[str, str | None] = {}
|
| 525 |
+
for match in re.finditer(
|
| 526 |
+
r"([a-zA-Z_:][\w:.-]*)(?:\s*=\s*(\"[^\"]*\"|'[^']*'|[^\s\"'>]+))?",
|
| 527 |
+
tag_html,
|
| 528 |
+
):
|
| 529 |
+
name = match.group(1).lower()
|
| 530 |
+
if name == "input":
|
| 531 |
+
continue
|
| 532 |
+
if name in attrs:
|
| 533 |
+
return None
|
| 534 |
+
raw_value = match.group(2)
|
| 535 |
+
if raw_value is None:
|
| 536 |
+
attrs[name] = None
|
| 537 |
+
continue
|
| 538 |
+
value = raw_value.strip()
|
| 539 |
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
| 540 |
+
value = value[1:-1]
|
| 541 |
+
attrs[name] = html.unescape(value)
|
| 542 |
+
return attrs
|
| 543 |
+
|
| 544 |
+
def is_safe_tabbed_input(tag_html: str) -> bool:
|
| 545 |
+
attrs = parse_attrs(tag_html)
|
| 546 |
+
if attrs is None:
|
| 547 |
+
return False
|
| 548 |
+
if set(attrs) - {"checked", "id", "name", "type"}:
|
| 549 |
+
return False
|
| 550 |
+
input_type = (attrs.get("type") or "").lower()
|
| 551 |
+
input_id = attrs.get("id") or ""
|
| 552 |
+
input_name = attrs.get("name") or ""
|
| 553 |
+
if input_type != "radio":
|
| 554 |
+
return False
|
| 555 |
+
if not re.fullmatch(r"__tabbed_\d+_\d+", input_id):
|
| 556 |
+
return False
|
| 557 |
+
if not re.fullmatch(r"__tabbed_\d+", input_name):
|
| 558 |
+
return False
|
| 559 |
+
if not input_id.startswith(f"{input_name}_"):
|
| 560 |
+
return False
|
| 561 |
+
checked = attrs.get("checked")
|
| 562 |
+
return checked is None or checked.lower() == "checked"
|
| 563 |
+
|
| 564 |
+
def escape_input_unless_safe(match: re.Match[str]) -> str:
|
| 565 |
+
tag_html = match.group(0)
|
| 566 |
+
if is_safe_tabbed_input(tag_html):
|
| 567 |
+
return tag_html
|
| 568 |
+
return html.escape(tag_html)
|
| 569 |
+
|
| 570 |
+
for tag in dangerous_blocks:
|
| 571 |
+
rendered_html = re.sub(
|
| 572 |
+
rf"<\s*{tag}\b[^>]*>.*?<\s*/\s*{tag}\s*>",
|
| 573 |
+
escape_match,
|
| 574 |
+
rendered_html,
|
| 575 |
+
flags=re.IGNORECASE | re.DOTALL,
|
| 576 |
+
)
|
| 577 |
+
rendered_html = re.sub(
|
| 578 |
+
rf"<\s*/?\s*{tag}\b[^>]*>",
|
| 579 |
+
escape_match,
|
| 580 |
+
rendered_html,
|
| 581 |
+
flags=re.IGNORECASE,
|
| 582 |
+
)
|
| 583 |
+
for tag in dangerous_tags:
|
| 584 |
+
if tag == "input":
|
| 585 |
+
rendered_html = re.sub(
|
| 586 |
+
rf"<\s*/?\s*{tag}\b[^>]*>",
|
| 587 |
+
escape_input_unless_safe,
|
| 588 |
+
rendered_html,
|
| 589 |
+
flags=re.IGNORECASE,
|
| 590 |
+
)
|
| 591 |
+
continue
|
| 592 |
+
rendered_html = re.sub(
|
| 593 |
+
rf"<\s*/?\s*{tag}\b[^>]*>",
|
| 594 |
+
escape_match,
|
| 595 |
+
rendered_html,
|
| 596 |
+
flags=re.IGNORECASE,
|
| 597 |
+
)
|
| 598 |
+
|
| 599 |
+
rendered_html = re.sub(
|
| 600 |
+
r"\s+on[a-zA-Z0-9_-]+\s*=\s*(\"[^\"]*\"|'[^']*'|[^\s>]+)",
|
| 601 |
+
"",
|
| 602 |
+
rendered_html,
|
| 603 |
+
flags=re.IGNORECASE,
|
| 604 |
+
)
|
| 605 |
+
rendered_html = re.sub(
|
| 606 |
+
r"\s+(href|src)\s*=\s*(?:\"\s*(?:javascript:|data:text/html)[^\"]*\"|'\s*(?:javascript:|data:text/html)[^']*'|(?:javascript:|data:text/html)[^\s>]*)",
|
| 607 |
+
lambda match: f' {match.group(1)}="#"',
|
| 608 |
+
rendered_html,
|
| 609 |
+
flags=re.IGNORECASE,
|
| 610 |
+
)
|
| 611 |
+
return rendered_html
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
def docs_search_text(entry: dict[str, Any]) -> str:
|
| 615 |
+
text = f"{entry['title']} {entry['path']} {entry['summary']} {entry['body']}"
|
| 616 |
+
text = re.sub(r"```.*?```", " ", text, flags=re.DOTALL)
|
| 617 |
+
text = re.sub(r"!!!\s+\w+(?:\s+\"[^\"]+\")?", " ", text)
|
| 618 |
+
text = re.sub(r"<[^>]+>", " ", text)
|
| 619 |
+
text = re.sub(r"[*_`#>\[\]().!:-]+", " ", text)
|
| 620 |
+
return re.sub(r"\s+", " ", text).strip().lower()
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
def render_docs_sidebar_page(
|
| 624 |
+
entry: dict[str, Any],
|
| 625 |
+
tab_slug: str,
|
| 626 |
+
page_anchors: dict[str, tuple[str, str]],
|
| 627 |
+
) -> str:
|
| 628 |
+
page_anchor = page_anchors.get(
|
| 629 |
+
str(entry["path"]),
|
| 630 |
+
(tab_slug, docs_page_anchor(tab_slug, str(entry["path"]))),
|
| 631 |
+
)[1]
|
| 632 |
+
title = str(entry.get("nav_title") or entry["title"])
|
| 633 |
+
page_search = docs_search_text(entry)
|
| 634 |
+
heading_links = "".join(
|
| 635 |
+
"<a class='docs-heading-link "
|
| 636 |
+
f"docs-heading-level-{int(heading['level'])}' "
|
| 637 |
+
f"href='#{html.escape(str(heading['id']))}' "
|
| 638 |
+
f"data-doc-link data-doc-tab='{html.escape(tab_slug)}' "
|
| 639 |
+
f"data-doc-target='{html.escape(str(heading['id']))}' "
|
| 640 |
+
f"data-doc-search='{html.escape(str(heading['title']).lower())}' "
|
| 641 |
+
f"data-doc-label='{html.escape(title)} / {html.escape(str(heading['title']))}'>"
|
| 642 |
+
f"{html.escape(str(heading['title']))}</a>"
|
| 643 |
+
for heading in docs_heading_items(str(entry["body"]), page_anchor)
|
| 644 |
+
)
|
| 645 |
+
headings = f"<div class='docs-heading-list'>{heading_links}</div>" if heading_links else ""
|
| 646 |
+
return (
|
| 647 |
+
"<div class='docs-toc-page'>"
|
| 648 |
+
f"<a class='docs-page-link' href='#{html.escape(page_anchor)}' "
|
| 649 |
+
f"data-doc-link data-doc-tab='{html.escape(tab_slug)}' "
|
| 650 |
+
f"data-doc-target='{html.escape(page_anchor)}' "
|
| 651 |
+
f"data-doc-search='{html.escape(page_search)}' "
|
| 652 |
+
f"data-doc-label='{html.escape(title)}'>{html.escape(title)}</a>"
|
| 653 |
+
f"{headings}"
|
| 654 |
+
"</div>"
|
| 655 |
+
)
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
def render_docs_page(
|
| 659 |
+
entry: dict[str, Any],
|
| 660 |
+
tab_slug: str,
|
| 661 |
+
page_anchors: dict[str, tuple[str, str]],
|
| 662 |
+
*,
|
| 663 |
+
render_markdown_func: MarkdownRenderer,
|
| 664 |
+
) -> str:
|
| 665 |
+
page_anchor = page_anchors.get(
|
| 666 |
+
str(entry["path"]),
|
| 667 |
+
(tab_slug, docs_page_anchor(tab_slug, str(entry["path"]))),
|
| 668 |
+
)[1]
|
| 669 |
+
source_url = f"https://github.com/stevesolun/ctx/blob/main/{quote(str(entry['path']))}"
|
| 670 |
+
body_html = render_markdown_func(str(entry["body"]), page_anchor)
|
| 671 |
+
body_html = rewrite_docs_links(body_html, str(entry["path"]), tab_slug, page_anchor, page_anchors)
|
| 672 |
+
return (
|
| 673 |
+
f"<article id='{html.escape(page_anchor)}' class='docs-page wiki-body' "
|
| 674 |
+
f"data-doc-page='{html.escape(docs_search_text(entry))}'>"
|
| 675 |
+
"<div class='docs-page-source'>"
|
| 676 |
+
f"<code>{html.escape(str(entry['path']))}</code>"
|
| 677 |
+
f"<a href='{html.escape(source_url)}'>source -></a>"
|
| 678 |
+
"</div>"
|
| 679 |
+
f"{body_html}"
|
| 680 |
+
"</article>"
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
def render_docs(
|
| 685 |
+
*,
|
| 686 |
+
roots: Sequence[Path],
|
| 687 |
+
layout: LayoutRenderer,
|
| 688 |
+
asset_text: AssetReader,
|
| 689 |
+
inline_script: InlineScriptRenderer,
|
| 690 |
+
cache_path: Path,
|
| 691 |
+
fallback_markdown: FallbackMarkdownRenderer,
|
| 692 |
+
cache_salt_paths: Sequence[Path] = (),
|
| 693 |
+
cache_extra: Sequence[Any] = (),
|
| 694 |
+
index_entries: Callable[[], list[dict[str, Any]]] | None = None,
|
| 695 |
+
tabs_for_entries: Callable[[list[dict[str, Any]]], list[dict[str, Any]]] | None = None,
|
| 696 |
+
render_markdown_func: MarkdownRenderer | None = None,
|
| 697 |
+
public_docs_url: str = PUBLIC_DOCS_URL,
|
| 698 |
+
) -> str:
|
| 699 |
+
cache_key = docs_render_cache_key(
|
| 700 |
+
roots,
|
| 701 |
+
asset_text=asset_text,
|
| 702 |
+
cache_salt_paths=cache_salt_paths,
|
| 703 |
+
cache_extra=cache_extra,
|
| 704 |
+
)
|
| 705 |
+
global _DOCS_RENDER_CACHE_KEY, _DOCS_RENDER_CACHE_VALUE
|
| 706 |
+
if _DOCS_RENDER_CACHE_KEY == cache_key and _DOCS_RENDER_CACHE_VALUE is not None:
|
| 707 |
+
return _DOCS_RENDER_CACHE_VALUE
|
| 708 |
+
cache_token = _disk_cache_token(cache_key)
|
| 709 |
+
cached = _read_html_disk_cache(cache_path, cache_token)
|
| 710 |
+
if cached is not None:
|
| 711 |
+
_DOCS_RENDER_CACHE_KEY = cache_key
|
| 712 |
+
_DOCS_RENDER_CACHE_VALUE = cached
|
| 713 |
+
return cached
|
| 714 |
+
|
| 715 |
+
entries = index_entries() if index_entries is not None else docs_index_entries(roots)
|
| 716 |
+
tabs = tabs_for_entries(entries) if tabs_for_entries is not None else docs_tabs(entries, roots)
|
| 717 |
+
if render_markdown_func is None:
|
| 718 |
+
def default_render_markdown(text: str, anchor: str) -> str:
|
| 719 |
+
return render_docs_markdown(
|
| 720 |
+
text,
|
| 721 |
+
anchor,
|
| 722 |
+
fallback_renderer=fallback_markdown,
|
| 723 |
+
)
|
| 724 |
+
|
| 725 |
+
render_markdown_func = default_render_markdown
|
| 726 |
+
if not tabs:
|
| 727 |
+
body = (
|
| 728 |
+
"<h1>Docs</h1>"
|
| 729 |
+
"<div class='card'><strong>No local docs found.</strong>"
|
| 730 |
+
f"<p class='muted'>Open the public docs at "
|
| 731 |
+
f"<a href='{public_docs_url}'>{public_docs_url}</a>.</p></div>"
|
| 732 |
+
)
|
| 733 |
+
html_out = layout("Docs", body)
|
| 734 |
+
_write_html_disk_cache(cache_path, cache_token, html_out)
|
| 735 |
+
_DOCS_RENDER_CACHE_KEY = cache_key
|
| 736 |
+
_DOCS_RENDER_CACHE_VALUE = html_out
|
| 737 |
+
return html_out
|
| 738 |
+
|
| 739 |
+
tab_buttons = "".join(
|
| 740 |
+
f"<button class='docs-tab-button{' active' if idx == 0 else ''}' "
|
| 741 |
+
f"type='button' data-doc-tab='{html.escape(str(tab['slug']))}'>"
|
| 742 |
+
f"{html.escape(str(tab['label']))}</button>"
|
| 743 |
+
for idx, tab in enumerate(tabs)
|
| 744 |
+
)
|
| 745 |
+
panels: list[str] = []
|
| 746 |
+
page_count = sum(len(list(tab["pages"])) for tab in tabs)
|
| 747 |
+
page_anchors: dict[str, tuple[str, str]] = {}
|
| 748 |
+
for tab in tabs:
|
| 749 |
+
tab_slug = str(tab["slug"])
|
| 750 |
+
for page in list(tab["pages"]):
|
| 751 |
+
page_anchors[str(page["path"])] = (tab_slug, docs_page_anchor(tab_slug, str(page["path"])))
|
| 752 |
+
for idx, tab in enumerate(tabs):
|
| 753 |
+
tab_slug = str(tab["slug"])
|
| 754 |
+
pages = list(tab["pages"])
|
| 755 |
+
page_links = "".join(
|
| 756 |
+
render_docs_sidebar_page(page, tab_slug, page_anchors)
|
| 757 |
+
for page in pages
|
| 758 |
+
)
|
| 759 |
+
page_bodies = "".join(
|
| 760 |
+
render_docs_page(
|
| 761 |
+
page,
|
| 762 |
+
tab_slug,
|
| 763 |
+
page_anchors,
|
| 764 |
+
render_markdown_func=render_markdown_func,
|
| 765 |
+
)
|
| 766 |
+
for page in pages
|
| 767 |
+
)
|
| 768 |
+
hidden = " hidden" if idx else ""
|
| 769 |
+
panels.append(
|
| 770 |
+
f"<section class='docs-tab-panel' data-doc-panel='{html.escape(tab_slug)}'{hidden}>"
|
| 771 |
+
"<div class='docs-reader'>"
|
| 772 |
+
f"<aside class='docs-page-list'>{page_links}</aside>"
|
| 773 |
+
f"<div>{page_bodies}</div>"
|
| 774 |
+
"</div>"
|
| 775 |
+
"</section>"
|
| 776 |
+
)
|
| 777 |
+
|
| 778 |
+
body = (
|
| 779 |
+
"<div class='docs-shell'>"
|
| 780 |
+
"<section class='docs-hero'>"
|
| 781 |
+
"<div class='docs-hero-grid'>"
|
| 782 |
+
"<div>"
|
| 783 |
+
"<div class='docs-eyebrow'>Repo documentation</div>"
|
| 784 |
+
"<h1>Docs</h1>"
|
| 785 |
+
"<p>Read the same Markdown tree and MkDocs nav shipped with the repo. "
|
| 786 |
+
"Use tabs for sections, search across local docs, and jump to source when you need the exact file.</p>"
|
| 787 |
+
"</div>"
|
| 788 |
+
"<div class='docs-hero-meta'>"
|
| 789 |
+
f"<span class='docs-stat'>{len(tabs)} sections</span>"
|
| 790 |
+
f"<span class='docs-stat'>{page_count} pages</span>"
|
| 791 |
+
"</div>"
|
| 792 |
+
"</div>"
|
| 793 |
+
"<div class='docs-actions'>"
|
| 794 |
+
"<div class='docs-search-wrap'>"
|
| 795 |
+
"<input id='docs-search' type='text' placeholder='Search local docs...'>"
|
| 796 |
+
"</div>"
|
| 797 |
+
f"<a class='docs-public-link' href='{public_docs_url}'>public docs -></a>"
|
| 798 |
+
"</div>"
|
| 799 |
+
"<div id='docs-search-results' class='docs-search-results' hidden></div>"
|
| 800 |
+
"</section>"
|
| 801 |
+
f"<div class='docs-tabs' role='tablist'>{tab_buttons}</div>"
|
| 802 |
+
+ "".join(panels)
|
| 803 |
+
+ inline_script("monitor-docs.js")
|
| 804 |
+
+ "</div>"
|
| 805 |
+
)
|
| 806 |
+
html_out = layout("Docs", body)
|
| 807 |
+
_write_html_disk_cache(cache_path, cache_token, html_out)
|
| 808 |
+
_DOCS_RENDER_CACHE_KEY = cache_key
|
| 809 |
+
_DOCS_RENDER_CACHE_VALUE = html_out
|
| 810 |
+
return html_out
|
src/ctx/dashboard_entities.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dashboard entity search, CRUD, and runtime load/unload helpers.
|
| 2 |
+
|
| 3 |
+
``ctx_monitor`` owns HTTP routing and local paths. This module owns the
|
| 4 |
+
catalog-management behavior so search/upsert/delete/load/unload logic can be
|
| 5 |
+
tested and reviewed without the full monitor server surface.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import re
|
| 12 |
+
import time
|
| 13 |
+
from contextlib import AbstractContextManager
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any, Callable
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass(frozen=True)
|
| 20 |
+
class EntityCrudDeps:
|
| 21 |
+
is_safe_slug: Callable[[str], bool]
|
| 22 |
+
normalize_entity_type: Callable[[object], str | None]
|
| 23 |
+
wiki_entity_detail: Callable[[str, str | None], dict[str, Any] | None]
|
| 24 |
+
wiki_entity_target_path: Callable[[str, str], Path]
|
| 25 |
+
wiki_entity_path: Callable[[str, str | None], Path | None]
|
| 26 |
+
iter_wiki_entity_paths: Callable[[str | None], list[tuple[str, str, Path]]]
|
| 27 |
+
read_manifest: Callable[[], dict[str, Any]]
|
| 28 |
+
perform_unload: Callable[[str, str], tuple[bool, str]]
|
| 29 |
+
queue_entity_refresh: Callable[[str, str, Path, str, str], None]
|
| 30 |
+
file_lock: Callable[[Path], AbstractContextManager[Any]]
|
| 31 |
+
write_entity_text: Callable[[Path, str], None]
|
| 32 |
+
parse_frontmatter: Callable[[str], tuple[dict[str, Any], str]]
|
| 33 |
+
frontmatter_tags: Callable[[Any], list[str]]
|
| 34 |
+
frontmatter_text: Callable[[Any], str]
|
| 35 |
+
display_slug: Callable[[str], str]
|
| 36 |
+
display_label: Callable[[Any], str]
|
| 37 |
+
entity_wiki_href: Callable[[str, str], str]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass(frozen=True)
|
| 41 |
+
class EntityRuntimeDeps:
|
| 42 |
+
is_safe_slug: Callable[[str], bool]
|
| 43 |
+
normalize_entity_type: Callable[[object], str | None]
|
| 44 |
+
wiki_dir: Callable[[], Path]
|
| 45 |
+
claude_dir: Callable[[], Path]
|
| 46 |
+
log_dashboard_entity_event: Callable[[str, str, str], None]
|
| 47 |
+
remove_loaded_manifest_entry: Callable[[str, str], list[dict[str, Any]]]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def normalize_entity_tags(raw: Any) -> list[str]:
|
| 51 |
+
if isinstance(raw, list):
|
| 52 |
+
parts = raw
|
| 53 |
+
else:
|
| 54 |
+
parts = re.split(r"[,\n]+", str(raw or ""))
|
| 55 |
+
tags: list[str] = []
|
| 56 |
+
seen: set[str] = set()
|
| 57 |
+
for part in parts:
|
| 58 |
+
tag = re.sub(r"[^a-z0-9_.+-]+", "-", str(part).lower()).strip("-_.+")
|
| 59 |
+
if tag and tag not in seen:
|
| 60 |
+
seen.add(tag)
|
| 61 |
+
tags.append(tag)
|
| 62 |
+
return tags
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def yaml_scalar(value: Any) -> str:
|
| 66 |
+
if isinstance(value, bool):
|
| 67 |
+
return "true" if value else "false"
|
| 68 |
+
if isinstance(value, int | float):
|
| 69 |
+
return str(value)
|
| 70 |
+
text = str(value).replace("\r\n", "\n").replace("\r", "\n").strip()
|
| 71 |
+
if not text:
|
| 72 |
+
return '""'
|
| 73 |
+
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9 _./:+@-]*", text):
|
| 74 |
+
return text
|
| 75 |
+
return json.dumps(text, ensure_ascii=False)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def frontmatter_to_text(frontmatter: dict[str, Any]) -> str:
|
| 79 |
+
lines = ["---"]
|
| 80 |
+
for key, value in frontmatter.items():
|
| 81 |
+
if value is None or value == "":
|
| 82 |
+
continue
|
| 83 |
+
if isinstance(value, list):
|
| 84 |
+
rendered = ", ".join(yaml_scalar(item) for item in value)
|
| 85 |
+
lines.append(f"{key}: [{rendered}]")
|
| 86 |
+
else:
|
| 87 |
+
lines.append(f"{key}: {yaml_scalar(value)}")
|
| 88 |
+
lines.append("---")
|
| 89 |
+
return "\n".join(lines) + "\n"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def entity_content_from_payload(
|
| 93 |
+
payload: dict[str, Any],
|
| 94 |
+
*,
|
| 95 |
+
existing: dict[str, Any] | None = None,
|
| 96 |
+
is_safe_slug: Callable[[str], bool],
|
| 97 |
+
normalize_entity_type: Callable[[object], str | None],
|
| 98 |
+
) -> tuple[str, str, str]:
|
| 99 |
+
slug = str(payload.get("slug", "")).strip()
|
| 100 |
+
if not is_safe_slug(slug):
|
| 101 |
+
raise ValueError(f"invalid slug: {slug!r}")
|
| 102 |
+
entity_type = str(payload.get("entity_type", "skill")).strip() or "skill"
|
| 103 |
+
normalized = normalize_entity_type(entity_type)
|
| 104 |
+
if normalized is None:
|
| 105 |
+
raise ValueError(f"unsupported entity_type: {entity_type!r}")
|
| 106 |
+
body = str(payload.get("body", "")).strip()
|
| 107 |
+
if not body:
|
| 108 |
+
raise ValueError("body is required")
|
| 109 |
+
title = str(payload.get("title") or slug).strip()
|
| 110 |
+
today = time.strftime("%Y-%m-%d", time.gmtime())
|
| 111 |
+
frontmatter = dict(existing or {})
|
| 112 |
+
frontmatter["title"] = title
|
| 113 |
+
frontmatter["type"] = normalized
|
| 114 |
+
frontmatter.setdefault("created", today)
|
| 115 |
+
frontmatter["updated"] = today
|
| 116 |
+
description = str(payload.get("description") or "").strip()
|
| 117 |
+
if description or "description" in payload:
|
| 118 |
+
frontmatter.pop("description", None)
|
| 119 |
+
if description:
|
| 120 |
+
frontmatter["description"] = description
|
| 121 |
+
tags = normalize_entity_tags(payload.get("tags"))
|
| 122 |
+
if tags or "tags" in payload:
|
| 123 |
+
frontmatter.pop("tags", None)
|
| 124 |
+
if tags:
|
| 125 |
+
frontmatter["tags"] = tags
|
| 126 |
+
source_url = str(payload.get("source_url") or "").strip()
|
| 127 |
+
if source_url or "source_url" in payload:
|
| 128 |
+
frontmatter.pop("source_url", None)
|
| 129 |
+
if source_url:
|
| 130 |
+
frontmatter["source_url"] = source_url
|
| 131 |
+
return slug, normalized, frontmatter_to_text(frontmatter) + body.rstrip() + "\n"
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def perform_load(
|
| 135 |
+
slug: str,
|
| 136 |
+
entity_type: str = "skill",
|
| 137 |
+
*,
|
| 138 |
+
command: str | None = None,
|
| 139 |
+
json_config: str | None = None,
|
| 140 |
+
deps: EntityRuntimeDeps,
|
| 141 |
+
) -> tuple[bool, str]:
|
| 142 |
+
"""Install/load one entity from the wiki. Returns (ok, message)."""
|
| 143 |
+
if not deps.is_safe_slug(slug):
|
| 144 |
+
return False, f"invalid slug: {slug!r}"
|
| 145 |
+
normalized_entity_type = deps.normalize_entity_type(entity_type)
|
| 146 |
+
if normalized_entity_type is None:
|
| 147 |
+
return False, f"unsupported entity_type: {entity_type!r}"
|
| 148 |
+
entity_type = normalized_entity_type
|
| 149 |
+
if entity_type == "harness":
|
| 150 |
+
return (
|
| 151 |
+
False,
|
| 152 |
+
"harness installs are managed by ctx-harness-install; "
|
| 153 |
+
f"run: ctx-harness-install {slug} --dry-run",
|
| 154 |
+
)
|
| 155 |
+
result: Any
|
| 156 |
+
try:
|
| 157 |
+
if entity_type == "agent":
|
| 158 |
+
from ctx.adapters.claude_code.install.agent_install import install_agent
|
| 159 |
+
result = install_agent(
|
| 160 |
+
slug,
|
| 161 |
+
wiki_dir=deps.wiki_dir(),
|
| 162 |
+
agents_dir=deps.claude_dir() / "agents",
|
| 163 |
+
)
|
| 164 |
+
elif entity_type == "mcp-server":
|
| 165 |
+
from ctx.adapters.claude_code.install.mcp_install import install_mcp
|
| 166 |
+
result = install_mcp(
|
| 167 |
+
slug,
|
| 168 |
+
wiki_dir=deps.wiki_dir(),
|
| 169 |
+
command=command,
|
| 170 |
+
json_config=json_config,
|
| 171 |
+
auto=True,
|
| 172 |
+
)
|
| 173 |
+
else:
|
| 174 |
+
from ctx.adapters.claude_code.install.skill_install import install_skill
|
| 175 |
+
result = install_skill(
|
| 176 |
+
slug,
|
| 177 |
+
wiki_dir=deps.wiki_dir(),
|
| 178 |
+
skills_dir=deps.claude_dir() / "skills",
|
| 179 |
+
security_scan=True,
|
| 180 |
+
security_scan_required=True,
|
| 181 |
+
)
|
| 182 |
+
except ImportError as exc:
|
| 183 |
+
return False, f"install import failed: {exc}"
|
| 184 |
+
except Exception as exc: # noqa: BLE001
|
| 185 |
+
return False, f"{type(exc).__name__}: {exc}"
|
| 186 |
+
if result.status not in ("installed", "skipped-existing"):
|
| 187 |
+
return False, f"load failed: {result.message or result.status}"
|
| 188 |
+
deps.log_dashboard_entity_event(entity_type, "loaded", slug)
|
| 189 |
+
message = result.message or f"loaded {entity_type}:{slug}"
|
| 190 |
+
scan = getattr(result, "security_scan", None)
|
| 191 |
+
scan_output = str(getattr(scan, "output", "") or "").strip()
|
| 192 |
+
if scan_output:
|
| 193 |
+
message = f"{message}\n\nSkillSpector report:\n{scan_output}"
|
| 194 |
+
return True, message
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def perform_unload(
|
| 198 |
+
slug: str,
|
| 199 |
+
entity_type: str = "skill",
|
| 200 |
+
*,
|
| 201 |
+
deps: EntityRuntimeDeps,
|
| 202 |
+
) -> tuple[bool, str]:
|
| 203 |
+
"""Unload one entity by routing to the correct installer/uninstaller."""
|
| 204 |
+
if not deps.is_safe_slug(slug):
|
| 205 |
+
return False, f"invalid slug: {slug!r}"
|
| 206 |
+
normalized_entity_type = deps.normalize_entity_type(entity_type)
|
| 207 |
+
if normalized_entity_type is None:
|
| 208 |
+
return False, f"unsupported entity_type: {entity_type!r}"
|
| 209 |
+
entity_type = normalized_entity_type
|
| 210 |
+
if entity_type == "harness":
|
| 211 |
+
return (
|
| 212 |
+
False,
|
| 213 |
+
"harness installs are managed by ctx-harness-install; "
|
| 214 |
+
f"run: ctx-harness-install {slug} --uninstall --dry-run",
|
| 215 |
+
)
|
| 216 |
+
if entity_type == "mcp-server":
|
| 217 |
+
try:
|
| 218 |
+
from ctx.adapters.claude_code.install.mcp_install import uninstall_mcp
|
| 219 |
+
except ImportError as exc:
|
| 220 |
+
return False, f"mcp_install import failed: {exc}"
|
| 221 |
+
try:
|
| 222 |
+
result = uninstall_mcp(slug, wiki_dir=deps.wiki_dir())
|
| 223 |
+
except Exception as exc: # noqa: BLE001
|
| 224 |
+
return False, f"{type(exc).__name__}: {exc}"
|
| 225 |
+
if result.status not in ("uninstalled",):
|
| 226 |
+
return False, f"uninstall failed: {result.message or result.status}"
|
| 227 |
+
deps.log_dashboard_entity_event("mcp-server", "unloaded", slug)
|
| 228 |
+
return True, f"unloaded mcp:{slug}"
|
| 229 |
+
|
| 230 |
+
if entity_type == "agent":
|
| 231 |
+
try:
|
| 232 |
+
removed_entries = deps.remove_loaded_manifest_entry(slug, "agent")
|
| 233 |
+
except Exception as exc: # noqa: BLE001
|
| 234 |
+
return False, f"{type(exc).__name__}: {exc}"
|
| 235 |
+
if not removed_entries:
|
| 236 |
+
return False, f"{slug} was not in the loaded set"
|
| 237 |
+
deps.log_dashboard_entity_event("agent", "unloaded", slug)
|
| 238 |
+
return True, f"unloaded {slug}"
|
| 239 |
+
|
| 240 |
+
# Skills keep using the existing skill_unload module so skill-events.jsonl
|
| 241 |
+
# remains compatible with older usage and retention analytics.
|
| 242 |
+
try:
|
| 243 |
+
from ctx.adapters.claude_code.install.skill_unload import unload_from_session
|
| 244 |
+
except ImportError as exc:
|
| 245 |
+
return False, f"skill_unload import failed: {exc}"
|
| 246 |
+
try:
|
| 247 |
+
removed = unload_from_session([slug], entity_type=entity_type)
|
| 248 |
+
except Exception as exc: # noqa: BLE001
|
| 249 |
+
return False, f"{type(exc).__name__}: {exc}"
|
| 250 |
+
if not removed:
|
| 251 |
+
return False, f"{slug} was not in the loaded set"
|
| 252 |
+
return True, f"unloaded {', '.join(removed)}"
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def search_wiki_entities(
|
| 256 |
+
query: str = "",
|
| 257 |
+
entity_type: str | None = None,
|
| 258 |
+
*,
|
| 259 |
+
limit: int = 80,
|
| 260 |
+
deps: EntityCrudDeps,
|
| 261 |
+
) -> list[dict[str, Any]]:
|
| 262 |
+
terms = [term for term in re.split(r"\s+", query.lower().strip()) if term]
|
| 263 |
+
results: list[dict[str, Any]] = []
|
| 264 |
+
for slug, current_type, path in deps.iter_wiki_entity_paths(entity_type):
|
| 265 |
+
try:
|
| 266 |
+
head = path.read_text(encoding="utf-8", errors="replace")[:4096]
|
| 267 |
+
except OSError:
|
| 268 |
+
continue
|
| 269 |
+
frontmatter, body = deps.parse_frontmatter(head)
|
| 270 |
+
tags = deps.frontmatter_tags(frontmatter.get("tags", ""))
|
| 271 |
+
description = deps.frontmatter_text(frontmatter.get("description", ""))
|
| 272 |
+
display_slug = deps.display_slug(slug)
|
| 273 |
+
title = deps.display_label(
|
| 274 |
+
deps.frontmatter_text(frontmatter.get("title") or frontmatter.get("name") or slug),
|
| 275 |
+
)
|
| 276 |
+
haystack = " ".join(
|
| 277 |
+
[slug, display_slug, current_type, title, description, " ".join(tags), body],
|
| 278 |
+
).lower()
|
| 279 |
+
if terms and not all(term in haystack for term in terms):
|
| 280 |
+
continue
|
| 281 |
+
results.append({
|
| 282 |
+
"slug": slug,
|
| 283 |
+
"display_slug": display_slug,
|
| 284 |
+
"type": current_type,
|
| 285 |
+
"title": title,
|
| 286 |
+
"description": description,
|
| 287 |
+
"tags": tags[:12],
|
| 288 |
+
"path": str(path),
|
| 289 |
+
"href": deps.entity_wiki_href(slug, current_type),
|
| 290 |
+
})
|
| 291 |
+
if len(results) >= max(1, limit):
|
| 292 |
+
break
|
| 293 |
+
return results
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def entity_live_in_manifest(slug: str, entity_type: str, *, deps: EntityCrudDeps) -> bool:
|
| 297 |
+
manifest = deps.read_manifest()
|
| 298 |
+
for entry in manifest.get("load", []):
|
| 299 |
+
if not isinstance(entry, dict):
|
| 300 |
+
continue
|
| 301 |
+
entry_slug = str(entry.get("skill") or entry.get("slug") or "")
|
| 302 |
+
entry_type = deps.normalize_entity_type(
|
| 303 |
+
str(entry.get("entity_type") or entry.get("type") or "skill"),
|
| 304 |
+
)
|
| 305 |
+
if entry_slug == slug and entry_type == entity_type:
|
| 306 |
+
return True
|
| 307 |
+
return False
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def upsert_wiki_entity(payload: dict[str, Any], *, deps: EntityCrudDeps) -> tuple[bool, str]:
|
| 311 |
+
try:
|
| 312 |
+
requested_slug = str(payload.get("slug", "")).strip()
|
| 313 |
+
requested_type = str(payload.get("entity_type", "skill")).strip() or "skill"
|
| 314 |
+
existing_detail = deps.wiki_entity_detail(requested_slug, requested_type)
|
| 315 |
+
existing_meta = (
|
| 316 |
+
existing_detail.get("frontmatter")
|
| 317 |
+
if isinstance(existing_detail, dict)
|
| 318 |
+
else None
|
| 319 |
+
)
|
| 320 |
+
confirm_update = str(payload.get("confirm_update", "")).strip().lower() in {
|
| 321 |
+
"1",
|
| 322 |
+
"true",
|
| 323 |
+
"yes",
|
| 324 |
+
"y",
|
| 325 |
+
"on",
|
| 326 |
+
}
|
| 327 |
+
if existing_detail is not None and not confirm_update:
|
| 328 |
+
return (
|
| 329 |
+
False,
|
| 330 |
+
f"existing {requested_type}:{requested_slug} found; review before "
|
| 331 |
+
"replacing. Benefit: keeps the catalog current. Risk: a lower-quality "
|
| 332 |
+
"manual edit can degrade recommendations. Resubmit with "
|
| 333 |
+
"confirm_update=true to apply.",
|
| 334 |
+
)
|
| 335 |
+
slug, entity_type, content = entity_content_from_payload(
|
| 336 |
+
payload,
|
| 337 |
+
existing=existing_meta if isinstance(existing_meta, dict) else None,
|
| 338 |
+
is_safe_slug=deps.is_safe_slug,
|
| 339 |
+
normalize_entity_type=deps.normalize_entity_type,
|
| 340 |
+
)
|
| 341 |
+
path = deps.wiki_entity_target_path(slug, entity_type)
|
| 342 |
+
with deps.file_lock(path):
|
| 343 |
+
deps.write_entity_text(path, content)
|
| 344 |
+
deps.queue_entity_refresh(entity_type, slug, path, content, "upsert")
|
| 345 |
+
except Exception as exc: # noqa: BLE001
|
| 346 |
+
return False, f"{type(exc).__name__}: {exc}"
|
| 347 |
+
return True, f"saved {entity_type}:{slug} and queued graph refresh"
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def delete_wiki_entity(
|
| 351 |
+
slug: str,
|
| 352 |
+
entity_type: str,
|
| 353 |
+
*,
|
| 354 |
+
deps: EntityCrudDeps,
|
| 355 |
+
) -> tuple[bool, str]:
|
| 356 |
+
try:
|
| 357 |
+
normalized = deps.normalize_entity_type(entity_type)
|
| 358 |
+
if normalized is None:
|
| 359 |
+
raise ValueError(f"unsupported entity_type: {entity_type!r}")
|
| 360 |
+
if not deps.is_safe_slug(slug):
|
| 361 |
+
raise ValueError(f"invalid slug: {slug!r}")
|
| 362 |
+
path = deps.wiki_entity_path(slug, normalized)
|
| 363 |
+
if path is None:
|
| 364 |
+
return False, f"no wiki entity found for {normalized}:{slug}"
|
| 365 |
+
if entity_live_in_manifest(slug, normalized, deps=deps):
|
| 366 |
+
unloaded, unload_detail = deps.perform_unload(slug, normalized)
|
| 367 |
+
if not unloaded:
|
| 368 |
+
return (
|
| 369 |
+
False,
|
| 370 |
+
f"{normalized}:{slug} is loaded; unload before delete failed: "
|
| 371 |
+
f"{unload_detail}",
|
| 372 |
+
)
|
| 373 |
+
with deps.file_lock(path):
|
| 374 |
+
path.unlink()
|
| 375 |
+
deps.queue_entity_refresh(normalized, slug, path, "", "delete")
|
| 376 |
+
except Exception as exc: # noqa: BLE001
|
| 377 |
+
return False, f"{type(exc).__name__}: {exc}"
|
| 378 |
+
return True, f"deleted {normalized}:{slug} and queued graph refresh"
|
src/ctx/dashboard_graph.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dashboard graph response contract helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
GRAPH_SCHEMA = {
|
| 9 |
+
"name": "ctx.dashboard.graph.neighborhood",
|
| 10 |
+
"version": 1,
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
GRAPH_LAYOUT = {
|
| 14 |
+
"kind": "radial-3d",
|
| 15 |
+
"node_size_field": "node_size",
|
| 16 |
+
"node_size_min": 8.0,
|
| 17 |
+
"node_size_max": 24.0,
|
| 18 |
+
"edge_weight_field": "weight",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
ENTITY_TYPES = ("skill", "agent", "mcp-server", "harness")
|
| 22 |
+
SOURCE_EXPLANATIONS = {
|
| 23 |
+
"dashboard-index": "Served from the cached dashboard index for fast cold-start graph browsing.",
|
| 24 |
+
"networkx": "Served from the full in-memory graph because the cached dashboard index was unavailable or not current.",
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _node_data(node: Any) -> dict[str, Any]:
|
| 29 |
+
if not isinstance(node, dict):
|
| 30 |
+
raise ValueError("graph node must be an object")
|
| 31 |
+
data = node.get("data")
|
| 32 |
+
if not isinstance(data, dict):
|
| 33 |
+
raise ValueError("graph node data must be an object")
|
| 34 |
+
if not isinstance(data.get("id"), str) or not data["id"]:
|
| 35 |
+
raise ValueError("graph node data.id must be a non-empty string")
|
| 36 |
+
return data
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _edge_data(edge: Any) -> dict[str, Any]:
|
| 40 |
+
if not isinstance(edge, dict):
|
| 41 |
+
raise ValueError("graph edge must be an object")
|
| 42 |
+
data = edge.get("data")
|
| 43 |
+
if not isinstance(data, dict):
|
| 44 |
+
raise ValueError("graph edge data must be an object")
|
| 45 |
+
if not isinstance(data.get("source"), str) or not data["source"]:
|
| 46 |
+
raise ValueError("graph edge data.source must be a non-empty string")
|
| 47 |
+
if not isinstance(data.get("target"), str) or not data["target"]:
|
| 48 |
+
raise ValueError("graph edge data.target must be a non-empty string")
|
| 49 |
+
return data
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _int_value(value: Any, default: int = 0) -> int:
|
| 53 |
+
try:
|
| 54 |
+
return int(value)
|
| 55 |
+
except (TypeError, ValueError):
|
| 56 |
+
return default
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def graph_insights(
|
| 60 |
+
nodes: list[Any],
|
| 61 |
+
edges: list[Any],
|
| 62 |
+
*,
|
| 63 |
+
center: str | None,
|
| 64 |
+
source: str,
|
| 65 |
+
) -> dict[str, Any]:
|
| 66 |
+
"""Return dashboard-visible counts after validating graph response shape."""
|
| 67 |
+
by_type = {entity_type: 0 for entity_type in ENTITY_TYPES}
|
| 68 |
+
max_degree = 0
|
| 69 |
+
center_degree = 0
|
| 70 |
+
valid_ids: set[str] = set()
|
| 71 |
+
|
| 72 |
+
for node in nodes:
|
| 73 |
+
data = _node_data(node)
|
| 74 |
+
node_id = str(data["id"])
|
| 75 |
+
valid_ids.add(node_id)
|
| 76 |
+
entity_type = str(data.get("type") or "skill")
|
| 77 |
+
if entity_type in by_type:
|
| 78 |
+
by_type[entity_type] += 1
|
| 79 |
+
degree = _int_value(data.get("degree"))
|
| 80 |
+
max_degree = max(max_degree, degree)
|
| 81 |
+
if center is not None and node_id == center:
|
| 82 |
+
center_degree = degree
|
| 83 |
+
|
| 84 |
+
for edge in edges:
|
| 85 |
+
data = _edge_data(edge)
|
| 86 |
+
if data["source"] not in valid_ids or data["target"] not in valid_ids:
|
| 87 |
+
raise ValueError("graph edge endpoints must exist in nodes")
|
| 88 |
+
|
| 89 |
+
return {
|
| 90 |
+
"source": source,
|
| 91 |
+
"node_count": len(nodes),
|
| 92 |
+
"edge_count": len(edges),
|
| 93 |
+
"by_type": by_type,
|
| 94 |
+
"max_degree": max_degree,
|
| 95 |
+
"center_degree": center_degree,
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def graph_explanations(payload: dict[str, Any], *, source: str) -> dict[str, str]:
|
| 100 |
+
"""Return plain-English graph/search explanations for the dashboard."""
|
| 101 |
+
resolved = payload.get("resolved")
|
| 102 |
+
if isinstance(resolved, dict) and resolved.get("query") and resolved.get("slug"):
|
| 103 |
+
if str(resolved.get("query")) != str(resolved.get("slug")):
|
| 104 |
+
focus = (
|
| 105 |
+
f"Focus search resolved {resolved['query']!r} to "
|
| 106 |
+
f"{resolved['slug']!r}."
|
| 107 |
+
)
|
| 108 |
+
else:
|
| 109 |
+
focus = f"Focus search matched {resolved['slug']!r}."
|
| 110 |
+
else:
|
| 111 |
+
center = payload.get("center")
|
| 112 |
+
focus = f"Focus search matched {center}." if center else "No graph focus matched."
|
| 113 |
+
|
| 114 |
+
return {
|
| 115 |
+
"source": SOURCE_EXPLANATIONS.get(
|
| 116 |
+
source,
|
| 117 |
+
f"Served from graph source {source}.",
|
| 118 |
+
),
|
| 119 |
+
"search": (
|
| 120 |
+
"Focus search tries exact or normalized slug first, then display "
|
| 121 |
+
"slug, title, and tag matches."
|
| 122 |
+
),
|
| 123 |
+
"layout": (
|
| 124 |
+
"Node size is cached metadata based on quality, usage, and graph "
|
| 125 |
+
"degree; it is bounded so important nodes stand out without "
|
| 126 |
+
"dominating the graph."
|
| 127 |
+
),
|
| 128 |
+
"edges": (
|
| 129 |
+
"Edges are sorted by weight and expose shared_tags, reasons, and "
|
| 130 |
+
"available semantic/tag/slug-token signals."
|
| 131 |
+
),
|
| 132 |
+
"focus": focus,
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def enrich_neighborhood(payload: dict[str, Any], *, source: str) -> dict[str, Any]:
|
| 137 |
+
"""Attach schema, layout, and insight metadata to a graph neighborhood."""
|
| 138 |
+
nodes = payload.get("nodes")
|
| 139 |
+
edges = payload.get("edges")
|
| 140 |
+
if not isinstance(nodes, list):
|
| 141 |
+
raise ValueError("graph payload nodes must be a list")
|
| 142 |
+
if not isinstance(edges, list):
|
| 143 |
+
raise ValueError("graph payload edges must be a list")
|
| 144 |
+
center = payload.get("center")
|
| 145 |
+
if center is not None and not isinstance(center, str):
|
| 146 |
+
raise ValueError("graph payload center must be a string or null")
|
| 147 |
+
|
| 148 |
+
enriched = dict(payload)
|
| 149 |
+
enriched["schema"] = dict(GRAPH_SCHEMA)
|
| 150 |
+
enriched["layout"] = dict(GRAPH_LAYOUT)
|
| 151 |
+
enriched["insights"] = graph_insights(
|
| 152 |
+
nodes,
|
| 153 |
+
edges,
|
| 154 |
+
center=center,
|
| 155 |
+
source=source,
|
| 156 |
+
)
|
| 157 |
+
enriched["explanations"] = graph_explanations(payload, source=source)
|
| 158 |
+
return enriched
|
src/ctx_monitor.py
CHANGED
|
@@ -76,10 +76,12 @@ from collections import defaultdict, deque
|
|
| 76 |
from http.cookies import CookieError, SimpleCookie
|
| 77 |
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 78 |
from importlib.resources import files
|
| 79 |
-
from pathlib import Path
|
| 80 |
from typing import Any
|
| 81 |
from urllib.parse import quote, unquote, urlsplit
|
| 82 |
|
|
|
|
|
|
|
| 83 |
from ctx.core.wiki import wiki_queue
|
| 84 |
from ctx.core.wiki.wiki_utils import parse_frontmatter_and_body
|
| 85 |
from ctx.utils._file_lock import file_lock
|
|
@@ -102,8 +104,6 @@ _SIDECAR_FILTER_CACHE_VALUE: dict[tuple[Any, ...], list[dict[str, Any]]] = {}
|
|
| 102 |
_KPI_SUMMARY_CACHE_KEY: tuple[Any, ...] | None = None
|
| 103 |
_KPI_SUMMARY_CACHE_VALUE: Any | None = None
|
| 104 |
_KPI_SUMMARY_CACHE_AT = 0.0
|
| 105 |
-
_DOCS_RENDER_CACHE_KEY: tuple[Any, ...] | None = None
|
| 106 |
-
_DOCS_RENDER_CACHE_VALUE: str | None = None
|
| 107 |
_WIKI_RENDER_CACHE_KEY: tuple[Any, ...] | None = None
|
| 108 |
_WIKI_RENDER_CACHE_VALUE: str | None = None
|
| 109 |
_WIKI_INDEX_LIMIT_PER_TYPE = 500
|
|
@@ -229,16 +229,10 @@ def _load_dashboard_graph() -> Any:
|
|
| 229 |
|
| 230 |
|
| 231 |
def _mcp_shard(slug: str) -> str:
|
| 232 |
-
|
| 233 |
-
return first if first.isalpha() else "0-9"
|
| 234 |
|
| 235 |
|
| 236 |
-
_DASHBOARD_ENTITY_SOURCES: tuple[tuple[str, str, bool], ...] = (
|
| 237 |
-
("skills", "skill", False),
|
| 238 |
-
("agents", "agent", False),
|
| 239 |
-
("mcp-servers", "mcp-server", True),
|
| 240 |
-
("harnesses", "harness", False),
|
| 241 |
-
)
|
| 242 |
_DASHBOARD_ENTITY_TYPES: tuple[str, ...] = tuple(
|
| 243 |
entity_type for _, entity_type, _ in _DASHBOARD_ENTITY_SOURCES
|
| 244 |
)
|
|
@@ -246,21 +240,10 @@ _DEFAULT_GRAPH_FOCUS_SLUG = "github"
|
|
| 246 |
|
| 247 |
|
| 248 |
def _normalize_dashboard_entity_type(raw: object) -> str | None:
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
"skills": "skill",
|
| 254 |
-
"skill": "skill",
|
| 255 |
-
"agents": "agent",
|
| 256 |
-
"agent": "agent",
|
| 257 |
-
"mcp": "mcp-server",
|
| 258 |
-
"mcp-server": "mcp-server",
|
| 259 |
-
"mcp-servers": "mcp-server",
|
| 260 |
-
"harness": "harness",
|
| 261 |
-
"harnesses": "harness",
|
| 262 |
-
}.get(value, value)
|
| 263 |
-
return normalized if normalized in _DASHBOARD_ENTITY_TYPES else None
|
| 264 |
|
| 265 |
|
| 266 |
def _audit_entity_type(row: dict) -> str | None:
|
|
@@ -291,14 +274,12 @@ def _wiki_entity_path(slug: str, entity_type: str | None = None) -> Path | None:
|
|
| 291 |
# Validate slug so a crafted request can't escape the wiki tree.
|
| 292 |
if not _is_safe_slug(slug):
|
| 293 |
return None
|
| 294 |
-
for
|
| 295 |
if entity_type is not None and entity_type != current_type:
|
| 296 |
continue
|
| 297 |
-
p = (
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
else _wiki_dir() / "entities" / sub / f"{slug}.md"
|
| 301 |
-
)
|
| 302 |
if p.exists():
|
| 303 |
return p
|
| 304 |
return None
|
|
@@ -311,13 +292,10 @@ def _wiki_entity_target_path(slug: str, entity_type: str) -> Path:
|
|
| 311 |
normalized = _normalize_dashboard_entity_type(entity_type)
|
| 312 |
if normalized is None:
|
| 313 |
raise ValueError(f"unsupported entity_type: {entity_type!r}")
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
return _wiki_dir() / "entities" / sub / _mcp_shard(slug) / f"{slug}.md"
|
| 319 |
-
return _wiki_dir() / "entities" / sub / f"{slug}.md"
|
| 320 |
-
raise ValueError(f"unsupported entity_type: {entity_type!r}")
|
| 321 |
|
| 322 |
|
| 323 |
def _iter_wiki_entity_paths(
|
|
@@ -369,121 +347,12 @@ def _search_wiki_entities(
|
|
| 369 |
*,
|
| 370 |
limit: int = 80,
|
| 371 |
) -> list[dict[str, Any]]:
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
continue
|
| 379 |
-
frontmatter, body = _parse_frontmatter(head)
|
| 380 |
-
tags = _frontmatter_tags(frontmatter.get("tags", ""), limit=None)
|
| 381 |
-
description = _frontmatter_text(frontmatter.get("description", ""))
|
| 382 |
-
display_slug = _display_slug(slug)
|
| 383 |
-
title = _display_label(
|
| 384 |
-
_frontmatter_text(frontmatter.get("title") or frontmatter.get("name") or slug),
|
| 385 |
-
fallback_slug=slug,
|
| 386 |
-
)
|
| 387 |
-
haystack = " ".join(
|
| 388 |
-
[slug, display_slug, current_type, title, description, " ".join(tags), body],
|
| 389 |
-
).lower()
|
| 390 |
-
if terms and not all(term in haystack for term in terms):
|
| 391 |
-
continue
|
| 392 |
-
results.append({
|
| 393 |
-
"slug": slug,
|
| 394 |
-
"display_slug": display_slug,
|
| 395 |
-
"type": current_type,
|
| 396 |
-
"title": title,
|
| 397 |
-
"description": description,
|
| 398 |
-
"tags": tags[:12],
|
| 399 |
-
"path": str(path),
|
| 400 |
-
"href": _entity_wiki_href(slug, current_type),
|
| 401 |
-
})
|
| 402 |
-
if len(results) >= max(1, limit):
|
| 403 |
-
break
|
| 404 |
-
return results
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
def _normalize_entity_tags(raw: Any) -> list[str]:
|
| 408 |
-
if isinstance(raw, list):
|
| 409 |
-
parts = raw
|
| 410 |
-
else:
|
| 411 |
-
parts = re.split(r"[,\n]+", str(raw or ""))
|
| 412 |
-
tags: list[str] = []
|
| 413 |
-
seen: set[str] = set()
|
| 414 |
-
for part in parts:
|
| 415 |
-
tag = re.sub(r"[^a-z0-9_.+-]+", "-", str(part).lower()).strip("-_.+")
|
| 416 |
-
if tag and tag not in seen:
|
| 417 |
-
seen.add(tag)
|
| 418 |
-
tags.append(tag)
|
| 419 |
-
return tags
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
def _yaml_scalar(value: Any) -> str:
|
| 423 |
-
if isinstance(value, bool):
|
| 424 |
-
return "true" if value else "false"
|
| 425 |
-
if isinstance(value, int | float):
|
| 426 |
-
return str(value)
|
| 427 |
-
text = str(value).replace("\r\n", "\n").replace("\r", "\n").strip()
|
| 428 |
-
if not text:
|
| 429 |
-
return '""'
|
| 430 |
-
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9 _./:+@-]*", text):
|
| 431 |
-
return text
|
| 432 |
-
return json.dumps(text, ensure_ascii=False)
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
def _frontmatter_to_text(frontmatter: dict[str, Any]) -> str:
|
| 436 |
-
lines = ["---"]
|
| 437 |
-
for key, value in frontmatter.items():
|
| 438 |
-
if value is None or value == "":
|
| 439 |
-
continue
|
| 440 |
-
if isinstance(value, list):
|
| 441 |
-
rendered = ", ".join(_yaml_scalar(item) for item in value)
|
| 442 |
-
lines.append(f"{key}: [{rendered}]")
|
| 443 |
-
else:
|
| 444 |
-
lines.append(f"{key}: {_yaml_scalar(value)}")
|
| 445 |
-
lines.append("---")
|
| 446 |
-
return "\n".join(lines) + "\n"
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
def _entity_content_from_payload(
|
| 450 |
-
payload: dict[str, Any],
|
| 451 |
-
*,
|
| 452 |
-
existing: dict[str, Any] | None = None,
|
| 453 |
-
) -> tuple[str, str, str]:
|
| 454 |
-
slug = str(payload.get("slug", "")).strip()
|
| 455 |
-
if not _is_safe_slug(slug):
|
| 456 |
-
raise ValueError(f"invalid slug: {slug!r}")
|
| 457 |
-
entity_type = str(payload.get("entity_type", "skill")).strip() or "skill"
|
| 458 |
-
normalized = _normalize_dashboard_entity_type(entity_type)
|
| 459 |
-
if normalized is None:
|
| 460 |
-
raise ValueError(f"unsupported entity_type: {entity_type!r}")
|
| 461 |
-
body = str(payload.get("body", "")).strip()
|
| 462 |
-
if not body:
|
| 463 |
-
raise ValueError("body is required")
|
| 464 |
-
title = str(payload.get("title") or slug).strip()
|
| 465 |
-
today = time.strftime("%Y-%m-%d", time.gmtime())
|
| 466 |
-
frontmatter = dict(existing or {})
|
| 467 |
-
frontmatter["title"] = title
|
| 468 |
-
frontmatter["type"] = normalized
|
| 469 |
-
frontmatter.setdefault("created", today)
|
| 470 |
-
frontmatter["updated"] = today
|
| 471 |
-
description = str(payload.get("description") or "").strip()
|
| 472 |
-
if description or "description" in payload:
|
| 473 |
-
frontmatter.pop("description", None)
|
| 474 |
-
if description:
|
| 475 |
-
frontmatter["description"] = description
|
| 476 |
-
tags = _normalize_entity_tags(payload.get("tags"))
|
| 477 |
-
if tags or "tags" in payload:
|
| 478 |
-
frontmatter.pop("tags", None)
|
| 479 |
-
if tags:
|
| 480 |
-
frontmatter["tags"] = tags
|
| 481 |
-
source_url = str(payload.get("source_url") or "").strip()
|
| 482 |
-
if source_url or "source_url" in payload:
|
| 483 |
-
frontmatter.pop("source_url", None)
|
| 484 |
-
if source_url:
|
| 485 |
-
frontmatter["source_url"] = source_url
|
| 486 |
-
return slug, normalized, _frontmatter_to_text(frontmatter) + body.rstrip() + "\n"
|
| 487 |
|
| 488 |
|
| 489 |
def _queue_entity_refresh(
|
|
@@ -514,94 +383,59 @@ def _queue_entity_refresh(
|
|
| 514 |
)
|
| 515 |
|
| 516 |
|
| 517 |
-
def
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
"on",
|
| 533 |
-
}
|
| 534 |
-
if existing_detail is not None and not confirm_update:
|
| 535 |
-
return (
|
| 536 |
-
False,
|
| 537 |
-
f"existing {requested_type}:{requested_slug} found; review before "
|
| 538 |
-
"replacing. Benefit: keeps the catalog current. Risk: a lower-quality "
|
| 539 |
-
"manual edit can degrade recommendations. Resubmit with "
|
| 540 |
-
"confirm_update=true to apply.",
|
| 541 |
-
)
|
| 542 |
-
slug, entity_type, content = _entity_content_from_payload(
|
| 543 |
-
payload,
|
| 544 |
-
existing=existing_meta if isinstance(existing_meta, dict) else None,
|
| 545 |
-
)
|
| 546 |
-
path = _wiki_entity_target_path(slug, entity_type)
|
| 547 |
-
with file_lock(path):
|
| 548 |
-
_safe_atomic_write_text(path, content, encoding="utf-8")
|
| 549 |
-
_queue_entity_refresh(
|
| 550 |
entity_type=entity_type,
|
| 551 |
slug=slug,
|
| 552 |
-
entity_path=
|
| 553 |
content=content,
|
| 554 |
-
action=
|
| 555 |
-
)
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
|
| 560 |
|
| 561 |
-
def
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
|
|
|
| 573 |
|
| 574 |
|
| 575 |
def _delete_wiki_entity(slug: str, entity_type: str) -> tuple[bool, str]:
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
raise ValueError(f"invalid slug: {slug!r}")
|
| 582 |
-
path = _wiki_entity_path(slug, entity_type=normalized)
|
| 583 |
-
if path is None:
|
| 584 |
-
return False, f"no wiki entity found for {normalized}:{slug}"
|
| 585 |
-
if _entity_live_in_manifest(slug, normalized):
|
| 586 |
-
unloaded, unload_detail = _perform_unload(slug, normalized)
|
| 587 |
-
if not unloaded:
|
| 588 |
-
return (
|
| 589 |
-
False,
|
| 590 |
-
f"{normalized}:{slug} is loaded; unload before delete failed: "
|
| 591 |
-
f"{unload_detail}",
|
| 592 |
-
)
|
| 593 |
-
with file_lock(path):
|
| 594 |
-
path.unlink()
|
| 595 |
-
_queue_entity_refresh(
|
| 596 |
-
entity_type=normalized,
|
| 597 |
-
slug=slug,
|
| 598 |
-
entity_path=path,
|
| 599 |
-
content="",
|
| 600 |
-
action="delete",
|
| 601 |
-
)
|
| 602 |
-
except Exception as exc: # noqa: BLE001
|
| 603 |
-
return False, f"{type(exc).__name__}: {exc}"
|
| 604 |
-
return True, f"deleted {normalized}:{slug} and queued graph refresh"
|
| 605 |
|
| 606 |
|
| 607 |
def _parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
|
@@ -3015,13 +2849,13 @@ def _graph_neighborhood_from_index(
|
|
| 3015 |
frontier = next_frontier
|
| 3016 |
if len(nodes_out) >= limit:
|
| 3017 |
break
|
| 3018 |
-
return {
|
| 3019 |
"nodes": list(nodes_out.values()),
|
| 3020 |
"edges": edges_out,
|
| 3021 |
"center": center,
|
| 3022 |
"resolved": resolved or {"source": "dashboard-index"},
|
| 3023 |
"suggestions": [],
|
| 3024 |
-
}
|
| 3025 |
except (OSError, sqlite3.Error, json.JSONDecodeError, zlib.error, KeyError, TypeError):
|
| 3026 |
return None
|
| 3027 |
finally:
|
|
@@ -3184,13 +3018,13 @@ def _graph_neighborhood(
|
|
| 3184 |
if len(nodes_out) >= limit:
|
| 3185 |
break
|
| 3186 |
|
| 3187 |
-
return {
|
| 3188 |
"nodes": list(nodes_out.values()),
|
| 3189 |
"edges": edges_out,
|
| 3190 |
"center": center,
|
| 3191 |
"resolved": resolved,
|
| 3192 |
"suggestions": suggestions,
|
| 3193 |
-
}
|
| 3194 |
|
| 3195 |
|
| 3196 |
def _graph_stats() -> dict:
|
|
@@ -4126,6 +3960,11 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
|
|
| 4126 |
"<span id='graph-match-count' class='muted'>—</span>"
|
| 4127 |
"</div>"
|
| 4128 |
"<div class='card'><span id='msg' class='muted'></span></div>"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4129 |
"</aside>"
|
| 4130 |
# Right: graph list panel
|
| 4131 |
"<div id='cy' style='width:100%; height:75vh; border:1px solid #ddd; "
|
|
@@ -4193,6 +4032,10 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
|
|
| 4193 |
" const weight = Number(d.weight || 0).toFixed(3);\n"
|
| 4194 |
" return nodeSlug(d.source) + ' ↔ ' + nodeSlug(d.target) + ' · weight ' + weight + ' · shared: ' + tags + ' · reasons: ' + reasons;\n"
|
| 4195 |
"}\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4196 |
"function renderGraph3d(g) {\n"
|
| 4197 |
" const nodes = g.nodes || [];\n"
|
| 4198 |
" const edges = g.edges || [];\n"
|
|
@@ -4340,6 +4183,7 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
|
|
| 4340 |
" try { renderGraph3d(g); } catch (err) { renderFallback(g); }\n"
|
| 4341 |
" const resolved = g.resolved && g.resolved.query !== g.resolved.slug ? ' · showing ' + g.resolved.slug + ' for ' + g.resolved.query : '';\n"
|
| 4342 |
" document.getElementById('msg').textContent = fmtCount(g.nodes.length) + ' nodes · ' + fmtCount(g.edges.length) + ' edges' + resolved;\n"
|
|
|
|
| 4343 |
" applyFilters();\n"
|
| 4344 |
"}\n"
|
| 4345 |
"function selectedFocusType() { return document.getElementById('focus-type').value || ''; }\n"
|
|
@@ -4838,42 +4682,63 @@ def _wiki_render_cache_key(
|
|
| 4838 |
)
|
| 4839 |
|
| 4840 |
|
| 4841 |
-
def
|
| 4842 |
return json.dumps(cache_key, separators=(",", ":"), sort_keys=True)
|
| 4843 |
|
| 4844 |
|
| 4845 |
-
def
|
| 4846 |
-
return _claude_dir() / ".ctx-monitor-wiki-cache.json"
|
| 4847 |
-
|
| 4848 |
-
|
| 4849 |
-
def _read_wiki_render_disk_cache(cache_token: str) -> str | None:
|
| 4850 |
try:
|
| 4851 |
-
data = json.loads(
|
| 4852 |
except (OSError, json.JSONDecodeError):
|
| 4853 |
return None
|
| 4854 |
if not isinstance(data, dict):
|
| 4855 |
return None
|
| 4856 |
if data.get("schema_version") != 1 or data.get("cache_token") != cache_token:
|
| 4857 |
return None
|
| 4858 |
-
|
| 4859 |
-
return html_text if isinstance(html_text, str) else None
|
| 4860 |
|
| 4861 |
|
| 4862 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4863 |
try:
|
| 4864 |
_atomic_write_text(
|
| 4865 |
-
|
| 4866 |
-
json.dumps(
|
| 4867 |
-
|
| 4868 |
-
|
| 4869 |
-
|
| 4870 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4871 |
encoding="utf-8",
|
| 4872 |
)
|
| 4873 |
except (OSError, TypeError, ValueError):
|
| 4874 |
return
|
| 4875 |
|
| 4876 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4877 |
def _render_wiki_index(entity_type: str | None = None, query: str = "") -> str:
|
| 4878 |
"""Card grid of every wiki entity — search + type filter + sidecar grades."""
|
| 4879 |
selected_type = _normalize_dashboard_entity_type(entity_type) if entity_type else None
|
|
@@ -4888,8 +4753,8 @@ def _render_wiki_index(entity_type: str | None = None, query: str = "") -> str:
|
|
| 4888 |
if cache_key is not None:
|
| 4889 |
if _WIKI_RENDER_CACHE_KEY == cache_key and _WIKI_RENDER_CACHE_VALUE is not None:
|
| 4890 |
return _WIKI_RENDER_CACHE_VALUE
|
| 4891 |
-
cache_token =
|
| 4892 |
-
cached =
|
| 4893 |
if cached is not None:
|
| 4894 |
_WIKI_RENDER_CACHE_KEY = cache_key
|
| 4895 |
_WIKI_RENDER_CACHE_VALUE = cached
|
|
@@ -5018,689 +4883,51 @@ def _render_wiki_index(entity_type: str | None = None, query: str = "") -> str:
|
|
| 5018 |
)
|
| 5019 |
html_out = _layout("Wiki", body)
|
| 5020 |
if cache_key is not None:
|
| 5021 |
-
|
| 5022 |
_WIKI_RENDER_CACHE_KEY = cache_key
|
| 5023 |
_WIKI_RENDER_CACHE_VALUE = html_out
|
| 5024 |
return html_out
|
| 5025 |
|
| 5026 |
|
| 5027 |
-
def _docs_roots() -> list[Path]:
|
| 5028 |
-
roots: list[Path] = []
|
| 5029 |
-
for root in (Path.cwd(), Path(__file__).resolve().parent.parent):
|
| 5030 |
-
if root not in roots and (root / "docs").is_dir():
|
| 5031 |
-
roots.append(root)
|
| 5032 |
-
return roots
|
| 5033 |
-
|
| 5034 |
-
|
| 5035 |
-
def _docs_cache_files() -> list[Path]:
|
| 5036 |
-
files: list[Path] = []
|
| 5037 |
-
seen: set[Path] = set()
|
| 5038 |
-
for root in _docs_roots():
|
| 5039 |
-
candidates = [root / "README.md", root / "graph" / "README.md", root / "mkdocs.yml"]
|
| 5040 |
-
docs_dir = root / "docs"
|
| 5041 |
-
if docs_dir.is_dir():
|
| 5042 |
-
candidates.extend(sorted(docs_dir.rglob("*.md")))
|
| 5043 |
-
for path in candidates:
|
| 5044 |
-
if not path.is_file():
|
| 5045 |
-
continue
|
| 5046 |
-
try:
|
| 5047 |
-
resolved = path.resolve()
|
| 5048 |
-
except OSError:
|
| 5049 |
-
resolved = path
|
| 5050 |
-
if resolved in seen:
|
| 5051 |
-
continue
|
| 5052 |
-
seen.add(resolved)
|
| 5053 |
-
files.append(path)
|
| 5054 |
-
return files
|
| 5055 |
-
|
| 5056 |
-
|
| 5057 |
-
def _docs_render_cache_key() -> tuple[Any, ...]:
|
| 5058 |
-
parts: list[Any] = []
|
| 5059 |
-
try:
|
| 5060 |
-
stat = Path(__file__).stat()
|
| 5061 |
-
parts.append(("ctx_monitor", stat.st_mtime_ns, stat.st_size))
|
| 5062 |
-
except OSError:
|
| 5063 |
-
parts.append(("ctx_monitor", None, None))
|
| 5064 |
-
for asset_name in ("monitor.css", "monitor-docs.js"):
|
| 5065 |
-
try:
|
| 5066 |
-
asset_text = _monitor_asset_text(asset_name)
|
| 5067 |
-
asset_hash = hashlib.sha256(asset_text.encode("utf-8")).hexdigest()
|
| 5068 |
-
except Exception:
|
| 5069 |
-
asset_hash = ""
|
| 5070 |
-
parts.append(("asset", asset_name, asset_hash))
|
| 5071 |
-
for path in _docs_cache_files():
|
| 5072 |
-
try:
|
| 5073 |
-
stat = path.stat()
|
| 5074 |
-
path_name = str(path.resolve())
|
| 5075 |
-
parts.append((path_name, stat.st_mtime_ns, stat.st_size))
|
| 5076 |
-
except OSError:
|
| 5077 |
-
continue
|
| 5078 |
-
return tuple(parts)
|
| 5079 |
-
|
| 5080 |
|
| 5081 |
-
def
|
| 5082 |
-
return
|
| 5083 |
|
| 5084 |
|
| 5085 |
def _docs_render_disk_cache_path() -> Path:
|
| 5086 |
-
return _claude_dir()
|
| 5087 |
-
|
| 5088 |
-
|
| 5089 |
-
def _read_docs_render_disk_cache(cache_token: str) -> str | None:
|
| 5090 |
-
try:
|
| 5091 |
-
data = json.loads(_docs_render_disk_cache_path().read_text(encoding="utf-8"))
|
| 5092 |
-
except (OSError, json.JSONDecodeError):
|
| 5093 |
-
return None
|
| 5094 |
-
if not isinstance(data, dict):
|
| 5095 |
-
return None
|
| 5096 |
-
if data.get("schema_version") != 1 or data.get("cache_token") != cache_token:
|
| 5097 |
-
return None
|
| 5098 |
-
html_text = data.get("html")
|
| 5099 |
-
return html_text if isinstance(html_text, str) else None
|
| 5100 |
-
|
| 5101 |
-
|
| 5102 |
-
def _write_docs_render_disk_cache(cache_token: str, html_text: str) -> None:
|
| 5103 |
-
try:
|
| 5104 |
-
_atomic_write_text(
|
| 5105 |
-
_docs_render_disk_cache_path(),
|
| 5106 |
-
json.dumps({
|
| 5107 |
-
"schema_version": 1,
|
| 5108 |
-
"cache_token": cache_token,
|
| 5109 |
-
"html": html_text,
|
| 5110 |
-
}, ensure_ascii=False) + "\n",
|
| 5111 |
-
encoding="utf-8",
|
| 5112 |
-
)
|
| 5113 |
-
except (OSError, TypeError, ValueError):
|
| 5114 |
-
return
|
| 5115 |
-
|
| 5116 |
-
|
| 5117 |
-
def _doc_title(text: str, fallback: str) -> str:
|
| 5118 |
-
for line in text.splitlines():
|
| 5119 |
-
match = re.match(r"^#\s+(.+?)\s*$", line)
|
| 5120 |
-
if match:
|
| 5121 |
-
return match.group(1).strip()
|
| 5122 |
-
return fallback
|
| 5123 |
-
|
| 5124 |
|
| 5125 |
-
def _doc_summary(text: str) -> str:
|
| 5126 |
-
in_frontmatter = text.startswith("---\n")
|
| 5127 |
-
for block in re.split(r"\n\s*\n", text):
|
| 5128 |
-
chunk = block.strip()
|
| 5129 |
-
if not chunk:
|
| 5130 |
-
continue
|
| 5131 |
-
if in_frontmatter:
|
| 5132 |
-
if chunk == "---" or chunk.endswith("\n---"):
|
| 5133 |
-
in_frontmatter = False
|
| 5134 |
-
continue
|
| 5135 |
-
if chunk.startswith("#") or chunk.startswith("```") or chunk.startswith("<!--"):
|
| 5136 |
-
continue
|
| 5137 |
-
summary = re.sub(r"\s+", " ", chunk)
|
| 5138 |
-
summary, _truncated = _truncate_text(summary, 180)
|
| 5139 |
-
return summary
|
| 5140 |
-
return ""
|
| 5141 |
|
|
|
|
|
|
|
| 5142 |
|
| 5143 |
-
def _strip_doc_frontmatter(text: str) -> str:
|
| 5144 |
-
if not text.startswith("---\n"):
|
| 5145 |
-
return text
|
| 5146 |
-
parts = text.split("---", 2)
|
| 5147 |
-
return parts[2].lstrip() if len(parts) == 3 else text
|
| 5148 |
|
| 5149 |
-
|
| 5150 |
-
|
| 5151 |
-
return _slugish(value) or "docs"
|
| 5152 |
-
|
| 5153 |
-
|
| 5154 |
-
def _docs_index_entries() -> list[dict[str, str]]:
|
| 5155 |
-
entries: list[dict[str, str]] = []
|
| 5156 |
-
seen: set[str] = set()
|
| 5157 |
-
for root in _docs_roots():
|
| 5158 |
-
candidates = [root / "README.md", root / "graph" / "README.md"]
|
| 5159 |
-
candidates.extend(sorted((root / "docs").rglob("*.md")))
|
| 5160 |
-
for path in candidates:
|
| 5161 |
-
if not path.is_file():
|
| 5162 |
-
continue
|
| 5163 |
-
rel = path.relative_to(root).as_posix()
|
| 5164 |
-
if rel == "docs/SKILL.md":
|
| 5165 |
-
continue
|
| 5166 |
-
if rel in seen:
|
| 5167 |
-
continue
|
| 5168 |
-
seen.add(rel)
|
| 5169 |
-
try:
|
| 5170 |
-
text = path.read_text(encoding="utf-8", errors="replace")
|
| 5171 |
-
except OSError:
|
| 5172 |
-
continue
|
| 5173 |
-
text = _strip_doc_frontmatter(text)
|
| 5174 |
-
title = _doc_title(text, path.stem.replace("-", " ").title())
|
| 5175 |
-
entries.append({
|
| 5176 |
-
"title": title,
|
| 5177 |
-
"path": rel,
|
| 5178 |
-
"summary": _doc_summary(text),
|
| 5179 |
-
"body": text,
|
| 5180 |
-
})
|
| 5181 |
-
return sorted(entries, key=lambda row: row["path"])
|
| 5182 |
-
|
| 5183 |
-
|
| 5184 |
-
def _docs_nav_from_mkdocs(
|
| 5185 |
-
root: Path,
|
| 5186 |
-
entries_by_path: dict[str, dict[str, str]],
|
| 5187 |
-
) -> list[dict[str, Any]]:
|
| 5188 |
-
mkdocs_path = root / "mkdocs.yml"
|
| 5189 |
-
if not mkdocs_path.is_file():
|
| 5190 |
-
return []
|
| 5191 |
-
try:
|
| 5192 |
-
import yaml # type: ignore[import-untyped]
|
| 5193 |
-
|
| 5194 |
-
data = yaml.load(mkdocs_path.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) or {}
|
| 5195 |
-
except Exception:
|
| 5196 |
-
return []
|
| 5197 |
-
raw_nav = data.get("nav")
|
| 5198 |
-
if not isinstance(raw_nav, list):
|
| 5199 |
-
return []
|
| 5200 |
-
|
| 5201 |
-
def pages_from_nav(value: Any) -> list[tuple[str, str]]:
|
| 5202 |
-
if isinstance(value, str):
|
| 5203 |
-
return [(Path(value).stem.replace("-", " ").title(), f"docs/{value}")]
|
| 5204 |
-
if isinstance(value, list):
|
| 5205 |
-
pages: list[tuple[str, str]] = []
|
| 5206 |
-
for child in value:
|
| 5207 |
-
pages.extend(pages_from_nav(child))
|
| 5208 |
-
return pages
|
| 5209 |
-
if isinstance(value, dict):
|
| 5210 |
-
pages = []
|
| 5211 |
-
for label, child in value.items():
|
| 5212 |
-
if isinstance(child, str):
|
| 5213 |
-
pages.append((str(label), f"docs/{child}"))
|
| 5214 |
-
else:
|
| 5215 |
-
pages.extend(pages_from_nav(child))
|
| 5216 |
-
return pages
|
| 5217 |
-
return []
|
| 5218 |
-
|
| 5219 |
-
tabs: list[dict[str, Any]] = []
|
| 5220 |
-
for item in raw_nav:
|
| 5221 |
-
if not isinstance(item, dict):
|
| 5222 |
-
continue
|
| 5223 |
-
for label, value in item.items():
|
| 5224 |
-
pages = [
|
| 5225 |
-
{**entries_by_path[path], "nav_title": page_label}
|
| 5226 |
-
for page_label, path in pages_from_nav(value)
|
| 5227 |
-
if path in entries_by_path
|
| 5228 |
-
]
|
| 5229 |
-
if pages:
|
| 5230 |
-
tabs.append({
|
| 5231 |
-
"label": str(label),
|
| 5232 |
-
"slug": _doc_anchor(str(label)),
|
| 5233 |
-
"pages": pages,
|
| 5234 |
-
})
|
| 5235 |
-
return tabs
|
| 5236 |
-
|
| 5237 |
-
|
| 5238 |
-
def _docs_tabs(entries: list[dict[str, str]]) -> list[dict[str, Any]]:
|
| 5239 |
-
entries_by_path = {entry["path"]: entry for entry in entries}
|
| 5240 |
-
tabs: list[dict[str, Any]] = []
|
| 5241 |
-
used: set[str] = set()
|
| 5242 |
-
for root in _docs_roots():
|
| 5243 |
-
tabs = _docs_nav_from_mkdocs(root, entries_by_path)
|
| 5244 |
-
if tabs:
|
| 5245 |
-
break
|
| 5246 |
-
for tab in tabs:
|
| 5247 |
-
for page in tab["pages"]:
|
| 5248 |
-
used.add(str(page["path"]))
|
| 5249 |
-
|
| 5250 |
-
repo_pages = [
|
| 5251 |
-
entries_by_path[path]
|
| 5252 |
-
for path in ("README.md", "graph/README.md")
|
| 5253 |
-
if path in entries_by_path
|
| 5254 |
-
]
|
| 5255 |
-
if repo_pages:
|
| 5256 |
-
tabs.append({"label": "Repo", "slug": "repo", "pages": repo_pages})
|
| 5257 |
-
used.update(str(page["path"]) for page in repo_pages)
|
| 5258 |
-
|
| 5259 |
-
other_pages = [
|
| 5260 |
-
entry
|
| 5261 |
-
for entry in entries
|
| 5262 |
-
if entry["path"] not in used and entry["path"] != "docs/SKILL.md"
|
| 5263 |
-
]
|
| 5264 |
-
if other_pages:
|
| 5265 |
-
tabs.append({"label": "Other", "slug": "other", "pages": other_pages})
|
| 5266 |
-
if not tabs and entries:
|
| 5267 |
-
tabs.append({"label": "Docs", "slug": "docs", "pages": entries})
|
| 5268 |
-
return tabs
|
| 5269 |
-
|
| 5270 |
-
|
| 5271 |
-
def _docs_heading_text(raw: str) -> str:
|
| 5272 |
-
raw = re.sub(r"\s+\{#[^}]+\}\s*$", "", raw.strip())
|
| 5273 |
-
raw = re.sub(r"`([^`]+)`", r"\1", raw)
|
| 5274 |
-
raw = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", raw)
|
| 5275 |
-
raw = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", raw)
|
| 5276 |
-
raw = re.sub(r"[*_~]+", "", raw)
|
| 5277 |
-
return re.sub(r"\s+", " ", raw).strip()
|
| 5278 |
-
|
| 5279 |
-
|
| 5280 |
-
def _docs_heading_id(page_anchor: str, title: str, seen: dict[str, int]) -> str:
|
| 5281 |
-
base = f"{page_anchor}-{_doc_anchor(title)}"
|
| 5282 |
-
count = seen.get(base, 0)
|
| 5283 |
-
seen[base] = count + 1
|
| 5284 |
-
return base if count == 0 else f"{base}_{count}"
|
| 5285 |
-
|
| 5286 |
-
|
| 5287 |
-
def _docs_heading_items(markdown_text: str, page_anchor: str) -> list[dict[str, Any]]:
|
| 5288 |
-
headings: list[dict[str, Any]] = []
|
| 5289 |
-
seen: dict[str, int] = {}
|
| 5290 |
-
in_fence = False
|
| 5291 |
-
for line in markdown_text.splitlines():
|
| 5292 |
-
stripped = line.strip()
|
| 5293 |
-
if stripped.startswith(("```", "~~~")):
|
| 5294 |
-
in_fence = not in_fence
|
| 5295 |
-
continue
|
| 5296 |
-
if in_fence:
|
| 5297 |
-
continue
|
| 5298 |
-
match = re.match(r"^(#{1,4})\s+(.+?)\s*#*\s*$", line)
|
| 5299 |
-
if not match:
|
| 5300 |
-
continue
|
| 5301 |
-
level = len(match.group(1))
|
| 5302 |
-
title = _docs_heading_text(match.group(2))
|
| 5303 |
-
if not title:
|
| 5304 |
-
continue
|
| 5305 |
-
heading_id = _docs_heading_id(page_anchor, title, seen)
|
| 5306 |
-
if level >= 2:
|
| 5307 |
-
headings.append({"level": level, "title": title, "id": heading_id})
|
| 5308 |
-
return headings
|
| 5309 |
-
|
| 5310 |
-
|
| 5311 |
-
def _docs_page_anchor(tab_slug: str, path: str) -> str:
|
| 5312 |
-
return f"doc-{tab_slug}-{_doc_anchor(path)}"
|
| 5313 |
-
|
| 5314 |
-
|
| 5315 |
-
def _normalise_doc_path(path: str) -> str:
|
| 5316 |
-
parts: list[str] = []
|
| 5317 |
-
for part in PurePosixPath(path).parts:
|
| 5318 |
-
if part in ("", "."):
|
| 5319 |
-
continue
|
| 5320 |
-
if part == "..":
|
| 5321 |
-
if parts:
|
| 5322 |
-
parts.pop()
|
| 5323 |
-
continue
|
| 5324 |
-
parts.append(part)
|
| 5325 |
-
return PurePosixPath(*parts).as_posix() if parts else ""
|
| 5326 |
-
|
| 5327 |
-
|
| 5328 |
-
def _resolve_docs_link_path(source_path: str, href_path: str, page_anchors: dict[str, tuple[str, str]]) -> str | None:
|
| 5329 |
-
base = PurePosixPath(source_path).parent
|
| 5330 |
-
resolved = _normalise_doc_path((base / href_path).as_posix())
|
| 5331 |
-
candidates = [resolved]
|
| 5332 |
-
if not resolved.endswith(".md"):
|
| 5333 |
-
stripped = resolved.rstrip("/")
|
| 5334 |
-
candidates.extend([f"{stripped}.md", f"{stripped}/index.md"])
|
| 5335 |
-
for candidate in candidates:
|
| 5336 |
-
if candidate in page_anchors:
|
| 5337 |
-
return candidate
|
| 5338 |
-
return None
|
| 5339 |
-
|
| 5340 |
-
|
| 5341 |
-
def _docs_dashboard_link(
|
| 5342 |
-
source_path: str,
|
| 5343 |
-
source_tab: str,
|
| 5344 |
-
source_anchor: str,
|
| 5345 |
-
href: str,
|
| 5346 |
-
page_anchors: dict[str, tuple[str, str]],
|
| 5347 |
-
) -> tuple[str, str, str] | None:
|
| 5348 |
-
href = html.unescape(href).strip()
|
| 5349 |
-
lowered = href.lower()
|
| 5350 |
-
if (
|
| 5351 |
-
not href
|
| 5352 |
-
or lowered.startswith(("http://", "https://", "mailto:", "tel:", "javascript:"))
|
| 5353 |
-
or href.startswith("/")
|
| 5354 |
-
):
|
| 5355 |
-
return None
|
| 5356 |
-
href_path, sep, fragment = href.partition("#")
|
| 5357 |
-
if not href_path:
|
| 5358 |
-
target_tab = source_tab
|
| 5359 |
-
target_anchor = source_anchor
|
| 5360 |
-
else:
|
| 5361 |
-
resolved_path = _resolve_docs_link_path(source_path, href_path, page_anchors)
|
| 5362 |
-
if resolved_path is None:
|
| 5363 |
-
return None
|
| 5364 |
-
target_tab, target_anchor = page_anchors[resolved_path]
|
| 5365 |
-
if sep and fragment:
|
| 5366 |
-
fragment_anchor = _doc_anchor(unquote(fragment))
|
| 5367 |
-
if not fragment_anchor.startswith(target_anchor):
|
| 5368 |
-
fragment_anchor = f"{target_anchor}-{fragment_anchor}"
|
| 5369 |
-
return f"#{fragment_anchor}", target_tab, fragment_anchor
|
| 5370 |
-
return f"#{target_anchor}", target_tab, target_anchor
|
| 5371 |
-
|
| 5372 |
-
|
| 5373 |
-
def _rewrite_docs_links(
|
| 5374 |
-
rendered_html: str,
|
| 5375 |
-
source_path: str,
|
| 5376 |
-
source_tab: str,
|
| 5377 |
-
source_anchor: str,
|
| 5378 |
-
page_anchors: dict[str, tuple[str, str]],
|
| 5379 |
-
) -> str:
|
| 5380 |
-
def replace_link(match: re.Match[str]) -> str:
|
| 5381 |
-
before = match.group(1)
|
| 5382 |
-
href = match.group(2)
|
| 5383 |
-
after = match.group(3)
|
| 5384 |
-
resolved = _docs_dashboard_link(source_path, source_tab, source_anchor, href, page_anchors)
|
| 5385 |
-
if resolved is None:
|
| 5386 |
-
return match.group(0)
|
| 5387 |
-
dashboard_href, target_tab, target_anchor = resolved
|
| 5388 |
-
return (
|
| 5389 |
-
f"<a{before}href=\"{html.escape(dashboard_href, quote=True)}\""
|
| 5390 |
-
f" data-doc-tab=\"{html.escape(target_tab, quote=True)}\""
|
| 5391 |
-
f" data-doc-target=\"{html.escape(target_anchor, quote=True)}\"{after}>"
|
| 5392 |
-
)
|
| 5393 |
-
|
| 5394 |
-
return re.sub(r"<a([^>]*?)href=\"([^\"]+)\"([^>]*)>", replace_link, rendered_html)
|
| 5395 |
|
| 5396 |
|
| 5397 |
def _render_docs_markdown(markdown_text: str, page_anchor: str) -> str:
|
| 5398 |
-
|
| 5399 |
-
|
| 5400 |
-
|
| 5401 |
-
|
| 5402 |
-
import markdown as markdown_lib # type: ignore[import-untyped]
|
| 5403 |
-
|
| 5404 |
-
rendered = str(markdown_lib.markdown(
|
| 5405 |
-
markdown_text,
|
| 5406 |
-
extensions=[
|
| 5407 |
-
"admonition",
|
| 5408 |
-
"attr_list",
|
| 5409 |
-
"def_list",
|
| 5410 |
-
"fenced_code",
|
| 5411 |
-
"footnotes",
|
| 5412 |
-
"md_in_html",
|
| 5413 |
-
"tables",
|
| 5414 |
-
"toc",
|
| 5415 |
-
"pymdownx.details",
|
| 5416 |
-
"pymdownx.superfences",
|
| 5417 |
-
"pymdownx.tabbed",
|
| 5418 |
-
"pymdownx.tasklist",
|
| 5419 |
-
"pymdownx.inlinehilite",
|
| 5420 |
-
],
|
| 5421 |
-
extension_configs={
|
| 5422 |
-
"toc": {
|
| 5423 |
-
"permalink": True,
|
| 5424 |
-
"slugify": lambda value, separator: f"{page_anchor}-{_doc_anchor(value)}",
|
| 5425 |
-
},
|
| 5426 |
-
"pymdownx.tabbed": {"alternate_style": True},
|
| 5427 |
-
"pymdownx.tasklist": {"custom_checkbox": True},
|
| 5428 |
-
},
|
| 5429 |
-
output_format="html5",
|
| 5430 |
-
))
|
| 5431 |
-
return _sanitize_docs_html(rendered)
|
| 5432 |
-
except Exception:
|
| 5433 |
-
return _render_wiki_markdown(markdown_text)
|
| 5434 |
-
|
| 5435 |
-
|
| 5436 |
-
def _sanitize_docs_html(rendered_html: str) -> str:
|
| 5437 |
-
"""Remove active HTML from local docs before embedding in the dashboard."""
|
| 5438 |
-
dangerous_blocks = (
|
| 5439 |
-
"script",
|
| 5440 |
-
"style",
|
| 5441 |
-
"iframe",
|
| 5442 |
-
"object",
|
| 5443 |
-
"embed",
|
| 5444 |
-
"form",
|
| 5445 |
-
"textarea",
|
| 5446 |
-
"select",
|
| 5447 |
-
)
|
| 5448 |
-
dangerous_tags = (
|
| 5449 |
-
"base",
|
| 5450 |
-
"button",
|
| 5451 |
-
"input",
|
| 5452 |
-
"link",
|
| 5453 |
-
"meta",
|
| 5454 |
-
)
|
| 5455 |
-
|
| 5456 |
-
def escape_match(match: re.Match[str]) -> str:
|
| 5457 |
-
return html.escape(match.group(0))
|
| 5458 |
-
|
| 5459 |
-
def parse_attrs(tag_html: str) -> dict[str, str | None] | None:
|
| 5460 |
-
attrs: dict[str, str | None] = {}
|
| 5461 |
-
for match in re.finditer(
|
| 5462 |
-
r"([a-zA-Z_:][\w:.-]*)(?:\s*=\s*(\"[^\"]*\"|'[^']*'|[^\s\"'>]+))?",
|
| 5463 |
-
tag_html,
|
| 5464 |
-
):
|
| 5465 |
-
name = match.group(1).lower()
|
| 5466 |
-
if name == "input":
|
| 5467 |
-
continue
|
| 5468 |
-
if name in attrs:
|
| 5469 |
-
return None
|
| 5470 |
-
raw_value = match.group(2)
|
| 5471 |
-
if raw_value is None:
|
| 5472 |
-
attrs[name] = None
|
| 5473 |
-
continue
|
| 5474 |
-
value = raw_value.strip()
|
| 5475 |
-
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
| 5476 |
-
value = value[1:-1]
|
| 5477 |
-
attrs[name] = html.unescape(value)
|
| 5478 |
-
return attrs
|
| 5479 |
-
|
| 5480 |
-
def is_safe_tabbed_input(tag_html: str) -> bool:
|
| 5481 |
-
attrs = parse_attrs(tag_html)
|
| 5482 |
-
if attrs is None:
|
| 5483 |
-
return False
|
| 5484 |
-
if set(attrs) - {"checked", "id", "name", "type"}:
|
| 5485 |
-
return False
|
| 5486 |
-
input_type = (attrs.get("type") or "").lower()
|
| 5487 |
-
input_id = attrs.get("id") or ""
|
| 5488 |
-
input_name = attrs.get("name") or ""
|
| 5489 |
-
if input_type != "radio":
|
| 5490 |
-
return False
|
| 5491 |
-
if not re.fullmatch(r"__tabbed_\d+_\d+", input_id):
|
| 5492 |
-
return False
|
| 5493 |
-
if not re.fullmatch(r"__tabbed_\d+", input_name):
|
| 5494 |
-
return False
|
| 5495 |
-
if not input_id.startswith(f"{input_name}_"):
|
| 5496 |
-
return False
|
| 5497 |
-
checked = attrs.get("checked")
|
| 5498 |
-
return checked is None or checked.lower() == "checked"
|
| 5499 |
-
|
| 5500 |
-
def escape_input_unless_safe(match: re.Match[str]) -> str:
|
| 5501 |
-
tag_html = match.group(0)
|
| 5502 |
-
if is_safe_tabbed_input(tag_html):
|
| 5503 |
-
return tag_html
|
| 5504 |
-
return html.escape(tag_html)
|
| 5505 |
-
|
| 5506 |
-
for tag in dangerous_blocks:
|
| 5507 |
-
rendered_html = re.sub(
|
| 5508 |
-
rf"<\s*{tag}\b[^>]*>.*?<\s*/\s*{tag}\s*>",
|
| 5509 |
-
escape_match,
|
| 5510 |
-
rendered_html,
|
| 5511 |
-
flags=re.IGNORECASE | re.DOTALL,
|
| 5512 |
-
)
|
| 5513 |
-
rendered_html = re.sub(
|
| 5514 |
-
rf"<\s*/?\s*{tag}\b[^>]*>",
|
| 5515 |
-
escape_match,
|
| 5516 |
-
rendered_html,
|
| 5517 |
-
flags=re.IGNORECASE,
|
| 5518 |
-
)
|
| 5519 |
-
for tag in dangerous_tags:
|
| 5520 |
-
if tag == "input":
|
| 5521 |
-
rendered_html = re.sub(
|
| 5522 |
-
rf"<\s*/?\s*{tag}\b[^>]*>",
|
| 5523 |
-
escape_input_unless_safe,
|
| 5524 |
-
rendered_html,
|
| 5525 |
-
flags=re.IGNORECASE,
|
| 5526 |
-
)
|
| 5527 |
-
continue
|
| 5528 |
-
rendered_html = re.sub(
|
| 5529 |
-
rf"<\s*/?\s*{tag}\b[^>]*>",
|
| 5530 |
-
escape_match,
|
| 5531 |
-
rendered_html,
|
| 5532 |
-
flags=re.IGNORECASE,
|
| 5533 |
-
)
|
| 5534 |
-
|
| 5535 |
-
rendered_html = re.sub(
|
| 5536 |
-
r"\s+on[a-zA-Z0-9_-]+\s*=\s*(\"[^\"]*\"|'[^']*'|[^\s>]+)",
|
| 5537 |
-
"",
|
| 5538 |
-
rendered_html,
|
| 5539 |
-
flags=re.IGNORECASE,
|
| 5540 |
-
)
|
| 5541 |
-
rendered_html = re.sub(
|
| 5542 |
-
r"\s+(href|src)\s*=\s*([\"'])\s*(javascript:|data:text/html)",
|
| 5543 |
-
r" \1=\2#",
|
| 5544 |
-
rendered_html,
|
| 5545 |
-
flags=re.IGNORECASE,
|
| 5546 |
-
)
|
| 5547 |
-
return rendered_html
|
| 5548 |
-
|
| 5549 |
-
|
| 5550 |
-
def _docs_search_text(entry: dict[str, str]) -> str:
|
| 5551 |
-
text = f"{entry['title']} {entry['path']} {entry['summary']} {entry['body']}"
|
| 5552 |
-
text = re.sub(r"```.*?```", " ", text, flags=re.DOTALL)
|
| 5553 |
-
text = re.sub(r"!!!\s+\w+(?:\s+\"[^\"]+\")?", " ", text)
|
| 5554 |
-
text = re.sub(r"<[^>]+>", " ", text)
|
| 5555 |
-
text = re.sub(r"[*_`#>\[\]().!:-]+", " ", text)
|
| 5556 |
-
return re.sub(r"\s+", " ", text).strip().lower()
|
| 5557 |
-
|
| 5558 |
-
|
| 5559 |
-
def _render_docs_sidebar_page(
|
| 5560 |
-
entry: dict[str, str],
|
| 5561 |
-
tab_slug: str,
|
| 5562 |
-
page_anchors: dict[str, tuple[str, str]],
|
| 5563 |
-
) -> str:
|
| 5564 |
-
page_anchor = page_anchors.get(entry["path"], (tab_slug, _docs_page_anchor(tab_slug, entry["path"])))[1]
|
| 5565 |
-
title = str(entry.get("nav_title") or entry["title"])
|
| 5566 |
-
page_search = _docs_search_text(entry)
|
| 5567 |
-
heading_links = "".join(
|
| 5568 |
-
"<a class='docs-heading-link "
|
| 5569 |
-
f"docs-heading-level-{int(heading['level'])}' "
|
| 5570 |
-
f"href='#{html.escape(str(heading['id']))}' "
|
| 5571 |
-
f"data-doc-link data-doc-tab='{html.escape(tab_slug)}' "
|
| 5572 |
-
f"data-doc-target='{html.escape(str(heading['id']))}' "
|
| 5573 |
-
f"data-doc-search='{html.escape(str(heading['title']).lower())}' "
|
| 5574 |
-
f"data-doc-label='{html.escape(title)} / {html.escape(str(heading['title']))}'>"
|
| 5575 |
-
f"{html.escape(str(heading['title']))}</a>"
|
| 5576 |
-
for heading in _docs_heading_items(entry["body"], page_anchor)
|
| 5577 |
-
)
|
| 5578 |
-
headings = f"<div class='docs-heading-list'>{heading_links}</div>" if heading_links else ""
|
| 5579 |
-
return (
|
| 5580 |
-
"<div class='docs-toc-page'>"
|
| 5581 |
-
f"<a class='docs-page-link' href='#{html.escape(page_anchor)}' "
|
| 5582 |
-
f"data-doc-link data-doc-tab='{html.escape(tab_slug)}' "
|
| 5583 |
-
f"data-doc-target='{html.escape(page_anchor)}' "
|
| 5584 |
-
f"data-doc-search='{html.escape(page_search)}' "
|
| 5585 |
-
f"data-doc-label='{html.escape(title)}'>{html.escape(title)}</a>"
|
| 5586 |
-
f"{headings}"
|
| 5587 |
-
"</div>"
|
| 5588 |
-
)
|
| 5589 |
-
|
| 5590 |
-
|
| 5591 |
-
def _render_docs_page(
|
| 5592 |
-
entry: dict[str, str],
|
| 5593 |
-
tab_slug: str,
|
| 5594 |
-
page_anchors: dict[str, tuple[str, str]],
|
| 5595 |
-
) -> str:
|
| 5596 |
-
page_anchor = page_anchors.get(entry["path"], (tab_slug, _docs_page_anchor(tab_slug, entry["path"])))[1]
|
| 5597 |
-
source_url = f"https://github.com/stevesolun/ctx/blob/main/{quote(entry['path'])}"
|
| 5598 |
-
body_html = _render_docs_markdown(entry["body"], page_anchor)
|
| 5599 |
-
body_html = _rewrite_docs_links(body_html, entry["path"], tab_slug, page_anchor, page_anchors)
|
| 5600 |
-
return (
|
| 5601 |
-
f"<article id='{html.escape(page_anchor)}' class='docs-page wiki-body' "
|
| 5602 |
-
f"data-doc-page='{html.escape(_docs_search_text(entry))}'>"
|
| 5603 |
-
"<div class='docs-page-source'>"
|
| 5604 |
-
f"<code>{html.escape(entry['path'])}</code>"
|
| 5605 |
-
f"<a href='{html.escape(source_url)}'>source -></a>"
|
| 5606 |
-
"</div>"
|
| 5607 |
-
f"{body_html}"
|
| 5608 |
-
"</article>"
|
| 5609 |
)
|
| 5610 |
|
| 5611 |
|
| 5612 |
def _render_docs() -> str:
|
| 5613 |
-
|
| 5614 |
-
|
| 5615 |
-
|
| 5616 |
-
|
| 5617 |
-
|
| 5618 |
-
|
| 5619 |
-
|
| 5620 |
-
|
| 5621 |
-
|
| 5622 |
-
|
| 5623 |
-
|
| 5624 |
-
|
| 5625 |
-
public_docs_url = "https://stevesolun.github.io/ctx/"
|
| 5626 |
-
tabs = _docs_tabs(entries)
|
| 5627 |
-
if not tabs:
|
| 5628 |
-
body = (
|
| 5629 |
-
"<h1>Docs</h1>"
|
| 5630 |
-
"<div class='card'><strong>No local docs found.</strong>"
|
| 5631 |
-
f"<p class='muted'>Open the public docs at "
|
| 5632 |
-
f"<a href='{public_docs_url}'>{public_docs_url}</a>.</p></div>"
|
| 5633 |
-
)
|
| 5634 |
-
html_out = _layout("Docs", body)
|
| 5635 |
-
_write_docs_render_disk_cache(cache_token, html_out)
|
| 5636 |
-
_DOCS_RENDER_CACHE_KEY = cache_key
|
| 5637 |
-
_DOCS_RENDER_CACHE_VALUE = html_out
|
| 5638 |
-
return html_out
|
| 5639 |
-
|
| 5640 |
-
tab_buttons = "".join(
|
| 5641 |
-
f"<button class='docs-tab-button{' active' if idx == 0 else ''}' "
|
| 5642 |
-
f"type='button' data-doc-tab='{html.escape(str(tab['slug']))}'>"
|
| 5643 |
-
f"{html.escape(str(tab['label']))}</button>"
|
| 5644 |
-
for idx, tab in enumerate(tabs)
|
| 5645 |
-
)
|
| 5646 |
-
panels: list[str] = []
|
| 5647 |
-
page_count = sum(len(list(tab["pages"])) for tab in tabs)
|
| 5648 |
-
page_anchors: dict[str, tuple[str, str]] = {}
|
| 5649 |
-
for tab in tabs:
|
| 5650 |
-
tab_slug = str(tab["slug"])
|
| 5651 |
-
for page in list(tab["pages"]):
|
| 5652 |
-
page_anchors[str(page["path"])] = (tab_slug, _docs_page_anchor(tab_slug, str(page["path"])))
|
| 5653 |
-
for idx, tab in enumerate(tabs):
|
| 5654 |
-
tab_slug = str(tab["slug"])
|
| 5655 |
-
pages = list(tab["pages"])
|
| 5656 |
-
page_links = "".join(
|
| 5657 |
-
_render_docs_sidebar_page(page, tab_slug, page_anchors)
|
| 5658 |
-
for page in pages
|
| 5659 |
-
)
|
| 5660 |
-
page_bodies = "".join(_render_docs_page(page, tab_slug, page_anchors) for page in pages)
|
| 5661 |
-
hidden = " hidden" if idx else ""
|
| 5662 |
-
panels.append(
|
| 5663 |
-
f"<section class='docs-tab-panel' data-doc-panel='{html.escape(tab_slug)}'{hidden}>"
|
| 5664 |
-
"<div class='docs-reader'>"
|
| 5665 |
-
f"<aside class='docs-page-list'>{page_links}</aside>"
|
| 5666 |
-
f"<div>{page_bodies}</div>"
|
| 5667 |
-
"</div>"
|
| 5668 |
-
"</section>"
|
| 5669 |
-
)
|
| 5670 |
-
|
| 5671 |
-
body = (
|
| 5672 |
-
"<div class='docs-shell'>"
|
| 5673 |
-
"<section class='docs-hero'>"
|
| 5674 |
-
"<div class='docs-hero-grid'>"
|
| 5675 |
-
"<div>"
|
| 5676 |
-
"<div class='docs-eyebrow'>Repo documentation</div>"
|
| 5677 |
-
"<h1>Docs</h1>"
|
| 5678 |
-
"<p>Read the same Markdown tree and MkDocs nav shipped with the repo. "
|
| 5679 |
-
"Use tabs for sections, search across local docs, and jump to source when you need the exact file.</p>"
|
| 5680 |
-
"</div>"
|
| 5681 |
-
"<div class='docs-hero-meta'>"
|
| 5682 |
-
f"<span class='docs-stat'>{len(tabs)} sections</span>"
|
| 5683 |
-
f"<span class='docs-stat'>{page_count} pages</span>"
|
| 5684 |
-
"</div>"
|
| 5685 |
-
"</div>"
|
| 5686 |
-
"<div class='docs-actions'>"
|
| 5687 |
-
"<div class='docs-search-wrap'>"
|
| 5688 |
-
"<input id='docs-search' type='text' placeholder='Search local docs...'>"
|
| 5689 |
-
"</div>"
|
| 5690 |
-
f"<a class='docs-public-link' href='{public_docs_url}'>public docs -></a>"
|
| 5691 |
-
"</div>"
|
| 5692 |
-
"<div id='docs-search-results' class='docs-search-results' hidden></div>"
|
| 5693 |
-
"</section>"
|
| 5694 |
-
f"<div class='docs-tabs' role='tablist'>{tab_buttons}</div>"
|
| 5695 |
-
+ "".join(panels)
|
| 5696 |
-
+ _monitor_inline_script("monitor-docs.js")
|
| 5697 |
-
+ "</div>"
|
| 5698 |
)
|
| 5699 |
-
html_out = _layout("Docs", body)
|
| 5700 |
-
_write_docs_render_disk_cache(cache_token, html_out)
|
| 5701 |
-
_DOCS_RENDER_CACHE_KEY = cache_key
|
| 5702 |
-
_DOCS_RENDER_CACHE_VALUE = html_out
|
| 5703 |
-
return html_out
|
| 5704 |
|
| 5705 |
|
| 5706 |
def _render_manage(mutations_enabled: bool | None = None) -> str:
|
|
@@ -6093,10 +5320,6 @@ def _kpi_summary_cache_key(sidecar_dir: Path) -> tuple[Any, ...]:
|
|
| 6093 |
return tuple(parts)
|
| 6094 |
|
| 6095 |
|
| 6096 |
-
def _kpi_summary_cache_token(cache_key: tuple[Any, ...]) -> str:
|
| 6097 |
-
return json.dumps(cache_key, separators=(",", ":"), sort_keys=True)
|
| 6098 |
-
|
| 6099 |
-
|
| 6100 |
def _kpi_summary_disk_cache_path(sidecar_dir: Path) -> Path:
|
| 6101 |
return sidecar_dir / ".dashboard-kpi-summary.json"
|
| 6102 |
|
|
@@ -6136,15 +5359,8 @@ def _read_kpi_summary_disk_cache(
|
|
| 6136 |
cache_token: str,
|
| 6137 |
summary_cls: Any,
|
| 6138 |
) -> Any | None:
|
| 6139 |
-
|
| 6140 |
-
|
| 6141 |
-
_kpi_summary_disk_cache_path(sidecar_dir).read_text(encoding="utf-8")
|
| 6142 |
-
)
|
| 6143 |
-
except (OSError, json.JSONDecodeError):
|
| 6144 |
-
return None
|
| 6145 |
-
if not isinstance(data, dict):
|
| 6146 |
-
return None
|
| 6147 |
-
if data.get("schema_version") != 1 or data.get("cache_token") != cache_token:
|
| 6148 |
return None
|
| 6149 |
return _dashboard_summary_from_dict(summary_cls, data.get("summary"))
|
| 6150 |
|
|
@@ -6154,19 +5370,12 @@ def _write_kpi_summary_disk_cache(
|
|
| 6154 |
cache_token: str,
|
| 6155 |
summary: Any,
|
| 6156 |
) -> None:
|
| 6157 |
-
|
| 6158 |
-
|
| 6159 |
-
|
| 6160 |
-
|
| 6161 |
-
|
| 6162 |
-
|
| 6163 |
-
_atomic_write_text(
|
| 6164 |
-
_kpi_summary_disk_cache_path(sidecar_dir),
|
| 6165 |
-
json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n",
|
| 6166 |
-
encoding="utf-8",
|
| 6167 |
-
)
|
| 6168 |
-
except (OSError, TypeError, ValueError):
|
| 6169 |
-
return
|
| 6170 |
|
| 6171 |
|
| 6172 |
def _kpi_summary():
|
|
@@ -6193,7 +5402,7 @@ def _kpi_summary():
|
|
| 6193 |
and time.monotonic() - _KPI_SUMMARY_CACHE_AT < _KPI_SUMMARY_CACHE_SECONDS
|
| 6194 |
):
|
| 6195 |
return _KPI_SUMMARY_CACHE_VALUE
|
| 6196 |
-
cache_token =
|
| 6197 |
summary = _read_kpi_summary_disk_cache(sidecar_dir, cache_token, DashboardSummary)
|
| 6198 |
if summary is not None:
|
| 6199 |
_KPI_SUMMARY_CACHE_KEY = cache_key
|
|
@@ -6799,119 +6008,21 @@ def _perform_load(
|
|
| 6799 |
command: str | None = None,
|
| 6800 |
json_config: str | None = None,
|
| 6801 |
) -> tuple[bool, str]:
|
| 6802 |
-
|
| 6803 |
-
|
| 6804 |
-
|
| 6805 |
-
|
| 6806 |
-
|
| 6807 |
-
|
| 6808 |
-
|
| 6809 |
-
if entity_type == "harness":
|
| 6810 |
-
return (
|
| 6811 |
-
False,
|
| 6812 |
-
"harness installs are managed by ctx-harness-install; "
|
| 6813 |
-
f"run: ctx-harness-install {slug} --dry-run",
|
| 6814 |
-
)
|
| 6815 |
-
result: Any
|
| 6816 |
-
try:
|
| 6817 |
-
if entity_type == "agent":
|
| 6818 |
-
from ctx.adapters.claude_code.install.agent_install import install_agent
|
| 6819 |
-
result = install_agent(
|
| 6820 |
-
slug,
|
| 6821 |
-
wiki_dir=_wiki_dir(),
|
| 6822 |
-
agents_dir=_claude_dir() / "agents",
|
| 6823 |
-
)
|
| 6824 |
-
elif entity_type == "mcp-server":
|
| 6825 |
-
from ctx.adapters.claude_code.install.mcp_install import install_mcp
|
| 6826 |
-
result = install_mcp(
|
| 6827 |
-
slug,
|
| 6828 |
-
wiki_dir=_wiki_dir(),
|
| 6829 |
-
command=command,
|
| 6830 |
-
json_config=json_config,
|
| 6831 |
-
auto=True,
|
| 6832 |
-
)
|
| 6833 |
-
else:
|
| 6834 |
-
from ctx.adapters.claude_code.install.skill_install import install_skill
|
| 6835 |
-
result = install_skill(
|
| 6836 |
-
slug,
|
| 6837 |
-
wiki_dir=_wiki_dir(),
|
| 6838 |
-
skills_dir=_claude_dir() / "skills",
|
| 6839 |
-
security_scan=True,
|
| 6840 |
-
)
|
| 6841 |
-
except ImportError as exc:
|
| 6842 |
-
return False, f"install import failed: {exc}"
|
| 6843 |
-
except Exception as exc: # noqa: BLE001
|
| 6844 |
-
return False, f"{type(exc).__name__}: {exc}"
|
| 6845 |
-
if result.status not in ("installed", "skipped-existing"):
|
| 6846 |
-
return False, f"load failed: {result.message or result.status}"
|
| 6847 |
-
_log_dashboard_entity_event(entity_type, "loaded", slug)
|
| 6848 |
-
message = result.message or f"loaded {entity_type}:{slug}"
|
| 6849 |
-
scan = getattr(result, "security_scan", None)
|
| 6850 |
-
scan_output = str(getattr(scan, "output", "") or "").strip()
|
| 6851 |
-
if scan_output:
|
| 6852 |
-
message = f"{message}\n\nSkillSpector report:\n{scan_output}"
|
| 6853 |
-
return True, message
|
| 6854 |
|
| 6855 |
|
| 6856 |
def _perform_unload(slug: str, entity_type: str = "skill") -> tuple[bool, str]:
|
| 6857 |
-
|
| 6858 |
-
|
| 6859 |
-
|
| 6860 |
-
|
| 6861 |
-
|
| 6862 |
-
- ``mcp-server``: ``mcp_install.uninstall_mcp`` — wraps
|
| 6863 |
-
``claude mcp remove`` subprocess. Requires the claude CLI on
|
| 6864 |
-
PATH; errors surface to the caller.
|
| 6865 |
-
"""
|
| 6866 |
-
if not _is_safe_slug(slug):
|
| 6867 |
-
return False, f"invalid slug: {slug!r}"
|
| 6868 |
-
normalized_entity_type = _normalize_dashboard_entity_type(entity_type)
|
| 6869 |
-
if normalized_entity_type is None:
|
| 6870 |
-
return False, f"unsupported entity_type: {entity_type!r}"
|
| 6871 |
-
entity_type = normalized_entity_type
|
| 6872 |
-
if entity_type == "harness":
|
| 6873 |
-
return (
|
| 6874 |
-
False,
|
| 6875 |
-
"harness installs are managed by ctx-harness-install; "
|
| 6876 |
-
f"run: ctx-harness-install {slug} --uninstall --dry-run",
|
| 6877 |
-
)
|
| 6878 |
-
if entity_type == "mcp-server":
|
| 6879 |
-
try:
|
| 6880 |
-
from ctx.adapters.claude_code.install.mcp_install import uninstall_mcp
|
| 6881 |
-
except ImportError as exc:
|
| 6882 |
-
return False, f"mcp_install import failed: {exc}"
|
| 6883 |
-
try:
|
| 6884 |
-
result = uninstall_mcp(slug, wiki_dir=_wiki_dir())
|
| 6885 |
-
except Exception as exc: # noqa: BLE001
|
| 6886 |
-
return False, f"{type(exc).__name__}: {exc}"
|
| 6887 |
-
if result.status not in ("uninstalled",):
|
| 6888 |
-
return False, f"uninstall failed: {result.message or result.status}"
|
| 6889 |
-
_log_dashboard_entity_event("mcp-server", "unloaded", slug)
|
| 6890 |
-
return True, f"unloaded mcp:{slug}"
|
| 6891 |
-
|
| 6892 |
-
if entity_type == "agent":
|
| 6893 |
-
try:
|
| 6894 |
-
removed_entries = _remove_loaded_manifest_entry(slug, "agent")
|
| 6895 |
-
except Exception as exc: # noqa: BLE001
|
| 6896 |
-
return False, f"{type(exc).__name__}: {exc}"
|
| 6897 |
-
if not removed_entries:
|
| 6898 |
-
return False, f"{slug} was not in the loaded set"
|
| 6899 |
-
_log_dashboard_entity_event("agent", "unloaded", slug)
|
| 6900 |
-
return True, f"unloaded {slug}"
|
| 6901 |
-
|
| 6902 |
-
# Skills keep using the existing skill_unload module so skill-events.jsonl
|
| 6903 |
-
# remains compatible with older usage and retention analytics.
|
| 6904 |
-
try:
|
| 6905 |
-
from ctx.adapters.claude_code.install.skill_unload import unload_from_session
|
| 6906 |
-
except ImportError as exc:
|
| 6907 |
-
return False, f"skill_unload import failed: {exc}"
|
| 6908 |
-
try:
|
| 6909 |
-
removed = unload_from_session([slug], entity_type=entity_type)
|
| 6910 |
-
except Exception as exc: # noqa: BLE001
|
| 6911 |
-
return False, f"{type(exc).__name__}: {exc}"
|
| 6912 |
-
if not removed:
|
| 6913 |
-
return False, f"{slug} was not in the loaded set"
|
| 6914 |
-
return True, f"unloaded {', '.join(removed)}"
|
| 6915 |
|
| 6916 |
|
| 6917 |
# ─── HTTP handler ────────────────────────────────────────────────────────────
|
|
|
|
| 76 |
from http.cookies import CookieError, SimpleCookie
|
| 77 |
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 78 |
from importlib.resources import files
|
| 79 |
+
from pathlib import Path
|
| 80 |
from typing import Any
|
| 81 |
from urllib.parse import quote, unquote, urlsplit
|
| 82 |
|
| 83 |
+
from ctx import dashboard_docs, dashboard_entities, dashboard_graph
|
| 84 |
+
from ctx.core import entity_types as core_entity_types
|
| 85 |
from ctx.core.wiki import wiki_queue
|
| 86 |
from ctx.core.wiki.wiki_utils import parse_frontmatter_and_body
|
| 87 |
from ctx.utils._file_lock import file_lock
|
|
|
|
| 104 |
_KPI_SUMMARY_CACHE_KEY: tuple[Any, ...] | None = None
|
| 105 |
_KPI_SUMMARY_CACHE_VALUE: Any | None = None
|
| 106 |
_KPI_SUMMARY_CACHE_AT = 0.0
|
|
|
|
|
|
|
| 107 |
_WIKI_RENDER_CACHE_KEY: tuple[Any, ...] | None = None
|
| 108 |
_WIKI_RENDER_CACHE_VALUE: str | None = None
|
| 109 |
_WIKI_INDEX_LIMIT_PER_TYPE = 500
|
|
|
|
| 229 |
|
| 230 |
|
| 231 |
def _mcp_shard(slug: str) -> str:
|
| 232 |
+
return core_entity_types.mcp_shard(slug)
|
|
|
|
| 233 |
|
| 234 |
|
| 235 |
+
_DASHBOARD_ENTITY_SOURCES: tuple[tuple[str, str, bool], ...] = core_entity_types.entity_source_specs()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
_DASHBOARD_ENTITY_TYPES: tuple[str, ...] = tuple(
|
| 237 |
entity_type for _, entity_type, _ in _DASHBOARD_ENTITY_SOURCES
|
| 238 |
)
|
|
|
|
| 240 |
|
| 241 |
|
| 242 |
def _normalize_dashboard_entity_type(raw: object) -> str | None:
|
| 243 |
+
return core_entity_types.normalize_entity_type(
|
| 244 |
+
raw,
|
| 245 |
+
allowed=_DASHBOARD_ENTITY_TYPES,
|
| 246 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
|
| 249 |
def _audit_entity_type(row: dict) -> str | None:
|
|
|
|
| 274 |
# Validate slug so a crafted request can't escape the wiki tree.
|
| 275 |
if not _is_safe_slug(slug):
|
| 276 |
return None
|
| 277 |
+
for _sub, current_type, _recursive in _DASHBOARD_ENTITY_SOURCES:
|
| 278 |
if entity_type is not None and entity_type != current_type:
|
| 279 |
continue
|
| 280 |
+
p = core_entity_types.entity_page_path(_wiki_dir(), current_type, slug)
|
| 281 |
+
if p is None:
|
| 282 |
+
continue
|
|
|
|
|
|
|
| 283 |
if p.exists():
|
| 284 |
return p
|
| 285 |
return None
|
|
|
|
| 292 |
normalized = _normalize_dashboard_entity_type(entity_type)
|
| 293 |
if normalized is None:
|
| 294 |
raise ValueError(f"unsupported entity_type: {entity_type!r}")
|
| 295 |
+
path = core_entity_types.entity_page_path(_wiki_dir(), normalized, slug)
|
| 296 |
+
if path is None:
|
| 297 |
+
raise ValueError(f"unsupported entity_type: {entity_type!r}")
|
| 298 |
+
return path
|
|
|
|
|
|
|
|
|
|
| 299 |
|
| 300 |
|
| 301 |
def _iter_wiki_entity_paths(
|
|
|
|
| 347 |
*,
|
| 348 |
limit: int = 80,
|
| 349 |
) -> list[dict[str, Any]]:
|
| 350 |
+
return dashboard_entities.search_wiki_entities(
|
| 351 |
+
query,
|
| 352 |
+
entity_type,
|
| 353 |
+
limit=limit,
|
| 354 |
+
deps=_entity_crud_deps(),
|
| 355 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
|
| 357 |
|
| 358 |
def _queue_entity_refresh(
|
|
|
|
| 383 |
)
|
| 384 |
|
| 385 |
|
| 386 |
+
def _write_entity_text(path: Path, content: str) -> None:
|
| 387 |
+
_safe_atomic_write_text(path, content, encoding="utf-8")
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def _entity_crud_deps() -> dashboard_entities.EntityCrudDeps:
|
| 391 |
+
return dashboard_entities.EntityCrudDeps(
|
| 392 |
+
is_safe_slug=_is_safe_slug,
|
| 393 |
+
normalize_entity_type=_normalize_dashboard_entity_type,
|
| 394 |
+
wiki_entity_detail=_wiki_entity_detail,
|
| 395 |
+
wiki_entity_target_path=_wiki_entity_target_path,
|
| 396 |
+
wiki_entity_path=_wiki_entity_path,
|
| 397 |
+
iter_wiki_entity_paths=_iter_wiki_entity_paths,
|
| 398 |
+
read_manifest=_read_manifest,
|
| 399 |
+
perform_unload=_perform_unload,
|
| 400 |
+
queue_entity_refresh=lambda entity_type, slug, entity_path, content, action: _queue_entity_refresh(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
entity_type=entity_type,
|
| 402 |
slug=slug,
|
| 403 |
+
entity_path=entity_path,
|
| 404 |
content=content,
|
| 405 |
+
action=action,
|
| 406 |
+
),
|
| 407 |
+
file_lock=file_lock,
|
| 408 |
+
write_entity_text=_write_entity_text,
|
| 409 |
+
parse_frontmatter=_parse_frontmatter,
|
| 410 |
+
frontmatter_tags=lambda value: _frontmatter_tags(value, limit=None),
|
| 411 |
+
frontmatter_text=_frontmatter_text,
|
| 412 |
+
display_slug=_display_slug,
|
| 413 |
+
display_label=lambda value: _display_label(value),
|
| 414 |
+
entity_wiki_href=_entity_wiki_href,
|
| 415 |
+
)
|
| 416 |
|
| 417 |
|
| 418 |
+
def _entity_runtime_deps() -> dashboard_entities.EntityRuntimeDeps:
|
| 419 |
+
return dashboard_entities.EntityRuntimeDeps(
|
| 420 |
+
is_safe_slug=_is_safe_slug,
|
| 421 |
+
normalize_entity_type=_normalize_dashboard_entity_type,
|
| 422 |
+
wiki_dir=_wiki_dir,
|
| 423 |
+
claude_dir=_claude_dir,
|
| 424 |
+
log_dashboard_entity_event=_log_dashboard_entity_event,
|
| 425 |
+
remove_loaded_manifest_entry=_remove_loaded_manifest_entry,
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def _upsert_wiki_entity(payload: dict[str, Any]) -> tuple[bool, str]:
|
| 430 |
+
return dashboard_entities.upsert_wiki_entity(payload, deps=_entity_crud_deps())
|
| 431 |
|
| 432 |
|
| 433 |
def _delete_wiki_entity(slug: str, entity_type: str) -> tuple[bool, str]:
|
| 434 |
+
return dashboard_entities.delete_wiki_entity(
|
| 435 |
+
slug,
|
| 436 |
+
entity_type,
|
| 437 |
+
deps=_entity_crud_deps(),
|
| 438 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
|
| 440 |
|
| 441 |
def _parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
|
|
|
| 2849 |
frontier = next_frontier
|
| 2850 |
if len(nodes_out) >= limit:
|
| 2851 |
break
|
| 2852 |
+
return dashboard_graph.enrich_neighborhood({
|
| 2853 |
"nodes": list(nodes_out.values()),
|
| 2854 |
"edges": edges_out,
|
| 2855 |
"center": center,
|
| 2856 |
"resolved": resolved or {"source": "dashboard-index"},
|
| 2857 |
"suggestions": [],
|
| 2858 |
+
}, source="dashboard-index")
|
| 2859 |
except (OSError, sqlite3.Error, json.JSONDecodeError, zlib.error, KeyError, TypeError):
|
| 2860 |
return None
|
| 2861 |
finally:
|
|
|
|
| 3018 |
if len(nodes_out) >= limit:
|
| 3019 |
break
|
| 3020 |
|
| 3021 |
+
return dashboard_graph.enrich_neighborhood({
|
| 3022 |
"nodes": list(nodes_out.values()),
|
| 3023 |
"edges": edges_out,
|
| 3024 |
"center": center,
|
| 3025 |
"resolved": resolved,
|
| 3026 |
"suggestions": suggestions,
|
| 3027 |
+
}, source="networkx")
|
| 3028 |
|
| 3029 |
|
| 3030 |
def _graph_stats() -> dict:
|
|
|
|
| 3960 |
"<span id='graph-match-count' class='muted'>—</span>"
|
| 3961 |
"</div>"
|
| 3962 |
"<div class='card'><span id='msg' class='muted'></span></div>"
|
| 3963 |
+
"<div class='card'><strong>Why this view?</strong>"
|
| 3964 |
+
"<p id='graph-explanation' class='muted' "
|
| 3965 |
+
"style='font-size:0.78rem; margin:0.45rem 0 0 0;'>"
|
| 3966 |
+
"Search a slug to see why ctx picked this neighborhood and how to read it."
|
| 3967 |
+
"</p></div>"
|
| 3968 |
"</aside>"
|
| 3969 |
# Right: graph list panel
|
| 3970 |
"<div id='cy' style='width:100%; height:75vh; border:1px solid #ddd; "
|
|
|
|
| 4032 |
" const weight = Number(d.weight || 0).toFixed(3);\n"
|
| 4033 |
" return nodeSlug(d.source) + ' ↔ ' + nodeSlug(d.target) + ' · weight ' + weight + ' · shared: ' + tags + ' · reasons: ' + reasons;\n"
|
| 4034 |
"}\n"
|
| 4035 |
+
"function graphExplanation(g) {\n"
|
| 4036 |
+
" const e = g.explanations || {};\n"
|
| 4037 |
+
" return [e.focus, e.source, e.search, e.layout, e.edges].filter(Boolean).join(' ');\n"
|
| 4038 |
+
"}\n"
|
| 4039 |
"function renderGraph3d(g) {\n"
|
| 4040 |
" const nodes = g.nodes || [];\n"
|
| 4041 |
" const edges = g.edges || [];\n"
|
|
|
|
| 4183 |
" try { renderGraph3d(g); } catch (err) { renderFallback(g); }\n"
|
| 4184 |
" const resolved = g.resolved && g.resolved.query !== g.resolved.slug ? ' · showing ' + g.resolved.slug + ' for ' + g.resolved.query : '';\n"
|
| 4185 |
" document.getElementById('msg').textContent = fmtCount(g.nodes.length) + ' nodes · ' + fmtCount(g.edges.length) + ' edges' + resolved;\n"
|
| 4186 |
+
" document.getElementById('graph-explanation').textContent = graphExplanation(g);\n"
|
| 4187 |
" applyFilters();\n"
|
| 4188 |
"}\n"
|
| 4189 |
"function selectedFocusType() { return document.getElementById('focus-type').value || ''; }\n"
|
|
|
|
| 4682 |
)
|
| 4683 |
|
| 4684 |
|
| 4685 |
+
def _disk_cache_token(cache_key: tuple[Any, ...]) -> str:
|
| 4686 |
return json.dumps(cache_key, separators=(",", ":"), sort_keys=True)
|
| 4687 |
|
| 4688 |
|
| 4689 |
+
def _read_disk_cache_payload(path: Path, cache_token: str) -> dict[str, Any] | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4690 |
try:
|
| 4691 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 4692 |
except (OSError, json.JSONDecodeError):
|
| 4693 |
return None
|
| 4694 |
if not isinstance(data, dict):
|
| 4695 |
return None
|
| 4696 |
if data.get("schema_version") != 1 or data.get("cache_token") != cache_token:
|
| 4697 |
return None
|
| 4698 |
+
return data
|
|
|
|
| 4699 |
|
| 4700 |
|
| 4701 |
+
def _write_disk_cache_payload(
|
| 4702 |
+
path: Path,
|
| 4703 |
+
cache_token: str,
|
| 4704 |
+
payload: dict[str, Any],
|
| 4705 |
+
*,
|
| 4706 |
+
sort_keys: bool = False,
|
| 4707 |
+
) -> None:
|
| 4708 |
try:
|
| 4709 |
_atomic_write_text(
|
| 4710 |
+
path,
|
| 4711 |
+
json.dumps(
|
| 4712 |
+
{
|
| 4713 |
+
"schema_version": 1,
|
| 4714 |
+
"cache_token": cache_token,
|
| 4715 |
+
**payload,
|
| 4716 |
+
},
|
| 4717 |
+
ensure_ascii=False,
|
| 4718 |
+
sort_keys=sort_keys,
|
| 4719 |
+
) + "\n",
|
| 4720 |
encoding="utf-8",
|
| 4721 |
)
|
| 4722 |
except (OSError, TypeError, ValueError):
|
| 4723 |
return
|
| 4724 |
|
| 4725 |
|
| 4726 |
+
def _read_html_disk_cache(path: Path, cache_token: str) -> str | None:
|
| 4727 |
+
data = _read_disk_cache_payload(path, cache_token)
|
| 4728 |
+
if data is None:
|
| 4729 |
+
return None
|
| 4730 |
+
html_text = data.get("html")
|
| 4731 |
+
return html_text if isinstance(html_text, str) else None
|
| 4732 |
+
|
| 4733 |
+
|
| 4734 |
+
def _write_html_disk_cache(path: Path, cache_token: str, html_text: str) -> None:
|
| 4735 |
+
_write_disk_cache_payload(path, cache_token, {"html": html_text})
|
| 4736 |
+
|
| 4737 |
+
|
| 4738 |
+
def _wiki_render_disk_cache_path() -> Path:
|
| 4739 |
+
return _claude_dir() / ".ctx-monitor-wiki-cache.json"
|
| 4740 |
+
|
| 4741 |
+
|
| 4742 |
def _render_wiki_index(entity_type: str | None = None, query: str = "") -> str:
|
| 4743 |
"""Card grid of every wiki entity — search + type filter + sidecar grades."""
|
| 4744 |
selected_type = _normalize_dashboard_entity_type(entity_type) if entity_type else None
|
|
|
|
| 4753 |
if cache_key is not None:
|
| 4754 |
if _WIKI_RENDER_CACHE_KEY == cache_key and _WIKI_RENDER_CACHE_VALUE is not None:
|
| 4755 |
return _WIKI_RENDER_CACHE_VALUE
|
| 4756 |
+
cache_token = _disk_cache_token(cache_key)
|
| 4757 |
+
cached = _read_html_disk_cache(_wiki_render_disk_cache_path(), cache_token)
|
| 4758 |
if cached is not None:
|
| 4759 |
_WIKI_RENDER_CACHE_KEY = cache_key
|
| 4760 |
_WIKI_RENDER_CACHE_VALUE = cached
|
|
|
|
| 4883 |
)
|
| 4884 |
html_out = _layout("Wiki", body)
|
| 4885 |
if cache_key is not None:
|
| 4886 |
+
_write_html_disk_cache(_wiki_render_disk_cache_path(), cache_token, html_out)
|
| 4887 |
_WIKI_RENDER_CACHE_KEY = cache_key
|
| 4888 |
_WIKI_RENDER_CACHE_VALUE = html_out
|
| 4889 |
return html_out
|
| 4890 |
|
| 4891 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4892 |
|
| 4893 |
+
def _docs_roots() -> list[Path]:
|
| 4894 |
+
return dashboard_docs.docs_roots(Path.cwd(), Path(__file__).resolve().parent.parent)
|
| 4895 |
|
| 4896 |
|
| 4897 |
def _docs_render_disk_cache_path() -> Path:
|
| 4898 |
+
return dashboard_docs.docs_render_disk_cache_path(_claude_dir())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4899 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4900 |
|
| 4901 |
+
def _docs_index_entries() -> list[dict[str, Any]]:
|
| 4902 |
+
return dashboard_docs.docs_index_entries(_docs_roots())
|
| 4903 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4904 |
|
| 4905 |
+
def _docs_tabs(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 4906 |
+
return dashboard_docs.docs_tabs(entries, _docs_roots())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4907 |
|
| 4908 |
|
| 4909 |
def _render_docs_markdown(markdown_text: str, page_anchor: str) -> str:
|
| 4910 |
+
return dashboard_docs.render_docs_markdown(
|
| 4911 |
+
markdown_text,
|
| 4912 |
+
page_anchor,
|
| 4913 |
+
fallback_renderer=_render_wiki_markdown,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4914 |
)
|
| 4915 |
|
| 4916 |
|
| 4917 |
def _render_docs() -> str:
|
| 4918 |
+
return dashboard_docs.render_docs(
|
| 4919 |
+
roots=_docs_roots(),
|
| 4920 |
+
layout=_layout,
|
| 4921 |
+
asset_text=_monitor_asset_text,
|
| 4922 |
+
inline_script=_monitor_inline_script,
|
| 4923 |
+
cache_path=_docs_render_disk_cache_path(),
|
| 4924 |
+
fallback_markdown=_render_wiki_markdown,
|
| 4925 |
+
cache_salt_paths=(Path(__file__),),
|
| 4926 |
+
cache_extra=(id(_docs_index_entries), id(_docs_tabs)),
|
| 4927 |
+
index_entries=_docs_index_entries,
|
| 4928 |
+
tabs_for_entries=_docs_tabs,
|
| 4929 |
+
render_markdown_func=_render_docs_markdown,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4930 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4931 |
|
| 4932 |
|
| 4933 |
def _render_manage(mutations_enabled: bool | None = None) -> str:
|
|
|
|
| 5320 |
return tuple(parts)
|
| 5321 |
|
| 5322 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5323 |
def _kpi_summary_disk_cache_path(sidecar_dir: Path) -> Path:
|
| 5324 |
return sidecar_dir / ".dashboard-kpi-summary.json"
|
| 5325 |
|
|
|
|
| 5359 |
cache_token: str,
|
| 5360 |
summary_cls: Any,
|
| 5361 |
) -> Any | None:
|
| 5362 |
+
data = _read_disk_cache_payload(_kpi_summary_disk_cache_path(sidecar_dir), cache_token)
|
| 5363 |
+
if data is None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5364 |
return None
|
| 5365 |
return _dashboard_summary_from_dict(summary_cls, data.get("summary"))
|
| 5366 |
|
|
|
|
| 5370 |
cache_token: str,
|
| 5371 |
summary: Any,
|
| 5372 |
) -> None:
|
| 5373 |
+
_write_disk_cache_payload(
|
| 5374 |
+
_kpi_summary_disk_cache_path(sidecar_dir),
|
| 5375 |
+
cache_token,
|
| 5376 |
+
{"summary": summary.to_dict()},
|
| 5377 |
+
sort_keys=True,
|
| 5378 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5379 |
|
| 5380 |
|
| 5381 |
def _kpi_summary():
|
|
|
|
| 5402 |
and time.monotonic() - _KPI_SUMMARY_CACHE_AT < _KPI_SUMMARY_CACHE_SECONDS
|
| 5403 |
):
|
| 5404 |
return _KPI_SUMMARY_CACHE_VALUE
|
| 5405 |
+
cache_token = _disk_cache_token(cache_key)
|
| 5406 |
summary = _read_kpi_summary_disk_cache(sidecar_dir, cache_token, DashboardSummary)
|
| 5407 |
if summary is not None:
|
| 5408 |
_KPI_SUMMARY_CACHE_KEY = cache_key
|
|
|
|
| 6008 |
command: str | None = None,
|
| 6009 |
json_config: str | None = None,
|
| 6010 |
) -> tuple[bool, str]:
|
| 6011 |
+
return dashboard_entities.perform_load(
|
| 6012 |
+
slug,
|
| 6013 |
+
entity_type,
|
| 6014 |
+
command=command,
|
| 6015 |
+
json_config=json_config,
|
| 6016 |
+
deps=_entity_runtime_deps(),
|
| 6017 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6018 |
|
| 6019 |
|
| 6020 |
def _perform_unload(slug: str, entity_type: str = "skill") -> tuple[bool, str]:
|
| 6021 |
+
return dashboard_entities.perform_unload(
|
| 6022 |
+
slug,
|
| 6023 |
+
entity_type,
|
| 6024 |
+
deps=_entity_runtime_deps(),
|
| 6025 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6026 |
|
| 6027 |
|
| 6028 |
# ─── HTTP handler ────────────────────────────────────────────────────────────
|
src/harness_install.py
CHANGED
|
@@ -27,6 +27,7 @@ from urllib.request import url2pathname
|
|
| 27 |
from ctx.core.entity_types import entity_page_path
|
| 28 |
from ctx.core.wiki.wiki_utils import parse_frontmatter_and_body, validate_skill_name
|
| 29 |
from ctx.utils._fs_utils import atomic_write_json, atomic_write_text, reject_symlink_path
|
|
|
|
| 30 |
from ctx_config import cfg
|
| 31 |
|
| 32 |
_COMMAND_ENV_ALLOWLIST = {
|
|
@@ -435,7 +436,7 @@ def _command_env() -> dict[str, str]:
|
|
| 435 |
|
| 436 |
|
| 437 |
def _redact_output(text: str) -> str:
|
| 438 |
-
redacted = text or ""
|
| 439 |
for key, value in os.environ.items():
|
| 440 |
if not value or len(value) < 8:
|
| 441 |
continue
|
|
|
|
| 27 |
from ctx.core.entity_types import entity_page_path
|
| 28 |
from ctx.core.wiki.wiki_utils import parse_frontmatter_and_body, validate_skill_name
|
| 29 |
from ctx.utils._fs_utils import atomic_write_json, atomic_write_text, reject_symlink_path
|
| 30 |
+
from ctx.utils._secret_scan import redact_secret_text
|
| 31 |
from ctx_config import cfg
|
| 32 |
|
| 33 |
_COMMAND_ENV_ALLOWLIST = {
|
|
|
|
| 436 |
|
| 437 |
|
| 438 |
def _redact_output(text: str) -> str:
|
| 439 |
+
redacted = redact_secret_text(text or "")
|
| 440 |
for key, value in os.environ.items():
|
| 441 |
if not value or len(value) < 8:
|
| 442 |
continue
|
src/tests/test_ctx_monitor.py
CHANGED
|
@@ -19,6 +19,8 @@ import pytest
|
|
| 19 |
|
| 20 |
import ctx_monitor as cm
|
| 21 |
import ctx_init as ci
|
|
|
|
|
|
|
| 22 |
from ctx.core.wiki import wiki_queue
|
| 23 |
|
| 24 |
|
|
@@ -38,8 +40,7 @@ def fake_claude(tmp_path: Path, monkeypatch) -> Path:
|
|
| 38 |
monkeypatch.setattr(cm, "_KPI_SUMMARY_CACHE_AT", 0.0)
|
| 39 |
monkeypatch.setattr(cm, "_WIKI_RENDER_CACHE_KEY", None)
|
| 40 |
monkeypatch.setattr(cm, "_WIKI_RENDER_CACHE_VALUE", None)
|
| 41 |
-
|
| 42 |
-
monkeypatch.setattr(cm, "_DOCS_RENDER_CACHE_VALUE", None)
|
| 43 |
return claude
|
| 44 |
|
| 45 |
|
|
@@ -690,6 +691,11 @@ def test_perform_load_rejects_invalid_slug() -> None:
|
|
| 690 |
ok, msg = cm._perform_load("../etc/passwd")
|
| 691 |
assert ok is False
|
| 692 |
assert "invalid slug" in msg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 693 |
|
| 694 |
|
| 695 |
def test_perform_load_runs_skill_security_scan_and_surfaces_output(
|
|
@@ -1541,6 +1547,8 @@ def test_render_graph_uses_builtin_3d_mount(monkeypatch: pytest.MonkeyPatch) ->
|
|
| 1541 |
assert "button id=\"graph-zoom-in\"" in html_out
|
| 1542 |
assert "button id=\"graph-zoom-out\"" in html_out
|
| 1543 |
assert "data-testid=\"graph-edge-detail\"" in html_out
|
|
|
|
|
|
|
| 1544 |
assert "Graph renderer unavailable" not in html_out
|
| 1545 |
assert "Enter a slug to render the graph" in html_out
|
| 1546 |
# Initial slug must be embedded as JSON literal so the JS picks it up.
|
|
@@ -1814,6 +1822,29 @@ def test_graph_neighborhood_uses_dashboard_index_without_full_graph_load(
|
|
| 1814 |
"skill:fastapi-pro",
|
| 1815 |
]
|
| 1816 |
assert result["edges"][0]["data"]["shared_tags"] == ["python"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1817 |
assert cm._graph_stats() == {"nodes": 2, "edges": 1, "available": True}
|
| 1818 |
assert cm._wiki_stats() == {
|
| 1819 |
"skills": 2,
|
|
@@ -3238,6 +3269,7 @@ def test_entity_delete_unloads_live_entity_before_removing_page(
|
|
| 3238 |
fake_claude: Path,
|
| 3239 |
monkeypatch: pytest.MonkeyPatch,
|
| 3240 |
) -> None:
|
|
|
|
| 3241 |
skill_dir = fake_claude / "skill-wiki" / "entities" / "skills"
|
| 3242 |
skill_dir.mkdir(parents=True)
|
| 3243 |
entity_path = skill_dir / "python-patterns.md"
|
|
@@ -3500,8 +3532,7 @@ def test_layout_nav_includes_wiki_and_kpi() -> None:
|
|
| 3500 |
|
| 3501 |
|
| 3502 |
def _use_temp_docs_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
| 3503 |
-
|
| 3504 |
-
monkeypatch.setattr(cm, "_DOCS_RENDER_CACHE_VALUE", None)
|
| 3505 |
monkeypatch.setattr(
|
| 3506 |
cm,
|
| 3507 |
"_docs_render_disk_cache_path",
|
|
@@ -3674,8 +3705,7 @@ def test_render_docs_reuses_disk_cache_after_process_cache_reset(
|
|
| 3674 |
first = cm._render_docs()
|
| 3675 |
assert (tmp_path / ".ctx-monitor-docs-cache.json").is_file()
|
| 3676 |
|
| 3677 |
-
|
| 3678 |
-
monkeypatch.setattr(cm, "_DOCS_RENDER_CACHE_VALUE", None)
|
| 3679 |
|
| 3680 |
def fail_render_markdown(*args: object, **kwargs: object) -> str:
|
| 3681 |
raise AssertionError("fresh process should read the rendered docs cache")
|
|
@@ -3694,7 +3724,11 @@ def test_render_docs_markdown_preserves_mkdocs_tab_controls() -> None:
|
|
| 3694 |
" Second body\n"
|
| 3695 |
)
|
| 3696 |
|
| 3697 |
-
html_out =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3698 |
|
| 3699 |
assert "<input" not in html_out
|
| 3700 |
assert 'type="radio"' in html_out
|
|
|
|
| 19 |
|
| 20 |
import ctx_monitor as cm
|
| 21 |
import ctx_init as ci
|
| 22 |
+
from ctx import dashboard_entities
|
| 23 |
+
from ctx import dashboard_docs
|
| 24 |
from ctx.core.wiki import wiki_queue
|
| 25 |
|
| 26 |
|
|
|
|
| 40 |
monkeypatch.setattr(cm, "_KPI_SUMMARY_CACHE_AT", 0.0)
|
| 41 |
monkeypatch.setattr(cm, "_WIKI_RENDER_CACHE_KEY", None)
|
| 42 |
monkeypatch.setattr(cm, "_WIKI_RENDER_CACHE_VALUE", None)
|
| 43 |
+
dashboard_docs.reset_docs_render_cache()
|
|
|
|
| 44 |
return claude
|
| 45 |
|
| 46 |
|
|
|
|
| 691 |
ok, msg = cm._perform_load("../etc/passwd")
|
| 692 |
assert ok is False
|
| 693 |
assert "invalid slug" in msg
|
| 694 |
+
helper_ok, helper_msg = dashboard_entities.perform_load(
|
| 695 |
+
"../etc/passwd",
|
| 696 |
+
deps=cm._entity_runtime_deps(),
|
| 697 |
+
)
|
| 698 |
+
assert (helper_ok, helper_msg) == (ok, msg)
|
| 699 |
|
| 700 |
|
| 701 |
def test_perform_load_runs_skill_security_scan_and_surfaces_output(
|
|
|
|
| 1547 |
assert "button id=\"graph-zoom-in\"" in html_out
|
| 1548 |
assert "button id=\"graph-zoom-out\"" in html_out
|
| 1549 |
assert "data-testid=\"graph-edge-detail\"" in html_out
|
| 1550 |
+
assert "id='graph-explanation'" in html_out
|
| 1551 |
+
assert "g.explanations" in html_out
|
| 1552 |
assert "Graph renderer unavailable" not in html_out
|
| 1553 |
assert "Enter a slug to render the graph" in html_out
|
| 1554 |
# Initial slug must be embedded as JSON literal so the JS picks it up.
|
|
|
|
| 1822 |
"skill:fastapi-pro",
|
| 1823 |
]
|
| 1824 |
assert result["edges"][0]["data"]["shared_tags"] == ["python"]
|
| 1825 |
+
assert result["schema"] == {
|
| 1826 |
+
"name": "ctx.dashboard.graph.neighborhood",
|
| 1827 |
+
"version": 1,
|
| 1828 |
+
}
|
| 1829 |
+
assert result["layout"] == {
|
| 1830 |
+
"kind": "radial-3d",
|
| 1831 |
+
"node_size_field": "node_size",
|
| 1832 |
+
"node_size_min": 8.0,
|
| 1833 |
+
"node_size_max": 24.0,
|
| 1834 |
+
"edge_weight_field": "weight",
|
| 1835 |
+
}
|
| 1836 |
+
assert result["insights"] == {
|
| 1837 |
+
"source": "dashboard-index",
|
| 1838 |
+
"node_count": 2,
|
| 1839 |
+
"edge_count": 1,
|
| 1840 |
+
"by_type": {"skill": 2, "agent": 0, "mcp-server": 0, "harness": 0},
|
| 1841 |
+
"max_degree": 10,
|
| 1842 |
+
"center_degree": 10,
|
| 1843 |
+
}
|
| 1844 |
+
assert "cached dashboard index" in result["explanations"]["source"]
|
| 1845 |
+
assert "exact or normalized slug" in result["explanations"]["search"]
|
| 1846 |
+
assert "quality, usage, and graph degree" in result["explanations"]["layout"]
|
| 1847 |
+
assert "shared_tags" in result["explanations"]["edges"]
|
| 1848 |
assert cm._graph_stats() == {"nodes": 2, "edges": 1, "available": True}
|
| 1849 |
assert cm._wiki_stats() == {
|
| 1850 |
"skills": 2,
|
|
|
|
| 3269 |
fake_claude: Path,
|
| 3270 |
monkeypatch: pytest.MonkeyPatch,
|
| 3271 |
) -> None:
|
| 3272 |
+
assert dashboard_entities.normalize_entity_tags(["Code Review"]) == ["code-review"]
|
| 3273 |
skill_dir = fake_claude / "skill-wiki" / "entities" / "skills"
|
| 3274 |
skill_dir.mkdir(parents=True)
|
| 3275 |
entity_path = skill_dir / "python-patterns.md"
|
|
|
|
| 3532 |
|
| 3533 |
|
| 3534 |
def _use_temp_docs_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
| 3535 |
+
dashboard_docs.reset_docs_render_cache()
|
|
|
|
| 3536 |
monkeypatch.setattr(
|
| 3537 |
cm,
|
| 3538 |
"_docs_render_disk_cache_path",
|
|
|
|
| 3705 |
first = cm._render_docs()
|
| 3706 |
assert (tmp_path / ".ctx-monitor-docs-cache.json").is_file()
|
| 3707 |
|
| 3708 |
+
dashboard_docs.reset_docs_render_cache()
|
|
|
|
| 3709 |
|
| 3710 |
def fail_render_markdown(*args: object, **kwargs: object) -> str:
|
| 3711 |
raise AssertionError("fresh process should read the rendered docs cache")
|
|
|
|
| 3724 |
" Second body\n"
|
| 3725 |
)
|
| 3726 |
|
| 3727 |
+
html_out = dashboard_docs.render_docs_markdown(
|
| 3728 |
+
markdown_text,
|
| 3729 |
+
"doc-home",
|
| 3730 |
+
fallback_renderer=cm._render_wiki_markdown,
|
| 3731 |
+
)
|
| 3732 |
|
| 3733 |
assert "<input" not in html_out
|
| 3734 |
assert 'type="radio"' in html_out
|
src/tests/test_ctx_monitor_3type.py
CHANGED
|
@@ -31,6 +31,7 @@ import pytest
|
|
| 31 |
sys.path.insert(0, str(Path(__file__).parents[1]))
|
| 32 |
|
| 33 |
import ctx_monitor as _cm
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
# ────────────────────────────────────────────────────────────────────
|
|
@@ -161,6 +162,8 @@ class TestWikiIndexEntries:
|
|
| 161 |
assert slugs == {"anthropic-python-sdk", "atlassian-cloud", "pulsemcp-meta"}
|
| 162 |
|
| 163 |
def test_wiki_entity_path_resolves_sharded_mcp_pages(self, wiki_3type):
|
|
|
|
|
|
|
| 164 |
path = _cm._wiki_entity_path("anthropic-python-sdk")
|
| 165 |
assert path == (
|
| 166 |
wiki_3type / "entities" / "mcp-servers" / "a" / "anthropic-python-sdk.md"
|
|
|
|
| 31 |
sys.path.insert(0, str(Path(__file__).parents[1]))
|
| 32 |
|
| 33 |
import ctx_monitor as _cm
|
| 34 |
+
from ctx.core import entity_types as _entity_types
|
| 35 |
|
| 36 |
|
| 37 |
# ────────────────────────────────────────────────────────────────────
|
|
|
|
| 162 |
assert slugs == {"anthropic-python-sdk", "atlassian-cloud", "pulsemcp-meta"}
|
| 163 |
|
| 164 |
def test_wiki_entity_path_resolves_sharded_mcp_pages(self, wiki_3type):
|
| 165 |
+
assert _entity_types.normalize_entity_type("mcp") == "mcp-server"
|
| 166 |
+
assert _cm._DASHBOARD_ENTITY_SOURCES == _entity_types.entity_source_specs()
|
| 167 |
path = _cm._wiki_entity_path("anthropic-python-sdk")
|
| 168 |
assert path == (
|
| 169 |
wiki_3type / "entities" / "mcp-servers" / "a" / "anthropic-python-sdk.md"
|
src/tests/test_security_hardening.py
CHANGED
|
@@ -5,6 +5,7 @@ Security-hardening tests covering:
|
|
| 5 |
2. Cross-process advisory file-lock behavior.
|
| 6 |
3. YAML validate-path misroute (toolbox.validate now reads the given file,
|
| 7 |
not .toolbox.yaml in its parent directory).
|
|
|
|
| 8 |
|
| 9 |
Each test targets a specific fix from the Phase 4b-6 code-review pass.
|
| 10 |
"""
|
|
@@ -15,7 +16,9 @@ import json
|
|
| 15 |
import sys
|
| 16 |
import threading
|
| 17 |
import time
|
|
|
|
| 18 |
from pathlib import Path
|
|
|
|
| 19 |
|
| 20 |
import pytest
|
| 21 |
|
|
@@ -24,8 +27,13 @@ if str(SRC) not in sys.path:
|
|
| 24 |
sys.path.insert(0, str(SRC))
|
| 25 |
|
| 26 |
import council_runner as cr # noqa: E402
|
|
|
|
|
|
|
| 27 |
import toolbox as tb # noqa: E402
|
| 28 |
import toolbox_verdict as tv # noqa: E402
|
|
|
|
|
|
|
|
|
|
| 29 |
from ctx.utils._file_lock import file_lock # noqa: E402
|
| 30 |
|
| 31 |
|
|
@@ -146,3 +154,115 @@ def test_validate_reads_supplied_yaml_file(tmp_path, capsys, monkeypatch):
|
|
| 146 |
out = capsys.readouterr().out
|
| 147 |
assert rc == 0
|
| 148 |
assert "1 toolbox(es)" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
2. Cross-process advisory file-lock behavior.
|
| 6 |
3. YAML validate-path misroute (toolbox.validate now reads the given file,
|
| 7 |
not .toolbox.yaml in its parent directory).
|
| 8 |
+
4. Dashboard/install regressions for active HTML and secret persistence.
|
| 9 |
|
| 10 |
Each test targets a specific fix from the Phase 4b-6 code-review pass.
|
| 11 |
"""
|
|
|
|
| 16 |
import sys
|
| 17 |
import threading
|
| 18 |
import time
|
| 19 |
+
from dataclasses import dataclass
|
| 20 |
from pathlib import Path
|
| 21 |
+
from typing import Any
|
| 22 |
|
| 23 |
import pytest
|
| 24 |
|
|
|
|
| 27 |
sys.path.insert(0, str(SRC))
|
| 28 |
|
| 29 |
import council_runner as cr # noqa: E402
|
| 30 |
+
import ctx_monitor as cm # noqa: E402
|
| 31 |
+
import harness_install # noqa: E402
|
| 32 |
import toolbox as tb # noqa: E402
|
| 33 |
import toolbox_verdict as tv # noqa: E402
|
| 34 |
+
from ctx import dashboard_docs # noqa: E402
|
| 35 |
+
from ctx.adapters.claude_code.install import install_utils # noqa: E402
|
| 36 |
+
from ctx.adapters.claude_code.install import mcp_install # noqa: E402
|
| 37 |
from ctx.utils._file_lock import file_lock # noqa: E402
|
| 38 |
|
| 39 |
|
|
|
|
| 154 |
out = capsys.readouterr().out
|
| 155 |
assert rc == 0
|
| 156 |
assert "1 toolbox(es)" in out
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ── dashboard/install hardening ─────────────────────────────────────────────
|
| 160 |
+
|
| 161 |
+
def _write_mcp_entity(wiki_dir: Path, slug: str, frontmatter: dict[str, str]) -> Path:
|
| 162 |
+
shard = slug[0] if slug and slug[0].isalpha() else "0-9"
|
| 163 |
+
path = wiki_dir / "entities" / "mcp-servers" / shard / f"{slug}.md"
|
| 164 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 165 |
+
lines = ["---"]
|
| 166 |
+
lines.extend(f"{key}: {value}" for key, value in frontmatter.items())
|
| 167 |
+
lines.extend(["---", "", "# MCP"])
|
| 168 |
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
| 169 |
+
return path
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_docs_sanitizer_rewrites_unquoted_active_urls() -> None:
|
| 173 |
+
cleaned = dashboard_docs.sanitize_docs_html(
|
| 174 |
+
'<a href=javascript:alert(1)>bad</a>'
|
| 175 |
+
'<img src=data:text/html,<svg/onload=alert(1)>>'
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
assert "javascript:" not in cleaned.lower()
|
| 179 |
+
assert "data:text/html" not in cleaned.lower()
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_dashboard_skill_load_requires_security_scan(
|
| 183 |
+
tmp_path: Path,
|
| 184 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 185 |
+
) -> None:
|
| 186 |
+
from ctx.adapters.claude_code.install import skill_install
|
| 187 |
+
|
| 188 |
+
calls: list[dict[str, object]] = []
|
| 189 |
+
|
| 190 |
+
class Result:
|
| 191 |
+
status = "installed"
|
| 192 |
+
message = "installed"
|
| 193 |
+
security_scan = None
|
| 194 |
+
|
| 195 |
+
def fake_install_skill(*_args: object, **kwargs: object) -> Result:
|
| 196 |
+
calls.append(kwargs)
|
| 197 |
+
return Result()
|
| 198 |
+
|
| 199 |
+
monkeypatch.setattr(cm, "_wiki_dir", lambda: tmp_path / "wiki")
|
| 200 |
+
monkeypatch.setattr(cm, "_claude_dir", lambda: tmp_path / ".claude")
|
| 201 |
+
monkeypatch.setattr(skill_install, "install_skill", fake_install_skill)
|
| 202 |
+
|
| 203 |
+
ok, _msg = cm._perform_load("python-patterns", entity_type="skill")
|
| 204 |
+
|
| 205 |
+
assert ok is True
|
| 206 |
+
assert calls
|
| 207 |
+
assert calls[0]["security_scan"] is True
|
| 208 |
+
assert calls[0]["security_scan_required"] is True
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def test_mcp_skip_path_does_not_persist_inline_secret_command(
|
| 212 |
+
tmp_path: Path,
|
| 213 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 214 |
+
) -> None:
|
| 215 |
+
wiki = tmp_path / "wiki"
|
| 216 |
+
manifest = tmp_path / "skill-manifest.json"
|
| 217 |
+
monkeypatch.setattr(install_utils, "MANIFEST_PATH", manifest)
|
| 218 |
+
_write_mcp_entity(
|
| 219 |
+
wiki,
|
| 220 |
+
"gh",
|
| 221 |
+
{
|
| 222 |
+
"status": "installed",
|
| 223 |
+
"install_cmd": "npx -y pkg GITHUB_TOKEN=ghp_supersecret1234567890",
|
| 224 |
+
},
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
result = mcp_install.install_mcp("gh", wiki_dir=wiki, auto=True)
|
| 228 |
+
|
| 229 |
+
assert result.status == "skipped-existing"
|
| 230 |
+
manifest_data = json.loads(manifest.read_text(encoding="utf-8"))
|
| 231 |
+
assert manifest_data["load"] == [
|
| 232 |
+
{
|
| 233 |
+
"skill": "gh",
|
| 234 |
+
"entity_type": "mcp-server",
|
| 235 |
+
"source": "ctx-mcp-install",
|
| 236 |
+
}
|
| 237 |
+
]
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
@dataclass
|
| 241 |
+
class _FakeRun:
|
| 242 |
+
returncode: int = 0
|
| 243 |
+
stdout: str = ""
|
| 244 |
+
stderr: str = ""
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def test_harness_run_redacts_token_shaped_output_not_present_in_parent_env(
|
| 248 |
+
tmp_path: Path,
|
| 249 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 250 |
+
) -> None:
|
| 251 |
+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
| 252 |
+
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
| 253 |
+
|
| 254 |
+
def fake_run(_cmd: list[str], **_kwargs: Any) -> _FakeRun:
|
| 255 |
+
return _FakeRun(
|
| 256 |
+
stdout=(
|
| 257 |
+
"created OPENAI_API_KEY=sk-testsecret1234567890 "
|
| 258 |
+
"and ghp_supersecret1234567890"
|
| 259 |
+
)
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
monkeypatch.setattr(harness_install.subprocess, "run", fake_run)
|
| 263 |
+
|
| 264 |
+
run = harness_install._run_command("python --version", cwd=tmp_path)
|
| 265 |
+
|
| 266 |
+
assert "sk-testsecret" not in run["stdout"]
|
| 267 |
+
assert "ghp_supersecret" not in run["stdout"]
|
| 268 |
+
assert harness_install._REDACTION in run["stdout"]
|
src/tests/test_update_repo_stats.py
CHANGED
|
@@ -374,18 +374,13 @@ def test_patch_readme_checks_docs_and_catalog(
|
|
| 374 |
docs_knowledge.write_text("| Total nodes | **10** |\n", encoding="utf-8")
|
| 375 |
docs_catalog.write_text(
|
| 376 |
"\n".join([
|
|
|
|
|
|
|
| 377 |
'<article class="ctx-catalog-card" data-type="skill">',
|
| 378 |
'<p class="ctx-catalog-muted">1 entities</p>',
|
| 379 |
"</article>",
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
"</article>",
|
| 383 |
-
'<article class="ctx-catalog-card" data-type="mcp-server">',
|
| 384 |
-
'<p class="ctx-catalog-muted">3 entities</p>',
|
| 385 |
-
"</article>",
|
| 386 |
-
'<article class="ctx-catalog-card" data-type="harness">',
|
| 387 |
-
'<p class="ctx-catalog-muted">4 entities</p>',
|
| 388 |
-
"</article>",
|
| 389 |
]),
|
| 390 |
encoding="utf-8",
|
| 391 |
)
|
|
@@ -418,6 +413,7 @@ def test_patch_readme_checks_docs_and_catalog(
|
|
| 418 |
assert ">56 entities</p>" in patched
|
| 419 |
assert ">7,890 entities</p>" in patched
|
| 420 |
assert ">12 entities</p>" in patched
|
|
|
|
| 421 |
|
| 422 |
|
| 423 |
def test_read_test_count_prefers_project_python(
|
|
|
|
| 374 |
docs_knowledge.write_text("| Total nodes | **10** |\n", encoding="utf-8")
|
| 375 |
docs_catalog.write_text(
|
| 376 |
"\n".join([
|
| 377 |
+
'<div id="ctx-catalog-grid" class="ctx-catalog-grid">',
|
| 378 |
+
"<!-- ctx-catalog:begin -->",
|
| 379 |
'<article class="ctx-catalog-card" data-type="skill">',
|
| 380 |
'<p class="ctx-catalog-muted">1 entities</p>',
|
| 381 |
"</article>",
|
| 382 |
+
"<!-- ctx-catalog:end -->",
|
| 383 |
+
"</div>",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
]),
|
| 385 |
encoding="utf-8",
|
| 386 |
)
|
|
|
|
| 413 |
assert ">56 entities</p>" in patched
|
| 414 |
assert ">7,890 entities</p>" in patched
|
| 415 |
assert ">12 entities</p>" in patched
|
| 416 |
+
assert "./?type=harness&q=tool+access" in patched
|
| 417 |
|
| 418 |
|
| 419 |
def test_read_test_count_prefers_project_python(
|
src/tests/test_wiki_sync.py
CHANGED
|
@@ -31,6 +31,7 @@ from pathlib import Path
|
|
| 31 |
|
| 32 |
import pytest
|
| 33 |
|
|
|
|
| 34 |
from ctx.core.wiki import wiki_sync
|
| 35 |
# ---------------------------------------------------------------------------
|
| 36 |
# Helpers
|
|
@@ -587,6 +588,8 @@ class TestUpsertSkillPage:
|
|
| 587 |
|
| 588 |
class TestEntityIndexLink:
|
| 589 |
def test_skills_flat_path(self) -> None:
|
|
|
|
|
|
|
| 590 |
result = wiki_sync._entity_index_link("skills", "my-skill")
|
| 591 |
assert result == "entities/skills/my-skill"
|
| 592 |
|
|
|
|
| 31 |
|
| 32 |
import pytest
|
| 33 |
|
| 34 |
+
from ctx.core import entity_types
|
| 35 |
from ctx.core.wiki import wiki_sync
|
| 36 |
# ---------------------------------------------------------------------------
|
| 37 |
# Helpers
|
|
|
|
| 588 |
|
| 589 |
class TestEntityIndexLink:
|
| 590 |
def test_skills_flat_path(self) -> None:
|
| 591 |
+
assert wiki_sync._SUBJECT_TYPE_FOR_ENTITY_TYPE is entity_types.SUBJECT_TYPE_FOR_ENTITY_TYPE
|
| 592 |
+
assert wiki_sync._INDEX_SECTION_FOR_SUBJECT is entity_types.INDEX_SECTION_FOR_SUBJECT
|
| 593 |
result = wiki_sync._entity_index_link("skills", "my-skill")
|
| 594 |
assert result == "entities/skills/my-skill"
|
| 595 |
|
src/toolbox.py
CHANGED
|
@@ -151,22 +151,6 @@ def cmd_show(args: argparse.Namespace) -> int:
|
|
| 151 |
return 0
|
| 152 |
|
| 153 |
|
| 154 |
-
def _seed_if_empty() -> ToolboxSet:
|
| 155 |
-
tset = load_global()
|
| 156 |
-
if tset.toolboxes:
|
| 157 |
-
return tset
|
| 158 |
-
starters = ["ship-it", "security-sweep", "refactor-safety",
|
| 159 |
-
"docs-review", "fresh-repo-init"]
|
| 160 |
-
for name in starters:
|
| 161 |
-
try:
|
| 162 |
-
raw = _load_template(name)
|
| 163 |
-
except FileNotFoundError as exc:
|
| 164 |
-
_print_err(f"[warn] {exc}")
|
| 165 |
-
continue
|
| 166 |
-
tset = tset.with_toolbox(Toolbox.from_dict(name, raw))
|
| 167 |
-
return tset
|
| 168 |
-
|
| 169 |
-
|
| 170 |
def cmd_init(args: argparse.Namespace) -> int:
|
| 171 |
tset = load_global()
|
| 172 |
if tset.toolboxes and not args.force:
|
|
|
|
| 151 |
return 0
|
| 152 |
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
def cmd_init(args: argparse.Namespace) -> int:
|
| 155 |
tset = load_global()
|
| 156 |
if tset.toolboxes and not args.force:
|
src/update_repo_stats.py
CHANGED
|
@@ -625,6 +625,79 @@ _ENTITY_COUNT_REPLACEMENTS: tuple[tuple[str, str, str, str], ...] = (
|
|
| 625 |
("mcps", "MCPs", "mcp-server", r"badge/MCPs-[0-9,%]+-"),
|
| 626 |
("harnesses", "Harnesses", "harness", r"badge/Harnesses-[0-9,%]+-"),
|
| 627 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 628 |
|
| 629 |
|
| 630 |
def _append_badge_target_replacements(reps: list[Replacement]) -> None:
|
|
@@ -679,6 +752,13 @@ def _append_entity_count_replacements(
|
|
| 679 |
))
|
| 680 |
_append_catalog_card_count(reps, entity_type=entity_type, count=count)
|
| 681 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
|
| 683 |
def build_replacements(
|
| 684 |
stats: Mapping[str, int | None],
|
|
|
|
| 625 |
("mcps", "MCPs", "mcp-server", r"badge/MCPs-[0-9,%]+-"),
|
| 626 |
("harnesses", "Harnesses", "harness", r"badge/Harnesses-[0-9,%]+-"),
|
| 627 |
)
|
| 628 |
+
_CATALOG_BEGIN = "<!-- ctx-catalog:begin -->"
|
| 629 |
+
_CATALOG_END = "<!-- ctx-catalog:end -->"
|
| 630 |
+
_CATALOG_FILTER_CARDS: tuple[tuple[str, str, str, str, str], ...] = (
|
| 631 |
+
("skill", "Skills", "Code review skills", "code review review pr diff quality bug tests", "code+review"),
|
| 632 |
+
("skill", "Skills", "Testing skills", "testing pytest unit browser smoke regression", "testing"),
|
| 633 |
+
("skill", "Skills", "Frontend skills", "frontend ui dashboard css react browser", "frontend"),
|
| 634 |
+
("agent", "Agents", "Architecture agents", "architecture design refactor planning", "architecture"),
|
| 635 |
+
("agent", "Agents", "Security agents", "security audit supply chain secrets", "security"),
|
| 636 |
+
("mcp-server", "MCPs", "GitHub MCPs", "github repo issues pull requests graphql", "github"),
|
| 637 |
+
("mcp-server", "MCPs", "Cloud MCPs", "cloud google cloud aws azure deploy", "cloud"),
|
| 638 |
+
("mcp-server", "MCPs", "Browser MCPs", "browser automation web scraping", "browser"),
|
| 639 |
+
("harness", "Harnesses", "Local/API model harnesses", "local api openai ollama vllm model harness", "local+model"),
|
| 640 |
+
("harness", "Harnesses", "Verification harnesses", "harness test eval guardrail validate verification", "verification"),
|
| 641 |
+
("harness", "Harnesses", "Tool-access harnesses", "harness tools sandbox filesystem cloud tool access", "tool+access"),
|
| 642 |
+
)
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
def _catalog_card(
|
| 646 |
+
*,
|
| 647 |
+
entity_type: str,
|
| 648 |
+
pill: str,
|
| 649 |
+
title: str,
|
| 650 |
+
search: str,
|
| 651 |
+
query_href: str,
|
| 652 |
+
muted: str,
|
| 653 |
+
) -> str:
|
| 654 |
+
return "\n".join([
|
| 655 |
+
f' <article class="ctx-catalog-card" data-type="{entity_type}" data-search="{search}">',
|
| 656 |
+
f" <span class=\"ctx-catalog-pill\">{pill}</span>",
|
| 657 |
+
f" <h3>{title}</h3>",
|
| 658 |
+
f" <p class=\"ctx-catalog-muted\">{muted}</p>",
|
| 659 |
+
f" <a class=\"md-button\" href=\"{query_href}\">Filter tiles</a>",
|
| 660 |
+
" <a class=\"md-button\" href=\"../dashboard/#catalog-badge-links\">Open full catalog locally</a>",
|
| 661 |
+
" </article>",
|
| 662 |
+
])
|
| 663 |
+
|
| 664 |
+
|
| 665 |
+
def render_catalog_cards(stats: Mapping[str, int | None]) -> str | None:
|
| 666 |
+
"""Render the generated public catalog tile block from graph stats."""
|
| 667 |
+
required = {
|
| 668 |
+
"skills": stats.get("skills"),
|
| 669 |
+
"agents": stats.get("agents"),
|
| 670 |
+
"mcps": stats.get("mcps"),
|
| 671 |
+
"harnesses": stats.get("harnesses"),
|
| 672 |
+
}
|
| 673 |
+
if any(value is None for value in required.values()):
|
| 674 |
+
return None
|
| 675 |
+
count_cards = (
|
| 676 |
+
("skill", "Skills", "Skills", "skill prompt workflow testing code review frontend backend security research", "skills"),
|
| 677 |
+
("agent", "Agents", "Agents", "agent reviewer planner architect debugger security research", "agents"),
|
| 678 |
+
("mcp-server", "MCPs", "MCP servers", "mcp server github filesystem browser database api cloud", "mcps"),
|
| 679 |
+
("harness", "Harnesses", "Harnesses", "harness local model api model llm orchestration verification", "harnesses"),
|
| 680 |
+
)
|
| 681 |
+
cards: list[str] = []
|
| 682 |
+
for entity_type, pill, title, search, key in count_cards:
|
| 683 |
+
cards.append(_catalog_card(
|
| 684 |
+
entity_type=entity_type,
|
| 685 |
+
pill=pill,
|
| 686 |
+
title=title,
|
| 687 |
+
search=search,
|
| 688 |
+
query_href=f"./?type={entity_type}",
|
| 689 |
+
muted=f"{int(required[key] or 0):,} entities",
|
| 690 |
+
))
|
| 691 |
+
for entity_type, pill, title, search, query in _CATALOG_FILTER_CARDS:
|
| 692 |
+
cards.append(_catalog_card(
|
| 693 |
+
entity_type=entity_type,
|
| 694 |
+
pill=pill,
|
| 695 |
+
title=title,
|
| 696 |
+
search=search,
|
| 697 |
+
query_href=f"./?type={entity_type}&q={query}",
|
| 698 |
+
muted="Filtered catalog launcher",
|
| 699 |
+
))
|
| 700 |
+
return _CATALOG_BEGIN + "\n" + "\n".join(cards) + "\n" + _CATALOG_END
|
| 701 |
|
| 702 |
|
| 703 |
def _append_badge_target_replacements(reps: list[Replacement]) -> None:
|
|
|
|
| 752 |
))
|
| 753 |
_append_catalog_card_count(reps, entity_type=entity_type, count=count)
|
| 754 |
|
| 755 |
+
catalog_cards = render_catalog_cards(stats)
|
| 756 |
+
if catalog_cards is not None:
|
| 757 |
+
reps.append((
|
| 758 |
+
re.compile(rf"{re.escape(_CATALOG_BEGIN)}[\s\S]*?{re.escape(_CATALOG_END)}"),
|
| 759 |
+
catalog_cards,
|
| 760 |
+
))
|
| 761 |
+
|
| 762 |
|
| 763 |
def build_replacements(
|
| 764 |
stats: Mapping[str, int | None],
|