Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 12 additions & 9 deletions docs/graph-demo/README.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
16 changes: 8 additions & 8 deletions docs/graph-demo/make_demo_vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
86 changes: 63 additions & 23 deletions docs/graph-demo/render_graph.py
Original file line number Diff line number Diff line change
@@ -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)
Binary file modified docs/graph.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading