From 61fe2e2a92fa83f314c772fe337d56979383c687 Mon Sep 17 00:00:00 2001 From: mountain Date: Mon, 22 Jun 2026 18:31:53 +0800 Subject: [PATCH 1/6] fix(test): align stale deck default-skill expectation with shipped openkb-deck-neon #101 made openkb-deck-neon the default deck skill (creator.py DEFAULT_DECK_SKILL) but left this test asserting the old openkb-deck-editorial, so it was red on main. Unrelated to visualize. --- tests/test_generator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_generator.py b/tests/test_generator.py index 4ce9f145..67dbca61 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -85,7 +85,7 @@ async def test_generator_deck_dispatches_to_deck_creator(tmp_path): # validation up to self.validation. from openkb.agent.skill_runner import SkillRunResult fake_run_result = SkillRunResult( - skill_name="openkb-deck-editorial", + skill_name="openkb-deck-neon", output_path=gen.output_dir / "index.html", validation=DeckValidationResult(), metadata={"mode": "deck"}, @@ -102,7 +102,7 @@ async def test_generator_deck_dispatches_to_deck_creator(tmp_path): intent="…", model="openai/gpt-4o", critique=False, - skill_name="openkb-deck-editorial", + skill_name="openkb-deck-neon", ) regen.assert_not_called() # marketplace is skill-only assert result == gen.output_dir From 30a649b1a4c76ad2281b824e1934dd12a84bfab0 Mon Sep 17 00:00:00 2001 From: mountain Date: Mon, 22 Jun 2026 18:31:53 +0800 Subject: [PATCH 2/6] feat(visualize): build the wikilink graph + render the self-contained HTML build_graph(wiki_dir) walks summaries/concepts/entities, collects nodes (id/label/type/description/sources + in/out degree), resolves [[wikilinks]] to edges (reusing lint._extract_wikilinks/_normalize_target and frontmatter.parse; dirs from schema.PAGE_CONTENT_DIRS), and drops broken/self/duplicate links. render_html injects the graph as JSON into the template (escaping ). --- openkb/visualize.py | 67 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 openkb/visualize.py diff --git a/openkb/visualize.py b/openkb/visualize.py new file mode 100644 index 00000000..a8748e3d --- /dev/null +++ b/openkb/visualize.py @@ -0,0 +1,67 @@ +"""Render the wiki's [[wikilink]] graph as a self-contained interactive HTML page.""" +from __future__ import annotations + +import json +from importlib import resources +from pathlib import Path + +from openkb import frontmatter +from openkb.lint import _extract_wikilinks, _normalize_target +from openkb.schema import PAGE_CONTENT_DIRS + +# Singular display type per content dir; falls back to a derived name for any +# dir not listed (so a new PAGE_CONTENT_DIRS entry never KeyErrors here). +_DIR_TYPE = {"summaries": "Summary", "concepts": "Concept", "entities": "Entity"} + + +def _type_for_dir(sub: str) -> str: + return _DIR_TYPE.get(sub) or (sub[:-1] if sub.endswith("s") else sub).capitalize() or sub + + +def build_graph(wiki_dir: Path) -> dict: + """Collect nodes (pages), directed edges (wikilinks), and the set of types.""" + nodes: dict[str, dict] = {} + texts: dict[str, str] = {} # nid -> file text, read once and reused for edges + for sub in PAGE_CONTENT_DIRS: + d = wiki_dir / sub + if not d.exists(): + continue + for p in sorted(d.glob("*.md")): + nid = f"{sub}/{p.stem}" + text = p.read_text(encoding="utf-8") + texts[nid] = text + fm = frontmatter.parse(text) + t = fm.get("type") + t = t.strip() if isinstance(t, str) and t.strip() else _type_for_dir(sub) + desc = fm.get("description") + desc = desc.strip() if isinstance(desc, str) else "" + srcs = fm.get("sources") + srcs = [str(s) for s in srcs] if isinstance(srcs, list) else [] + ft = fm.get("full_text") # summaries record their origin document here, not in `sources` + if isinstance(ft, str) and ft.strip(): + srcs.insert(0, ft.strip()) + nodes[nid] = {"id": nid, "label": p.stem, "type": t, + "description": desc, "sources": srcs, "out": 0, "in": 0} + + norm = {_normalize_target(nid): nid for nid in nodes} + edges: list[dict] = [] + seen: set[tuple[str, str]] = set() + for src, text in texts.items(): + for raw in _extract_wikilinks(text): + tgt = norm.get(_normalize_target(raw)) + if not tgt or tgt == src or (src, tgt) in seen: + continue + seen.add((src, tgt)) + edges.append({"source": src, "target": tgt}) + nodes[src]["out"] += 1 + nodes[tgt]["in"] += 1 + + types = sorted({n["type"] for n in nodes.values()}) + return {"nodes": list(nodes.values()), "edges": edges, "types": types} + + +def render_html(graph: dict) -> str: + """Inject the graph as JSON into the self-contained HTML template.""" + template = resources.files("openkb").joinpath("templates/graph.html").read_text(encoding="utf-8") + data = json.dumps(graph, ensure_ascii=False).replace(" breakout + return template.replace("__GRAPH_DATA__", data) From 22587dcf2c6e8d5abfa3c560fdda1f5bd992b25d Mon Sep 17 00:00:00 2001 From: mountain Date: Mon, 22 Jun 2026 18:31:53 +0800 Subject: [PATCH 3/6] feat(cli): add the visualize command Thin read-only command mirroring the other commands' decorators + KB lock: resolve KB -> build_graph -> render_html -> write output/visualize/graph.html. Opens in the browser by default (--no-open for headless), resolve()s the path for a valid file URI, and degrades with a hint if no browser launches. --- openkb/cli.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/openkb/cli.py b/openkb/cli.py index b48ebc17..a6236b7d 100644 --- a/openkb/cli.py +++ b/openkb/cli.py @@ -1494,6 +1494,36 @@ def lint(ctx, fix): asyncio.run(run_lint(kb_dir)) +@cli.command() +@click.option("--open/--no-open", "open_browser", default=True, + help="Open the graph in your browser after generating (default: on; --no-open for headless).") +@click.pass_context +@_with_kb_lock(exclusive=False) +def visualize(ctx, open_browser): + """Render the wiki's [[wikilink]] graph as a self-contained interactive HTML page.""" + kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override")) + if kb_dir is None: + click.echo("No knowledge base found. Run `openkb init` first.") + return + from openkb import visualize as viz + graph = viz.build_graph(kb_dir / "wiki") + if not graph["nodes"]: + click.echo("No wiki pages to visualize yet. Run `openkb add` first.") + return + out = kb_dir / "output" / "visualize" / "graph.html" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(viz.render_html(graph), encoding="utf-8") + click.echo(f"Graph written to {out} ({len(graph['nodes'])} nodes, {len(graph['edges'])} edges)") + if open_browser: + import webbrowser + try: + opened = webbrowser.open(out.resolve().as_uri()) # resolve() so a relative --kb-dir still yields a valid file URI + except Exception: + opened = False + if not opened: + click.echo("(couldn't launch a browser — open the file above manually, or use --no-open)") + + def print_list(kb_dir: Path) -> None: """Print all documents in the knowledge base. Usable from CLI and chat REPL.""" openkb_dir = kb_dir / ".openkb" From 983071f86382e25bb0189d01fe574b22ee696454 Mon Sep 17 00:00:00 2001 From: mountain Date: Mon, 22 Jun 2026 18:31:53 +0800 Subject: [PATCH 4/6] feat(visualize): self-contained interactive graph template (3D / mind-map / radial) One offline HTML page (canvas + DOM, no CDN/network) with three switchable views of the same KB: a 3D force 'nebula' (default; glow, degree-sized nodes, flow particles, idle auto-rotation), an OpenKB-rooted horizontal mind-map (collapsible provenance tree), and a radial OpenKB-centred circle with faint cross-references. Shared: glass inspector panel, search, legend type-filter, spacing slider, reset, smooth auto-fit. Neon-on-dark aurora. --- openkb/templates/graph.html | 907 ++++++++++++++++++++++++++++++++++++ 1 file changed, 907 insertions(+) create mode 100644 openkb/templates/graph.html diff --git a/openkb/templates/graph.html b/openkb/templates/graph.html new file mode 100644 index 00000000..e678b619 --- /dev/null +++ b/openkb/templates/graph.html @@ -0,0 +1,907 @@ + + + + + +openkb · knowledge graph + + + +
+
+
+ +
+ +
openkbknowledge graph
+ +
+ 0nodes + · + 0edges +
+
+ +
+ +
+ +
+
+ +
+ + + +
+ +
+ + + + From 9b2788bba7115288816057be2d457f9cf7ab7aad Mon Sep 17 00:00:00 2001 From: mountain Date: Mon, 22 Jun 2026 18:31:53 +0800 Subject: [PATCH 5/6] test(visualize): build_graph + render_html + CLI tests build_graph (nodes/edges/types, broken-link drop, orphan, degree, provenance sources incl. summary full_text); render_html self-contained (canvas, JSON embedded, no http(s), unicode round-trip); CLI writes output/visualize/ graph.html, opens by default, --no-open suppresses, empty wiki writes nothing. --- tests/test_visualize.py | 57 +++++++++++++++++++++++++++++++++++++ tests/test_visualize_cli.py | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tests/test_visualize.py create mode 100644 tests/test_visualize_cli.py diff --git a/tests/test_visualize.py b/tests/test_visualize.py new file mode 100644 index 00000000..3d7f2016 --- /dev/null +++ b/tests/test_visualize.py @@ -0,0 +1,57 @@ +from pathlib import Path + +from openkb.visualize import build_graph, render_html + + +def _wiki(tmp_path: Path) -> Path: + wiki = tmp_path / "wiki" + for sub in ("summaries", "concepts", "entities", "reports", "sources"): + (wiki / sub).mkdir(parents=True) + (wiki / "index.md").write_text("# Index\n", encoding="utf-8") + return wiki + + +def test_build_graph_nodes_edges_types(tmp_path): + wiki = _wiki(tmp_path) + (wiki / "summaries" / "paper.md").write_text( + '---\ntype: "Summary"\ndescription: "A paper."\nfull_text: "sources/paper.json"\n---\n\n' + "Discusses [[concepts/attention]] and [[entities/anthropic]].\n", encoding="utf-8") + (wiki / "concepts" / "attention.md").write_text( + '---\ntype: "Concept"\ndescription: "Focus."\nsources: ["summaries/paper"]\n---\n\n' + "Used by [[concepts/attention]] (self) and [[concepts/missing]] (broken).\n", encoding="utf-8") + (wiki / "entities" / "anthropic.md").write_text( + '---\ntype: "Organization"\ndescription: "AI lab."\n---\n\n' + "# Anthropic\n", encoding="utf-8") + (wiki / "concepts" / "orphan.md").write_text("# Orphan\n\nNo links.\n", encoding="utf-8") + + g = build_graph(wiki) + ids = {n["id"] for n in g["nodes"]} + assert ids == {"summaries/paper", "concepts/attention", "entities/anthropic", "concepts/orphan"} + by = {n["id"]: n for n in g["nodes"]} + assert by["concepts/orphan"]["type"] == "Concept" + assert by["entities/anthropic"]["type"] == "Organization" + edge_pairs = {(e["source"], e["target"]) for e in g["edges"]} + assert ("summaries/paper", "concepts/attention") in edge_pairs + assert ("summaries/paper", "entities/anthropic") in edge_pairs + assert not any(e["target"] == "concepts/missing" for e in g["edges"]) + assert not any(e["source"] == e["target"] for e in g["edges"]) + assert by["concepts/attention"]["in"] == 1 and by["summaries/paper"]["out"] == 2 + assert g["types"] == ["Concept", "Organization", "Summary"] + # sources: concepts use the `sources` field; summaries fall back to `full_text` (the origin doc) + assert by["concepts/attention"]["sources"] == ["summaries/paper"] + assert by["summaries/paper"]["sources"] == ["sources/paper.json"] + + +def test_build_graph_empty_wiki(tmp_path): + assert build_graph(_wiki(tmp_path)) == {"nodes": [], "edges": [], "types": []} + + +def test_render_html_self_contained(): + g = {"nodes":[{"id":"concepts/a","label":"a","type":"Concept","description":"x—y","sources":[],"out":0,"in":0}], + "edges":[], "types":["Concept"]} + html = render_html(g) + assert " Path: + for sub in ("summaries", "concepts", "entities"): + (tmp_path / "wiki" / sub).mkdir(parents=True) + (tmp_path / ".openkb").mkdir() + (tmp_path / ".openkb" / "config.yaml").write_text("model: gpt-4o-mini\n", encoding="utf-8") + (tmp_path / "wiki" / "concepts" / "a.md").write_text( + '---\ntype: "Concept"\ndescription: "d"\n---\n\nlinks [[concepts/b]]\n', encoding="utf-8") + (tmp_path / "wiki" / "concepts" / "b.md").write_text( + '---\ntype: "Concept"\ndescription: "d2"\n---\n\n# B\n', encoding="utf-8") + return tmp_path + + +def test_visualize_writes_html_and_opens_by_default(tmp_path): + kb = _kb(tmp_path) + with patch("openkb.cli._find_kb_dir", return_value=kb), \ + patch("webbrowser.open") as wb: + result = CliRunner().invoke(cli, ["visualize"]) + assert result.exit_code == 0, result.output + out = kb / "output" / "visualize" / "graph.html" + assert out.exists() + html = out.read_text(encoding="utf-8") + assert " Date: Mon, 22 Jun 2026 18:40:11 +0800 Subject: [PATCH 6/6] docs(readme): document openkb visualize Add it to the Layer 2 generators table and a '(iii) Visualize' subsection matching the existing (i)/(ii) generator sections: a self-contained interactive knowledge graph (3D / mind-map / radial) written to output/visualize/graph.html. --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 6f07d133..093a661a 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,7 @@ A "generator" reads from the compiled wiki and produces something usable: an ans |---|---| | openkb query "question" | A grounded answer with citations (`--save` to persist to `wiki/explorations/`) | | openkb chat | Interactive multi-turn session over the wiki (`--resume`, `--list`, `--delete` to manage sessions) | +| openkb visualize | A self-contained interactive knowledge graph at `output/visualize/graph.html` — 3D, mind-map, and radial views | | | | | openkb skill new <skill-name> "<intent>" | Distill a redistributable agent skill from your wiki (see [Skill Factory](#-skill-factory--drop-in-a-book-out-comes-a-digital-expert) below) | @@ -339,6 +340,14 @@ openkb skill rollback karpathy-thinking --to 2 +### (iii) 🗺 Visualize — *see the shape of your knowledge* + +`openkb visualize` renders the wiki as a single self-contained, offline HTML page with three views of the same knowledge base — a **3D** force graph, an OpenKB-rooted **mind-map**, and a **radial** tree — coloured by type and linked by `[[wikilinks]]`. + +```bash +openkb visualize # build + open output/visualize/graph.html +``` + # 🔧 Configuration ### Settings