Stevesolun commited on
Commit
547daee
·
verified ·
1 Parent(s): 52381ac

Sync ctx 333622a

Browse files

GitHub commit: 333622a56de0a4acad72146b82611401bc076c69

README.md CHANGED
@@ -18,7 +18,7 @@ tags:
18
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
19
  [![Python 3.11+](https://img.shields.io/badge/Python-3.11+-green.svg)](https://python.org)
20
  [![PyPI](https://img.shields.io/pypi/v/claude-ctx.svg)](https://pypi.org/project/claude-ctx/)
21
- [![Tests](https://img.shields.io/badge/Tests-4054_collected-brightgreen.svg)](https://github.com/stevesolun/ctx/actions/workflows/test.yml)
22
  [![Graph](https://img.shields.io/badge/Graph-102%2C928_nodes_/_2.9M_edges-red.svg)](https://stevesolun.github.io/ctx/knowledge-graph/)
23
  [![Skills](https://img.shields.io/badge/Skills-91%2C464-blue.svg)](https://stevesolun.github.io/ctx/catalog/?type=skill)
24
  [![Agents](https://img.shields.io/badge/Agents-467-purple.svg)](https://stevesolun.github.io/ctx/catalog/?type=agent)
 
18
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
19
  [![Python 3.11+](https://img.shields.io/badge/Python-3.11+-green.svg)](https://python.org)
20
  [![PyPI](https://img.shields.io/pypi/v/claude-ctx.svg)](https://pypi.org/project/claude-ctx/)
21
+ [![Tests](https://img.shields.io/badge/Tests-4058_collected-brightgreen.svg)](https://github.com/stevesolun/ctx/actions/workflows/test.yml)
22
  [![Graph](https://img.shields.io/badge/Graph-102%2C928_nodes_/_2.9M_edges-red.svg)](https://stevesolun.github.io/ctx/knowledge-graph/)
23
  [![Skills](https://img.shields.io/badge/Skills-91%2C464-blue.svg)](https://stevesolun.github.io/ctx/catalog/?type=skill)
24
  [![Agents](https://img.shields.io/badge/Agents-467-purple.svg)](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 list renderer and does not load
 
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 built-in list view over the dashboard-supported
134
- skill/agent/MCP/harness graph. Imported skills are normal `skill`
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 list view, entity pills identify the node type. The
150
- focus row has `depth=0` in the page data, and neighbor rows are filterable
151
- by entity type and shared tag/token text.
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,054 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`
 
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 a
152
- cytoscape 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
 
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 isinstance(install_cmd, str) and install_cmd:
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: dict[str, str] = {
172
- "skill": "skills",
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. Adding a new
307
- # subject type requires extending this map and the entity-link helper
308
- # below. ``"skills"`` stays first so the default keyword arg for backward
309
- # compat (callers that predate the subject_type param) keeps writing
310
- # into the same section it always did.
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
- if subject_type == "mcp-servers":
329
- first = slug[0] if slug else ""
330
- shard = first if first.isalpha() else "0-9"
331
- return f"entities/mcp-servers/{shard}/{slug}"
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, PurePosixPath
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
- first = slug[0] if slug else ""
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
- if raw is None:
250
- return None
251
- value = str(raw).strip()
252
- normalized = {
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 sub, current_type, recursive in _DASHBOARD_ENTITY_SOURCES:
295
  if entity_type is not None and entity_type != current_type:
296
  continue
297
- p = (
298
- _wiki_dir() / "entities" / sub / _mcp_shard(slug) / f"{slug}.md"
299
- if recursive
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
- for sub, current_type, recursive in _DASHBOARD_ENTITY_SOURCES:
315
- if normalized != current_type:
316
- continue
317
- if recursive:
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
- terms = [term for term in re.split(r"\s+", query.lower().strip()) if term]
373
- results: list[dict[str, Any]] = []
374
- for slug, current_type, path in _iter_wiki_entity_paths(entity_type):
375
- try:
376
- head = path.read_text(encoding="utf-8", errors="replace")[:4096]
377
- except OSError:
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 _upsert_wiki_entity(payload: dict[str, Any]) -> tuple[bool, str]:
518
- try:
519
- requested_slug = str(payload.get("slug", "")).strip()
520
- requested_type = str(payload.get("entity_type", "skill")).strip() or "skill"
521
- existing_detail = _wiki_entity_detail(requested_slug, requested_type)
522
- existing_meta = (
523
- existing_detail.get("frontmatter")
524
- if isinstance(existing_detail, dict)
525
- else None
526
- )
527
- confirm_update = str(payload.get("confirm_update", "")).strip().lower() in {
528
- "1",
529
- "true",
530
- "yes",
531
- "y",
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=path,
553
  content=content,
554
- action="upsert",
555
- )
556
- except Exception as exc: # noqa: BLE001
557
- return False, f"{type(exc).__name__}: {exc}"
558
- return True, f"saved {entity_type}:{slug} and queued graph refresh"
 
 
 
 
 
 
559
 
560
 
561
- def _entity_live_in_manifest(slug: str, entity_type: str) -> bool:
562
- manifest = _read_manifest()
563
- for entry in manifest.get("load", []):
564
- if not isinstance(entry, dict):
565
- continue
566
- entry_slug = str(entry.get("skill") or entry.get("slug") or "")
567
- entry_type = _normalize_dashboard_entity_type(
568
- str(entry.get("entity_type") or entry.get("type") or "skill"),
569
- )
570
- if entry_slug == slug and entry_type == entity_type:
571
- return True
572
- return False
 
573
 
574
 
575
  def _delete_wiki_entity(slug: str, entity_type: str) -> tuple[bool, str]:
576
- try:
577
- normalized = _normalize_dashboard_entity_type(entity_type)
578
- if normalized is None:
579
- raise ValueError(f"unsupported entity_type: {entity_type!r}")
580
- if not _is_safe_slug(slug):
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 _wiki_render_cache_token(cache_key: tuple[Any, ...]) -> str:
4842
  return json.dumps(cache_key, separators=(",", ":"), sort_keys=True)
4843
 
4844
 
4845
- def _wiki_render_disk_cache_path() -> Path:
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(_wiki_render_disk_cache_path().read_text(encoding="utf-8"))
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
- html_text = data.get("html")
4859
- return html_text if isinstance(html_text, str) else None
4860
 
4861
 
4862
- def _write_wiki_render_disk_cache(cache_token: str, html_text: str) -> None:
 
 
 
 
 
 
4863
  try:
4864
  _atomic_write_text(
4865
- _wiki_render_disk_cache_path(),
4866
- json.dumps({
4867
- "schema_version": 1,
4868
- "cache_token": cache_token,
4869
- "html": html_text,
4870
- }, ensure_ascii=False) + "\n",
 
 
 
 
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 = _wiki_render_cache_token(cache_key)
4892
- cached = _read_wiki_render_disk_cache(cache_token)
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
- _write_wiki_render_disk_cache(cache_token, html_out)
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 _docs_render_cache_token(cache_key: tuple[Any, ...]) -> str:
5082
- return json.dumps(cache_key, separators=(",", ":"), sort_keys=True)
5083
 
5084
 
5085
  def _docs_render_disk_cache_path() -> Path:
5086
- return _claude_dir() / ".ctx-monitor-docs-cache.json"
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
- def _doc_anchor(value: str) -> str:
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
- """Render repo docs with MkDocs-like Markdown support when available."""
5399
- markdown_text = re.sub(r":octicons-arrow-right-24:", "->", markdown_text)
5400
- markdown_text = re.sub(r":octicons-[a-z0-9-]+:", "", markdown_text)
5401
- try:
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
- cache_key = _docs_render_cache_key()
5614
- global _DOCS_RENDER_CACHE_KEY, _DOCS_RENDER_CACHE_VALUE
5615
- if _DOCS_RENDER_CACHE_KEY == cache_key and _DOCS_RENDER_CACHE_VALUE is not None:
5616
- return _DOCS_RENDER_CACHE_VALUE
5617
- cache_token = _docs_render_cache_token(cache_key)
5618
- cached = _read_docs_render_disk_cache(cache_token)
5619
- if cached is not None:
5620
- _DOCS_RENDER_CACHE_KEY = cache_key
5621
- _DOCS_RENDER_CACHE_VALUE = cached
5622
- return cached
5623
-
5624
- entries = _docs_index_entries()
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
- try:
6140
- data = json.loads(
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
- try:
6158
- payload = {
6159
- "schema_version": 1,
6160
- "cache_token": cache_token,
6161
- "summary": summary.to_dict(),
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 = _kpi_summary_cache_token(cache_key)
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
- """Install/load one entity from the wiki. Returns (ok, message)."""
6803
- if not _is_safe_slug(slug):
6804
- return False, f"invalid slug: {slug!r}"
6805
- normalized_entity_type = _normalize_dashboard_entity_type(entity_type)
6806
- if normalized_entity_type is None:
6807
- return False, f"unsupported entity_type: {entity_type!r}"
6808
- entity_type = normalized_entity_type
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
- """Unload the given entity.
6858
-
6859
- Routes by ``entity_type``:
6860
- - ``skill`` / ``agent``: ``skill_unload.unload_from_session`` —
6861
- file-copy + manifest update, reversible via /api/load.
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
- monkeypatch.setattr(cm, "_DOCS_RENDER_CACHE_KEY", None)
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
- monkeypatch.setattr(cm, "_DOCS_RENDER_CACHE_KEY", None)
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
- monkeypatch.setattr(cm, "_DOCS_RENDER_CACHE_KEY", None)
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 = cm._render_docs_markdown(markdown_text, "doc-home")
 
 
 
 
3698
 
3699
  assert "&lt;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 "&lt;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
- '<article class="ctx-catalog-card" data-type="agent">',
381
- '<p class="ctx-catalog-muted">2 entities</p>',
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],