diff --git a/CHANGELOG.md b/CHANGELOG.md index 7977cff..52e3502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Docs +- Regenerated the README hero graph (`docs/graph.png`) so its nodes are coloured by OKF `type` (with a legend), matching the web graph view. The demo renderer (`docs/graph-demo/render_graph.py`) is now a self-contained `networkx` + `matplotlib` script (no Graphviz dependency); `make_demo_vault.py` assigns each demo note an illustrative `type`. + ## [3.8.1] - 2026-07-02 ### Fixed diff --git a/README.md b/README.md index 7481ab8..8e09ece 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ OMI/Obsidian memory tooling for AI agents: reproduce the integration on any mach ![omind's knowledge graph over a vault's [[wikilinks]]](docs/graph.png) -*`omind graph` over an OMI vault — every note a node, every `[[wikilink]]` an edge. Rendered from `omind graph export` (see [docs/graph-demo](docs/graph-demo/)).* +*`omind graph` over an OMI vault — every note a node **coloured by its OKF `type`** (and sized by link degree), every `[[wikilink]]` an edge. Rendered from `omind graph export` (see [docs/graph-demo](docs/graph-demo/)).* ## What it does diff --git a/docs/graph-demo/README.md b/docs/graph-demo/README.md index a90c14c..6134a73 100644 --- a/docs/graph-demo/README.md +++ b/docs/graph-demo/README.md @@ -1,25 +1,28 @@ # Graph demo — how `docs/graph.png` was made The README hero is `omind graph` rendered over a **synthetic** demo vault (no private -data). Two small, dependency-free scripts build it: +data), with nodes **coloured by each note's OKF `type`** — the same signal omind's web +graph view uses. Two small scripts build it: 1. **`make_demo_vault.py`** — generates an OMI vault of notes whose `[[wikilinks]]` form a connected graph. It pulls proper-noun node names from a plain-text corpus you point - it at and adds a few random cross-links per note. Only the node **names** end up in the - rendered image — no prose is published. + it at, assigns each note an illustrative OKF `type` (Character / Place / Construct / + Corp / Tech), and adds a few random cross-links per note. Only node **names** and their + `type` reach the rendered image — no prose is published. ```bash python3 make_demo_vault.py /path/to/corpus.txt /tmp/demo-vault ``` -2. **`render_graph.py`** — calls `omind graph export --format json` on that vault and emits - a [Cyberdeck](https://codeberg.org/CryptoJones/cyberdeck-theme)-themed Graphviz `dot` - (near-black `#07090f` background, neon node strokes, light edges), which you render with - `sfdp`: +2. **`render_graph.py`** — calls `omind graph export --format json` on that vault and draws + a dark, force-directed PNG whose nodes are **coloured by OKF `type`** (and sized by link + degree), with a type legend, in the + [Cyberdeck](https://codeberg.org/CryptoJones/cyberdeck-theme) palette. Self-contained — + no Graphviz needed; run it with its two extra deps via `uv`: ```bash - python3 render_graph.py /tmp/demo-vault graph.dot - sfdp -Tpng -Gsize="16,9!" -Gratio=compress -Gdpi=150 graph.dot -o graph.png + MPLBACKEND=Agg uv run --with networkx --with matplotlib \ + python render_graph.py /tmp/demo-vault ../graph.png ``` The shipped image used William Gibson's *Neuromancer* as the corpus — hence Case, Molly, diff --git a/docs/graph-demo/make_demo_vault.py b/docs/graph-demo/make_demo_vault.py index ef821fe..7fb4d17 100644 --- a/docs/graph-demo/make_demo_vault.py +++ b/docs/graph-demo/make_demo_vault.py @@ -25,21 +25,21 @@ # real lines (for flavor bodies, kept local only) lines = [ln.strip() for ln in txt.splitlines() if 30 < len(ln.strip()) < 90] -# neon Cyberdeck categorical palette for node strokes -PALETTE = ["#27d4ff", "#55ff99", "#ffb000", "#ff4f4f", "#a371f7", "#4c9aff", "#ff8ad8"] -colors = {n: PALETTE[i % len(PALETTE)] for i, n in enumerate(sorted(names))} +# Give each node an OKF `type` (cycled thematically by sorted name) so the render +# demonstrates omind's colour-by-type. The demo is synthetic, so the types are +# illustrative — the point is that each node's colour encodes its `type`. +TYPES = ["Character", "Place", "Construct", "Corp", "Tech"] +types = {n: TYPES[i % len(TYPES)] for i, n in enumerate(sorted(names))} for n in names: k = random.randint(1, 3) # random out-links -> dense, organic graph targets = random.sample([m for m in names if m != n], k) - body = [f"# {n}", "", random.choice(lines), ""] + # A minimal OKF note: YAML frontmatter with the required `type`, then the body. + body = [f"---\ntype: {types[n]}\n---", "", f"# {n}", "", random.choice(lines), ""] body += [f"- [[{t}]]" for t in targets] safe = re.sub(r"[^\w '\-]", "", n) (OMI / f"{safe}.md").write_text("\n".join(body) + "\n", encoding="utf-8") -# emit the color map for the renderer -import json - -(VAULT / "colors.json").write_text(json.dumps(colors), encoding="utf-8") print(f"wrote {len(names)} Neuromancer nodes to {OMI}") +print("types:", ", ".join(sorted(set(types.values())))) print("sample:", ", ".join(names[:12])) diff --git a/docs/graph-demo/render_graph.py b/docs/graph-demo/render_graph.py index 28c8a14..c978997 100644 --- a/docs/graph-demo/render_graph.py +++ b/docs/graph-demo/render_graph.py @@ -1,31 +1,71 @@ #!/usr/bin/env python3 -"""Render omind's graph JSON for the Neuromancer vault in the Cyberdeck theme.""" +"""Render omind's graph for the demo vault as a dark, force-directed PNG, +coloured by each note's OKF `type` — mirroring omind's web graph view. + +Self-contained (no Graphviz needed). Run it with the two extra deps via uv: + + MPLBACKEND=Agg uv run --with networkx --with matplotlib \ + python render_graph.py /tmp/demo-vault ../graph.png +""" import json import pathlib import subprocess import sys -demo = sys.argv[1]; out_dot = pathlib.Path(sys.argv[2]) +import matplotlib.pyplot as plt +import networkx as nx + +demo = sys.argv[1] +out_png = pathlib.Path(sys.argv[2]) + data = json.loads(subprocess.check_output( - ["omind", "graph", "export", "--format", "json", "--vault", demo, "--folder", "OMI"], text=True)) -colors = json.loads((pathlib.Path(demo) / "colors.json").read_text()) -titles = {n["id"]: n["title"] for n in data["nodes"]} - -# Cyberdeck: near-black bg, dark-slate fills, neon strokes, light mono labels, light edges. -L = ['digraph omi {', - ' bgcolor="#07090f";', - ' layout=sfdp; overlap=prism; overlap_scaling=-4; splines=true; sep="+8";', - ' node [shape=box, style="rounded,filled", fillcolor="#11151f", ' - 'fontname="Menlo", fontsize=11, fontcolor="#d7e0ee", penwidth=1.6];', - ' edge [color="#8aa0c6", arrowsize=0.45, penwidth=0.9];'] # brighter than #5a6678 -for n in data["nodes"]: - t = n["title"] - L.append(f' "{t}" [label="{t}", color="{colors.get(t, "#27d4ff")}"];') + ["omind", "graph", "export", "--format", "json", "--vault", demo, "--folder", "OMI"], + text=True)) + +# Neon Cyberdeck colour per OKF `type` — the node's colour IS its kind. +TYPE_COLOR = { + "Character": "#27d4ff", "Place": "#55ff99", "Construct": "#a371f7", + "Corp": "#ffb000", "Tech": "#ff4f4f", +} +DEFAULT = "#8aa0c6" +BG = "#07090f" + +G = nx.DiGraph() +title, ntype = {}, {} for n in data["nodes"]: - for tgt in n["out"]: - tt = titles.get(tgt) - if tt: - L.append(f' "{n["title"]}" -> "{tt}";') -L.append("}") -out_dot.write_text("\n".join(L) + "\n", encoding="utf-8") -print("wrote", out_dot) + G.add_node(n["id"]) + title[n["id"]] = n["title"] or n["id"][:-3] + ntype[n["id"]] = n["type"] +for src, dst in data["edges"]: + G.add_edge(src, dst) + +# Deterministic force-directed layout (seeded so re-renders match). +pos = nx.spring_layout(G, seed=1984, k=0.9, iterations=240) +deg = dict(G.degree()) + +fig, ax = plt.subplots(figsize=(16, 9), dpi=150) +fig.patch.set_facecolor(BG) +ax.set_facecolor(BG) +ax.axis("off") + +nx.draw_networkx_edges(G, pos, ax=ax, edge_color="#8aa0c6", width=0.6, alpha=0.45, + arrows=True, arrowsize=5, connectionstyle="arc3,rad=0.06") +nx.draw_networkx_nodes( + G, pos, ax=ax, + node_color=[TYPE_COLOR.get(ntype[n], DEFAULT) for n in G.nodes()], # colour == OKF type + node_size=[40 + 30 * deg[n] for n in G.nodes()], # size == link degree + edgecolors=BG, linewidths=0.8) +# Label only the better-connected nodes so the hero image stays legible. +nx.draw_networkx_labels( + G, pos, ax=ax, labels={n: title[n] for n in G.nodes() if deg[n] >= 3}, + font_size=6.5, font_family="monospace", font_color="#d7e0ee") + +present = sorted({ntype[n] for n in G.nodes() if ntype[n]}) +handles = [plt.Line2D([0], [0], marker="o", linestyle="", markersize=8, markeredgecolor="none", + markerfacecolor=TYPE_COLOR.get(t, DEFAULT), label=t) for t in present] +legend = ax.legend(handles=handles, title="OKF type", loc="upper left", frameon=False, + labelcolor="#d7e0ee", fontsize=8) +legend.get_title().set_color("#8aa0c6") + +fig.savefig(out_png, facecolor=BG, bbox_inches="tight", pad_inches=0.2) +print("wrote", out_png) diff --git a/docs/graph.png b/docs/graph.png index 0d1fb3d..20491b0 100644 Binary files a/docs/graph.png and b/docs/graph.png differ