From 2c49da24f0a81086e0cd5cea0df2aaea00c4b544 Mon Sep 17 00:00:00 2001
From: Safi
Date: Thu, 23 Apr 2026 19:49:56 +0100
Subject: [PATCH 01/89] =?UTF-8?q?feat:=20graphify=20clone=20?=
=?UTF-8?q?=20=E2=80=94=20clone=20any=20repo=20and=20run=20full=20pipeline?=
=?UTF-8?q?=20on=20it?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
graphify/__main__.py | 75 ++++++++++++++++++++++++++++++++++++++++++++
graphify/skill.md | 14 +++++++++
2 files changed, 89 insertions(+)
diff --git a/graphify/__main__.py b/graphify/__main__.py
index 9abaf5a55..c94f11b27 100644
--- a/graphify/__main__.py
+++ b/graphify/__main__.py
@@ -906,6 +906,59 @@ def claude_uninstall(project_dir: Path | None = None) -> None:
_uninstall_claude_hook(project_dir or Path("."))
+def _clone_repo(url: str, branch: str | None = None, out_dir: Path | None = None) -> Path:
+ """Clone a GitHub repo to a local cache dir and return the path.
+
+ Clones into ~/.graphify/repos// by default so repeated
+ runs on the same URL reuse the existing clone (git pull instead of clone).
+ """
+ import subprocess as _sp
+ import re as _re
+
+ # Normalise URL — strip trailing .git if present
+ url = url.rstrip("/")
+ if not url.endswith(".git"):
+ git_url = url + ".git"
+ else:
+ git_url = url
+ url = url[:-4]
+
+ # Extract owner/repo from URL
+ m = _re.search(r"github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?$", url)
+ if not m:
+ print(f"error: not a recognised GitHub URL: {url}", file=sys.stderr)
+ sys.exit(1)
+ owner, repo = m.group(1), m.group(2)
+
+ if out_dir:
+ dest = out_dir
+ else:
+ dest = Path.home() / ".graphify" / "repos" / owner / repo
+
+ if dest.exists():
+ print(f"Repo already cloned at {dest} — pulling latest...", flush=True)
+ cmd = ["git", "-C", str(dest), "pull"]
+ if branch:
+ cmd += ["origin", branch]
+ result = _sp.run(cmd, capture_output=True, text=True)
+ if result.returncode != 0:
+ print(f"warning: git pull failed:\n{result.stderr}", file=sys.stderr)
+ else:
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ print(f"Cloning {url} → {dest} ...", flush=True)
+ cmd = ["git", "clone", "--depth", "1"]
+ if branch:
+ cmd += ["--branch", branch]
+ cmd += [git_url, str(dest)]
+ result = _sp.run(cmd, capture_output=True, text=True)
+ if result.returncode != 0:
+ print(f"error: git clone failed:\n{result.stderr}", file=sys.stderr)
+ sys.exit(1)
+
+ print(f"Ready at: {dest}", flush=True)
+ return dest
+
+
def main() -> None:
# Check all known skill install locations for a stale version stamp.
# Skip during install/uninstall (hook writes trigger a fresh check anyway).
@@ -923,6 +976,9 @@ def main() -> None:
print(" --graph path to graph.json (default graphify-out/graph.json)")
print(" explain \"X\" plain-language explanation of a node and its neighbors")
print(" --graph path to graph.json (default graphify-out/graph.json)")
+ print(" clone clone a GitHub repo locally and print its path for /graphify")
+ print(" --branch checkout a specific branch (default: repo default)")
+ print(" --out clone to a custom directory (default: ~/.graphify/repos//)")
print(" add fetch a URL and save it to ./raw, then update the graph")
print(" --author \"Name\" tag the author of the content")
print(" --contributor \"Name\" tag who added it to the corpus")
@@ -1359,6 +1415,25 @@ def main() -> None:
from graphify.watch import check_update
check_update(Path(sys.argv[2]).resolve())
sys.exit(0)
+ elif cmd == "clone":
+ if len(sys.argv) < 3:
+ print("Usage: graphify clone [--branch ] [--out ]", file=sys.stderr)
+ sys.exit(1)
+ url = sys.argv[2]
+ branch: str | None = None
+ out_dir: Path | None = None
+ args = sys.argv[3:]
+ i = 0
+ while i < len(args):
+ if args[i] == "--branch" and i + 1 < len(args):
+ branch = args[i + 1]; i += 2
+ elif args[i] == "--out" and i + 1 < len(args):
+ out_dir = Path(args[i + 1]); i += 2
+ else:
+ i += 1
+ local_path = _clone_repo(url, branch=branch, out_dir=out_dir)
+ print(local_path)
+
elif cmd == "benchmark":
from graphify.benchmark import run_benchmark, print_benchmark
graph_path = sys.argv[2] if len(sys.argv) > 2 else "graphify-out/graph.json"
diff --git a/graphify/skill.md b/graphify/skill.md
index 0674b0a79..abbf90190 100644
--- a/graphify/skill.md
+++ b/graphify/skill.md
@@ -13,6 +13,8 @@ Turn any folder of files into a navigable knowledge graph with community detecti
```
/graphify # full pipeline on current directory → Obsidian vault
/graphify # full pipeline on specific path
+/graphify https://github.com// # clone repo then run full pipeline on it
+/graphify https://github.com// --branch # clone a specific branch
/graphify --mode deep # thorough extraction, richer INFERRED edges
/graphify --update # incremental - re-extract only new/changed files
/graphify --directed # build directed graph (preserves edge direction: source→target)
@@ -57,8 +59,20 @@ Use it for:
If no path was given, use `.` (current directory). Do not ask the user for a path.
+If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL — run Step 0 before anything else, then continue with the resolved local path.
+
Follow these steps in order. Do not skip steps.
+### Step 0 - Clone GitHub repo (only if a GitHub URL was given)
+
+```bash
+# Clone the repo (or pull if already cloned) and capture the local path
+LOCAL_PATH=$(graphify clone [--branch ])
+# Use LOCAL_PATH as the target for all subsequent steps
+```
+
+Graphify clones into `~/.graphify/repos//` so repeated calls on the same URL reuse the existing clone. Print the resolved path to the user before continuing. If `--branch` was specified, pass it through.
+
### Step 1 - Ensure graphify is installed
```bash
From 2faeed99a2e9939772e304ceec61d918be8d022f Mon Sep 17 00:00:00 2001
From: Safi
Date: Thu, 23 Apr 2026 19:59:14 +0100
Subject: [PATCH 02/89] feat: cross-repo merge-graphs; fix #527
CLAUDE_CONFIG_DIR; fix #524 graphify-out excluded from source scan
---
graphify/__main__.py | 50 +++++++++++++++++++++++++++++++++++++++++++-
graphify/detect.py | 1 +
graphify/skill.md | 20 +++++++++++++++---
3 files changed, 67 insertions(+), 4 deletions(-)
diff --git a/graphify/__main__.py b/graphify/__main__.py
index c94f11b27..be14274f8 100644
--- a/graphify/__main__.py
+++ b/graphify/__main__.py
@@ -148,7 +148,12 @@ def install(platform: str = "claude") -> None:
print(f"error: {cfg['skill_file']} not found in package - reinstall graphify", file=sys.stderr)
sys.exit(1)
- skill_dst = Path.home() / cfg["skill_dst"]
+ import os as _os
+ if platform in ("claude", "windows") and _os.environ.get("CLAUDE_CONFIG_DIR"):
+ _claude_base = Path(_os.environ["CLAUDE_CONFIG_DIR"])
+ skill_dst = _claude_base / "skills" / "graphify" / "SKILL.md"
+ else:
+ skill_dst = Path.home() / cfg["skill_dst"]
skill_dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(skill_src, skill_dst)
(skill_dst.parent / ".graphify_version").write_text(__version__, encoding="utf-8")
@@ -977,6 +982,8 @@ def main() -> None:
print(" explain \"X\" plain-language explanation of a node and its neighbors")
print(" --graph path to graph.json (default graphify-out/graph.json)")
print(" clone clone a GitHub repo locally and print its path for /graphify")
+ print(" merge-graphs merge two or more graph.json files into one cross-repo graph")
+ print(" --out output path (default: graphify-out/merged-graph.json)")
print(" --branch checkout a specific branch (default: repo default)")
print(" --out clone to a custom directory (default: ~/.graphify/repos//)")
print(" add fetch a URL and save it to ./raw, then update the graph")
@@ -1415,6 +1422,47 @@ def main() -> None:
from graphify.watch import check_update
check_update(Path(sys.argv[2]).resolve())
sys.exit(0)
+ elif cmd == "merge-graphs":
+ # graphify merge-graphs graph1.json graph2.json ... --out merged.json
+ args = sys.argv[2:]
+ graph_paths: list[Path] = []
+ out_path = Path("graphify-out/merged-graph.json")
+ i = 0
+ while i < len(args):
+ if args[i] == "--out" and i + 1 < len(args):
+ out_path = Path(args[i + 1]); i += 2
+ else:
+ graph_paths.append(Path(args[i])); i += 1
+ if len(graph_paths) < 2:
+ print("Usage: graphify merge-graphs [...] [--out merged.json]", file=sys.stderr)
+ sys.exit(1)
+ import networkx as _nx
+ from networkx.readwrite import json_graph as _jg
+ graphs = []
+ for gp in graph_paths:
+ if not gp.exists():
+ print(f"error: not found: {gp}", file=sys.stderr)
+ sys.exit(1)
+ data = json.loads(gp.read_text(encoding="utf-8"))
+ try:
+ G = _jg.node_link_graph(data, edges="links")
+ except TypeError:
+ G = _jg.node_link_graph(data)
+ # Tag every node with which repo it came from
+ repo_tag = gp.parent.parent.name # graphify-out/../ → repo dir name
+ for node in G.nodes:
+ G.nodes[node].setdefault("repo", repo_tag)
+ graphs.append(G)
+ merged = _nx.compose_all(graphs)
+ try:
+ out_data = _jg.node_link_data(merged, edges="links")
+ except TypeError:
+ out_data = _jg.node_link_data(merged)
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ out_path.write_text(json.dumps(out_data, indent=2), encoding="utf-8")
+ print(f"Merged {len(graphs)} graphs → {merged.number_of_nodes()} nodes, {merged.number_of_edges()} edges")
+ print(f"Written to: {out_path}")
+
elif cmd == "clone":
if len(sys.argv) < 3:
print("Usage: graphify clone [--branch ] [--out ]", file=sys.stderr)
diff --git a/graphify/detect.py b/graphify/detect.py
index 392625877..338449291 100644
--- a/graphify/detect.py
+++ b/graphify/detect.py
@@ -241,6 +241,7 @@ def count_words(path: Path) -> int:
"site-packages", "lib64",
".pytest_cache", ".mypy_cache", ".ruff_cache",
".tox", ".eggs", "*.egg-info",
+ "graphify-out", # never treat own output as source input (#524)
}
# Large generated files that are never useful to extract
diff --git a/graphify/skill.md b/graphify/skill.md
index abbf90190..6d45b1534 100644
--- a/graphify/skill.md
+++ b/graphify/skill.md
@@ -15,6 +15,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
+/graphify ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify --mode deep # thorough extraction, richer INFERRED edges
/graphify --update # incremental - re-extract only new/changed files
/graphify --directed # build directed graph (preserves edge direction: source→target)
@@ -63,15 +64,28 @@ If the path argument starts with `https://github.com/` or `http://github.com/`,
Follow these steps in order. Do not skip steps.
-### Step 0 - Clone GitHub repo (only if a GitHub URL was given)
+### Step 0 - Clone GitHub repo(s) (only if a GitHub URL was given)
+**Single repo:**
```bash
-# Clone the repo (or pull if already cloned) and capture the local path
LOCAL_PATH=$(graphify clone [--branch ])
# Use LOCAL_PATH as the target for all subsequent steps
```
-Graphify clones into `~/.graphify/repos//` so repeated calls on the same URL reuse the existing clone. Print the resolved path to the user before continuing. If `--branch` was specified, pass it through.
+**Multiple repos (cross-repo graph):**
+```bash
+# Clone each repo, run the full pipeline on each, then merge
+graphify clone # → ~/.graphify/repos//
+graphify clone # → ~/.graphify/repos//
+# Run /graphify on each local path to produce their graph.json files
+# Then merge:
+graphify merge-graphs \
+ ~/.graphify/repos///graphify-out/graph.json \
+ ~/.graphify/repos///graphify-out/graph.json \
+ --out graphify-out/cross-repo-graph.json
+```
+
+Graphify clones into `~/.graphify/repos//` and reuses existing clones on repeat runs. Each node in the merged graph carries a `repo` attribute so you can filter by origin.
### Step 1 - Ensure graphify is installed
From df9b7ec54f2593ba9aaa86184f6ba9b1a7ca97f5 Mon Sep 17 00:00:00 2001
From: Safi
Date: Thu, 23 Apr 2026 20:04:43 +0100
Subject: [PATCH 03/89] fix #479 #451: build_merge(), pre-write shrink guard,
label dedup, chunk-suffix prompt block
---
graphify/build.py | 121 +++++++++++++++++++++++++++++++++++++++++++++
graphify/export.py | 22 ++++++++-
graphify/skill.md | 2 +-
3 files changed, 143 insertions(+), 2 deletions(-)
diff --git a/graphify/build.py b/graphify/build.py
index 19b5b1867..2c2c773bb 100644
--- a/graphify/build.py
+++ b/graphify/build.py
@@ -21,8 +21,10 @@
# before any graph construction happens.
#
from __future__ import annotations
+import json
import re
import sys
+from pathlib import Path
import networkx as nx
from .validate import validate_extraction
@@ -50,6 +52,18 @@ def build_from_json(extraction: dict, *, directed: bool = False) -> nx.Graph:
# Canonicalize legacy node/edge schema before validation.
for node in extraction.get("nodes", []):
if isinstance(node, dict) and "source" in node and "source_file" not in node:
+ # Count edges that reference this node so the warning is actionable (#479)
+ node_id = node.get("id", "?")
+ affected_edges = sum(
+ 1 for e in extraction.get("edges", [])
+ if e.get("source") == node_id or e.get("target") == node_id
+ )
+ print(
+ f"[graphify] WARNING: node '{node_id}' uses field 'source' instead of "
+ f"'source_file' — {affected_edges} edge(s) may be misrouted. "
+ f"Rename the field to 'source_file' to silence this warning.",
+ file=sys.stderr,
+ )
node["source_file"] = node.pop("source")
errors = validate_extraction(extraction)
@@ -111,3 +125,110 @@ def build(extractions: list[dict], *, directed: bool = False) -> nx.Graph:
combined["input_tokens"] += ext.get("input_tokens", 0)
combined["output_tokens"] += ext.get("output_tokens", 0)
return build_from_json(combined, directed=directed)
+
+
+def _norm_label(label: str) -> str:
+ """Canonical dedup key — lowercase, alphanumeric only."""
+ return re.sub(r"[^a-z0-9 ]", "", label.lower()).strip()
+
+
+def deduplicate_by_label(nodes: list[dict], edges: list[dict]) -> tuple[list[dict], list[dict]]:
+ """Merge nodes that share a normalised label, rewriting edge references.
+
+ Prefers IDs without chunk suffixes (_c\\d+) and shorter IDs when tied.
+ Drops self-loops created by the merge. Called in build() automatically.
+ """
+ _CHUNK_SUFFIX = re.compile(r"_c\d+$")
+ canonical: dict[str, dict] = {} # norm_label -> surviving node
+ remap: dict[str, str] = {} # old_id -> surviving_id
+
+ for node in nodes:
+ key = _norm_label(node.get("label", node.get("id", "")))
+ if not key:
+ continue
+ existing = canonical.get(key)
+ if existing is None:
+ canonical[key] = node
+ else:
+ has_suffix = bool(_CHUNK_SUFFIX.search(node["id"]))
+ existing_has_suffix = bool(_CHUNK_SUFFIX.search(existing["id"]))
+ if has_suffix and not existing_has_suffix:
+ remap[node["id"]] = existing["id"]
+ elif existing_has_suffix and not has_suffix:
+ remap[existing["id"]] = node["id"]
+ canonical[key] = node
+ elif len(node["id"]) < len(existing["id"]):
+ remap[existing["id"]] = node["id"]
+ canonical[key] = node
+ else:
+ remap[node["id"]] = existing["id"]
+
+ if not remap:
+ return nodes, edges
+
+ print(f"[graphify] Deduplicated {len(remap)} duplicate node(s) by label.", file=sys.stderr)
+ deduped_nodes = list(canonical.values())
+ deduped_edges = []
+ for edge in edges:
+ e = dict(edge)
+ e["source"] = remap.get(e["source"], e["source"])
+ e["target"] = remap.get(e["target"], e["target"])
+ if e["source"] != e["target"]:
+ deduped_edges.append(e)
+ return deduped_nodes, deduped_edges
+
+
+def build_merge(
+ new_chunks: list[dict],
+ graph_path: str | Path = "graphify-out/graph.json",
+ prune_sources: list[str] | None = None,
+ *,
+ directed: bool = False,
+) -> nx.Graph:
+ """Load existing graph.json, merge new chunks into it, and save back.
+
+ Never replaces — only grows (or prunes deleted-file nodes via prune_sources).
+ Safe to call repeatedly: existing nodes and edges are preserved.
+ """
+ from networkx.readwrite import json_graph as _jg
+
+ graph_path = Path(graph_path)
+ if graph_path.exists():
+ data = json.loads(graph_path.read_text(encoding="utf-8"))
+ try:
+ existing_G = _jg.node_link_graph(data, edges="links")
+ except TypeError:
+ existing_G = _jg.node_link_graph(data)
+ # Reconstruct as a plain extraction dict so build() can merge it
+ existing_nodes = [{"id": n, **existing_G.nodes[n]} for n in existing_G.nodes]
+ existing_edges = [
+ {"source": u, "target": v, **d} for u, v, d in existing_G.edges(data=True)
+ ]
+ base = [{"nodes": existing_nodes, "edges": existing_edges}]
+ else:
+ base = []
+
+ all_chunks = base + list(new_chunks)
+ G = build(all_chunks, directed=directed)
+
+ # Prune nodes from deleted source files
+ if prune_sources:
+ to_remove = [
+ n for n, d in G.nodes(data=True)
+ if d.get("source_file") in prune_sources
+ ]
+ G.remove_nodes_from(to_remove)
+ if to_remove:
+ print(f"[graphify] Pruned {len(to_remove)} node(s) from deleted sources.", file=sys.stderr)
+
+ # Safety check: refuse to shrink the graph silently (#479)
+ if graph_path.exists():
+ existing_n = len(existing_nodes)
+ new_n = G.number_of_nodes()
+ if new_n < existing_n:
+ raise ValueError(
+ f"graphify: build_merge would shrink graph from {existing_n} → {new_n} nodes. "
+ f"Pass prune_sources explicitly if you intend to remove nodes."
+ )
+
+ return G
diff --git a/graphify/export.py b/graphify/export.py
index 12cafb874..6eb4e0d51 100644
--- a/graphify/export.py
+++ b/graphify/export.py
@@ -279,7 +279,27 @@ def attach_hyperedges(G: nx.Graph, hyperedges: list) -> None:
G.graph["hyperedges"] = existing
-def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str) -> None:
+def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False) -> None:
+ # Safety check: refuse to silently shrink an existing graph (#479)
+ existing_path = Path(output_path)
+ if not force and existing_path.exists():
+ try:
+ existing_data = json.loads(existing_path.read_text(encoding="utf-8"))
+ existing_n = len(existing_data.get("nodes", []))
+ new_n = G.number_of_nodes()
+ if new_n < existing_n:
+ import sys as _sys
+ print(
+ f"[graphify] WARNING: new graph has {new_n} nodes but existing "
+ f"graph.json has {existing_n}. Refusing to overwrite — you may be "
+ f"missing chunk files from a previous session. "
+ f"Pass force=True to override.",
+ file=_sys.stderr,
+ )
+ return
+ except Exception:
+ pass # unreadable existing file — proceed with write
+
node_community = _node_community_map(communities)
try:
data = json_graph.node_link_data(G, edges="links")
diff --git a/graphify/skill.md b/graphify/skill.md
index 6d45b1534..60d242209 100644
--- a/graphify/skill.md
+++ b/graphify/skill.md
@@ -335,7 +335,7 @@ confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a d
Weak or speculative: 0.4-0.5. Most edges should be 0.6-0.9, not 0.5.
- AMBIGUOUS edges: 0.1-0.3
-Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the filename without extension and entity is the symbol name, both normalized (lowercase, non-alphanumeric chars replaced with `_`). Example: `src/auth/session.py` + `ValidateToken` → `session_validatetoken`. This must match the ID the AST extractor generates so cross-references between code and semantic nodes connect correctly.
+Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the filename without extension and entity is the symbol name, both normalized (lowercase, non-alphanumeric chars replaced with `_`). Example: `src/auth/session.py` + `ValidateToken` → `session_validatetoken`. This must match the ID the AST extractor generates so cross-references between code and semantic nodes connect correctly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
Output exactly this JSON (no other text):
{"nodes":[{"id":"session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image","source_file":"relative/path","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"relative/path","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":"relative/path"}],"input_tokens":0,"output_tokens":0}
From 8bed332ff4b0c518fa9f2e76f38de4372f4a6e9b Mon Sep 17 00:00:00 2001
From: Safi
Date: Thu, 23 Apr 2026 20:09:03 +0100
Subject: [PATCH 04/89] =?UTF-8?q?release:=20bump=20to=20v0.5.0=20=E2=80=94?=
=?UTF-8?q?=20clone,=20merge-graphs,=20shrink=20guard,=20dedup,=20bug=20fi?=
=?UTF-8?q?xes?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 18 ++++++++++++++++++
pyproject.toml | 2 +-
2 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index efcca4de1..21ca8179a 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,16 @@ dist/
Same syntax as `.gitignore`. You can keep a single `.graphifyignore` at your repo root — patterns work correctly even when graphify is run on a subfolder.
+## What's new in v0.5.0
+
+- **`graphify clone `** — clone any public GitHub repo and run the full pipeline on it. Clones to `~/.graphify/repos//`, reuses existing clones on repeat runs (`git pull`). Supports `--branch` and `--out`.
+- **`graphify merge-graphs`** — combine two or more `graph.json` outputs into one cross-repo graph. Each node is tagged with its source repo. Useful for mapping dependencies across multiple projects.
+- **`CLAUDE_CONFIG_DIR` support** — `graphify install` now respects the `CLAUDE_CONFIG_DIR` environment variable when installing the Claude Code skill, instead of always writing to `~/.claude`.
+- **Shrink guard** — `to_json()` refuses to overwrite `graph.json` with a smaller graph. Prevents silent data loss when `--update` is called with a partial chunk list.
+- **`build_merge()`** — new library function for safe incremental updates: loads existing graph, merges new chunks, optionally prunes deleted-file nodes, never shrinks.
+- **Duplicate node deduplication** — `deduplicate_by_label()` collapses nodes that share a normalised label (e.g. from parallel subagents generating `achille_varzi` and `achille_varzi_c4`). Chunk-suffix contamination is also blocked at the prompt level.
+- **Bug fixes** — `graphify-out/` is now excluded from source scanning so generated artifacts never trigger false incremental refresh pressure.
+
## How it works
graphify runs in three passes. First, a deterministic AST pass extracts structure from code files (classes, functions, imports, call graphs, docstrings, rationale comments) with no LLM needed. Second, video and audio files are transcribed locally with faster-whisper using a domain-aware prompt derived from corpus god nodes — transcripts are cached so re-runs are instant. Third, Claude subagents run in parallel over docs, papers, images, and transcripts to extract concepts, relationships, and design rationale. The results are merged into a NetworkX graph, clustered with Leiden community detection, and exported as interactive HTML, queryable JSON, and a plain-language audit report.
@@ -327,6 +337,14 @@ graphify explain "SwinTransformer" # plain-language explanation of a n
graphify add https://arxiv.org/abs/1706.03762 # fetch paper, save to ./raw, update graph
graphify add https://... --author "Name" --contributor "Name"
+# clone any GitHub repo and run the full pipeline on it
+graphify clone https://github.com/karpathy/nanoGPT # clones to ~/.graphify/repos/karpathy/nanoGPT
+graphify clone https://github.com/org/repo --branch dev --out ./my-clone
+
+# cross-repo graphs — merge two or more graph.json outputs into one
+graphify merge-graphs repo1/graphify-out/graph.json repo2/graphify-out/graph.json
+graphify merge-graphs g1.json g2.json g3.json --out cross-repo.json
+
# incremental update and maintenance
graphify watch ./src # auto-rebuild on code changes
graphify check-update ./src # check if semantic re-extraction is pending (cron-safe)
diff --git a/pyproject.toml b/pyproject.toml
index 4ed246e27..058f6de5f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "graphifyy"
-version = "0.4.31"
+version = "0.5.0"
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
readme = "README.md"
license = { file = "LICENSE" }
From 3ff7188fbf276af8cb447a978e32f5c5f75ccd14 Mon Sep 17 00:00:00 2001
From: Danil Tarasov
Date: Fri, 24 Apr 2026 02:59:04 +0300
Subject: [PATCH 05/89] feat: add cross-language edge contexts and
context-aware queries
---
graphify/__main__.py | 30 ++++---
graphify/extract.py | 178 +++++++++++++++++++++++++++++++---------
graphify/serve.py | 120 +++++++++++++++++++++++++--
tests/test_extract.py | 7 ++
tests/test_languages.py | 148 +++++++++++++++++++++++++++++++++
tests/test_multilang.py | 46 +++++++++++
tests/test_query_cli.py | 51 ++++++++++++
tests/test_serve.py | 49 ++++++++++-
8 files changed, 567 insertions(+), 62 deletions(-)
create mode 100644 tests/test_query_cli.py
diff --git a/graphify/__main__.py b/graphify/__main__.py
index be14274f8..535c9a601 100644
--- a/graphify/__main__.py
+++ b/graphify/__main__.py
@@ -995,6 +995,7 @@ def main() -> None:
print(" cluster-only rerun clustering on an existing graph.json and regenerate report")
print(" query \"\" BFS traversal of graph.json for a question")
print(" --dfs use depth-first instead of breadth-first")
+ print(" --context C explicit edge-context filter (repeatable)")
print(" --budget N cap output at N tokens (default 2000)")
print(" --graph path to graph.json (default graphify-out/graph.json)")
print(" save-result save a Q&A result to graphify-out/memory/ for graph feedback loop")
@@ -1159,15 +1160,16 @@ def main() -> None:
sys.exit(1)
elif cmd == "query":
if len(sys.argv) < 3:
- print("Usage: graphify query \"\" [--dfs] [--budget N] [--graph path]", file=sys.stderr)
+ print("Usage: graphify query \"\" [--dfs] [--context C] [--budget N] [--graph path]", file=sys.stderr)
sys.exit(1)
- from graphify.serve import _score_nodes, _bfs, _dfs, _subgraph_to_text
+ from graphify.serve import _query_graph_text
from graphify.security import sanitize_label
from networkx.readwrite import json_graph
question = sys.argv[2]
use_dfs = "--dfs" in sys.argv
budget = 2000
graph_path = "graphify-out/graph.json"
+ context_filters: list[str] = []
args = sys.argv[3:]
i = 0
while i < len(args):
@@ -1185,6 +1187,12 @@ def main() -> None:
print(f"error: --budget must be an integer", file=sys.stderr)
sys.exit(1)
i += 1
+ elif args[i] == "--context" and i + 1 < len(args):
+ context_filters.append(args[i + 1])
+ i += 2
+ elif args[i].startswith("--context="):
+ context_filters.append(args[i].split("=", 1)[1])
+ i += 1
elif args[i] == "--graph" and i + 1 < len(args):
graph_path = args[i + 1]; i += 2
else:
@@ -1207,14 +1215,16 @@ def main() -> None:
except Exception as exc:
print(f"error: could not load graph: {exc}", file=sys.stderr)
sys.exit(1)
- terms = [t.lower() for t in question.split() if len(t) > 2]
- scored = _score_nodes(G, terms)
- if not scored:
- print("No matching nodes found.")
- sys.exit(0)
- start = [nid for _, nid in scored[:5]]
- nodes, edges = (_dfs if use_dfs else _bfs)(G, start, depth=2)
- print(_subgraph_to_text(G, nodes, edges, token_budget=budget))
+ print(
+ _query_graph_text(
+ G,
+ question,
+ mode="dfs" if use_dfs else "bfs",
+ depth=2,
+ token_budget=budget,
+ context_filters=context_filters,
+ )
+ )
elif cmd == "save-result":
# graphify save-result --question Q --answer A --type T [--nodes N1 N2 ...]
import argparse as _ap
diff --git a/graphify/extract.py b/graphify/extract.py
index dbd441c6c..4bef45fb8 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -108,6 +108,7 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
+ "context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -132,6 +133,7 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s
"source": file_nid,
"target": tgt_nid,
"relation": "imports_from",
+ "context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -165,6 +167,7 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p
"source": file_nid,
"target": tgt_nid,
"relation": "imports_from",
+ "context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -203,6 +206,7 @@ def _walk_scoped(n) -> str:
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
+ "context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -222,6 +226,7 @@ def _import_c(node, source: bytes, file_nid: str, stem: str, edges: list, str_pa
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
+ "context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -241,6 +246,7 @@ def _import_csharp(node, source: bytes, file_nid: str, stem: str, edges: list, s
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
+ "context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -260,6 +266,7 @@ def _import_kotlin(node, source: bytes, file_nid: str, stem: str, edges: list, s
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
+ "context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -275,6 +282,7 @@ def _import_kotlin(node, source: bytes, file_nid: str, stem: str, edges: list, s
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
+ "context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -294,6 +302,7 @@ def _import_scala(node, source: bytes, file_nid: str, stem: str, edges: list, st
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
+ "context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -313,6 +322,7 @@ def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
+ "context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -591,6 +601,7 @@ def _import_lua(node, source: bytes, file_nid: str, stem: str, edges: list, str_
"source": file_nid,
"target": module_name,
"relation": "imports",
+ "context": "import",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": str_path,
@@ -625,6 +636,7 @@ def _import_swift(node, source: bytes, file_nid: str, stem: str, edges: list, st
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
+ "context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
@@ -633,6 +645,27 @@ def _import_swift(node, source: bytes, file_nid: str, stem: str, edges: list, st
break
+def _read_csharp_type_name(node, source: bytes) -> str | None:
+ """Resolve a readable C# type name from a field/type node."""
+ if node is None:
+ return None
+ if node.type in ("identifier", "predefined_type"):
+ return _read_text(node, source)
+ if node.type == "qualified_name":
+ return _read_text(node, source).split(".")[-1]
+ if node.type == "generic_name":
+ name_node = node.child_by_field_name("name")
+ if name_node is not None:
+ return _read_text(name_node, source)
+ for child in node.children:
+ if not child.is_named:
+ continue
+ name = _read_csharp_type_name(child, source)
+ if name:
+ return name
+ return None
+
+
_SWIFT_CONFIG = LanguageConfig(
ts_module="tree_sitter_swift",
class_types=frozenset({"class_declaration", "protocol_declaration"}),
@@ -696,8 +729,9 @@ def add_node(nid: str, label: str, line: int) -> None:
})
def add_edge(src: str, tgt: str, relation: str, line: int,
- confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
- edges.append({
+ confidence: str = "EXTRACTED", weight: float = 1.0,
+ context: str | None = None) -> None:
+ edge = {
"source": src,
"target": tgt,
"relation": relation,
@@ -705,7 +739,19 @@ def add_edge(src: str, tgt: str, relation: str, line: int,
"source_file": str_path,
"source_location": f"L{line}",
"weight": weight,
- })
+ }
+ if context:
+ edge["context"] = context
+ edges.append(edge)
+
+ def ensure_named_node(name: str, line: int) -> str:
+ nid = _make_id(stem, name)
+ if nid in seen_ids:
+ return nid
+ nid = _make_id(name)
+ if nid not in seen_ids:
+ add_node(nid, name, line)
+ return nid
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
@@ -904,6 +950,23 @@ def _emit_java_parent(base_name: str, rel: str, at_line: int) -> None:
break
return
+ if (config.ts_module == "tree_sitter_c_sharp"
+ and t == "field_declaration"
+ and parent_class_nid):
+ type_node = node.child_by_field_name("type")
+ if type_node is None:
+ for child in node.children:
+ if child.type == "variable_declaration":
+ type_node = child.child_by_field_name("type")
+ if type_node is not None:
+ break
+ type_name = _read_csharp_type_name(type_node, source)
+ if type_name:
+ line = node.start_point[0] + 1
+ add_edge(parent_class_nid, ensure_named_node(type_name, line),
+ "references", line, context="field")
+ return
+
# Function types
if t in config.function_types:
# Swift deinit/subscript have no name field — resolve before generic fallback
@@ -1104,6 +1167,7 @@ def walk_calls(node, caller_nid: str) -> None:
"source": caller_nid,
"target": tgt_nid,
"relation": "calls",
+ "context": "call",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
@@ -1695,8 +1759,9 @@ def add_node(nid: str, label: str, line: int) -> None:
})
def add_edge(src: str, tgt: str, relation: str, line: int,
- confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
- edges.append({
+ confidence: str = "EXTRACTED", weight: float = 1.0,
+ context: str | None = None) -> None:
+ edge = {
"source": src,
"target": tgt,
"relation": relation,
@@ -1704,7 +1769,10 @@ def add_edge(src: str, tgt: str, relation: str, line: int,
"source_file": str_path,
"source_location": f"L{line}",
"weight": weight,
- })
+ }
+ if context:
+ edge["context"] = context
+ edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
@@ -1731,14 +1799,14 @@ def walk_calls(body_node, func_nid: str) -> None:
callee_name = _read_text(callee, source)
target_nid = _make_id(stem, callee_name)
add_edge(func_nid, target_nid, "calls", body_node.start_point[0] + 1,
- confidence="EXTRACTED")
+ confidence="EXTRACTED", context="call")
# Method call: obj.method(...)
elif callee.type == "field_expression" and len(callee.children) >= 3:
method_node = callee.children[-1]
method_name = _read_text(method_node, source)
target_nid = _make_id(stem, method_name)
add_edge(func_nid, target_nid, "calls", body_node.start_point[0] + 1,
- confidence="EXTRACTED")
+ confidence="EXTRACTED", context="call")
for child in body_node.children:
walk_calls(child, func_nid)
@@ -1839,14 +1907,14 @@ def walk(node, scope_nid: str) -> None:
mod_name = _read_text(child, source)
imp_nid = _make_id(mod_name)
add_node(imp_nid, mod_name, line)
- add_edge(scope_nid, imp_nid, "imports", line)
+ add_edge(scope_nid, imp_nid, "imports", line, context="import")
elif child.type == "selected_import":
identifiers = [c for c in child.children if c.type == "identifier"]
if identifiers:
pkg_name = _read_text(identifiers[0], source)
pkg_nid = _make_id(pkg_name)
add_node(pkg_nid, pkg_name, line)
- add_edge(scope_nid, pkg_nid, "imports", line)
+ add_edge(scope_nid, pkg_nid, "imports", line, context="import")
return
for child in node.children:
@@ -1910,8 +1978,9 @@ def add_node(nid: str, label: str, line: int) -> None:
})
def add_edge(src: str, tgt: str, relation: str, line: int,
- confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
- edges.append({
+ confidence: str = "EXTRACTED", weight: float = 1.0,
+ context: str | None = None) -> None:
+ edge = {
"source": src,
"target": tgt,
"relation": relation,
@@ -1919,7 +1988,10 @@ def add_edge(src: str, tgt: str, relation: str, line: int,
"source_file": str_path,
"source_location": f"L{line}",
"weight": weight,
- })
+ }
+ if context:
+ edge["context"] = context
+ edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
@@ -1993,13 +2065,13 @@ def walk(node) -> None:
# Prefix with go_pkg_ so stdlib names (e.g. "context")
# don't collide with local files of the same basename.
tgt_nid = _make_id("go", "pkg", raw)
- add_edge(file_nid, tgt_nid, "imports_from", spec.start_point[0] + 1)
+ add_edge(file_nid, tgt_nid, "imports_from", spec.start_point[0] + 1, context="import")
elif child.type == "import_spec":
path_node = child.child_by_field_name("path")
if path_node:
raw = _read_text(path_node, source).strip('"')
tgt_nid = _make_id("go", "pkg", raw)
- add_edge(file_nid, tgt_nid, "imports_from", child.start_point[0] + 1)
+ add_edge(file_nid, tgt_nid, "imports_from", child.start_point[0] + 1, context="import")
return
for child in node.children:
@@ -2040,6 +2112,7 @@ def walk_calls(node, caller_nid: str) -> None:
"source": caller_nid,
"target": tgt_nid,
"relation": "calls",
+ "context": "call",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
@@ -2106,8 +2179,9 @@ def add_node(nid: str, label: str, line: int) -> None:
})
def add_edge(src: str, tgt: str, relation: str, line: int,
- confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
- edges.append({
+ confidence: str = "EXTRACTED", weight: float = 1.0,
+ context: str | None = None) -> None:
+ edge = {
"source": src,
"target": tgt,
"relation": relation,
@@ -2115,7 +2189,10 @@ def add_edge(src: str, tgt: str, relation: str, line: int,
"source_file": str_path,
"source_location": f"L{line}",
"weight": weight,
- })
+ }
+ if context:
+ edge["context"] = context
+ edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
@@ -2172,7 +2249,7 @@ def walk(node, parent_impl_nid: str | None = None) -> None:
module_name = clean.split("::")[-1].strip()
if module_name:
tgt_nid = _make_id(module_name)
- add_edge(file_nid, tgt_nid, "imports_from", node.start_point[0] + 1)
+ add_edge(file_nid, tgt_nid, "imports_from", node.start_point[0] + 1, context="import")
return
for child in node.children:
@@ -2217,6 +2294,7 @@ def walk_calls(node, caller_nid: str) -> None:
"source": caller_nid,
"target": tgt_nid,
"relation": "calls",
+ "context": "call",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
@@ -2278,10 +2356,14 @@ def add_node(nid: str, label: str, line: int) -> None:
"source_file": str_path, "source_location": f"L{line}"})
def add_edge(src: str, tgt: str, relation: str, line: int,
- confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
- edges.append({"source": src, "target": tgt, "relation": relation,
- "confidence": confidence, "source_file": str_path,
- "source_location": f"L{line}", "weight": weight})
+ confidence: str = "EXTRACTED", weight: float = 1.0,
+ context: str | None = None) -> None:
+ edge = {"source": src, "target": tgt, "relation": relation,
+ "confidence": confidence, "source_file": str_path,
+ "source_location": f"L{line}", "weight": weight}
+ if context:
+ edge["context"] = context
+ edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
@@ -2441,10 +2523,14 @@ def add_node(nid: str, label: str, line: int) -> None:
"source_file": str_path, "source_location": f"L{line}"})
def add_edge(src: str, tgt: str, relation: str, line: int,
- confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
- edges.append({"source": src, "target": tgt, "relation": relation,
- "confidence": confidence, "source_file": str_path,
- "source_location": f"L{line}", "weight": weight})
+ confidence: str = "EXTRACTED", weight: float = 1.0,
+ context: str | None = None) -> None:
+ edge = {"source": src, "target": tgt, "relation": relation,
+ "confidence": confidence, "source_file": str_path,
+ "source_location": f"L{line}", "weight": weight}
+ if context:
+ edge["context"] = context
+ edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
@@ -2823,10 +2909,14 @@ def add_node(nid: str, label: str, line: int) -> None:
"source_file": str_path, "source_location": f"L{line}"})
def add_edge(src: str, tgt: str, relation: str, line: int,
- confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
- edges.append({"source": src, "target": tgt, "relation": relation,
- "confidence": confidence, "source_file": str_path,
- "source_location": f"L{line}", "weight": weight})
+ confidence: str = "EXTRACTED", weight: float = 1.0,
+ context: str | None = None) -> None:
+ edge = {"source": src, "target": tgt, "relation": relation,
+ "confidence": confidence, "source_file": str_path,
+ "source_location": f"L{line}", "weight": weight}
+ if context:
+ edge["context"] = context
+ edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
@@ -2850,7 +2940,7 @@ def walk(node, parent_nid: str | None = None) -> None:
module = raw.split("/")[-1].replace(".h", "")
if module:
tgt_nid = _make_id(module)
- add_edge(file_nid, tgt_nid, "imports", line)
+ add_edge(file_nid, tgt_nid, "imports", line, context="import")
elif child.type == "string_literal":
# recurse into string_literal to find string_content
for sub in child.children:
@@ -2859,7 +2949,7 @@ def walk(node, parent_nid: str | None = None) -> None:
module = raw.split("/")[-1].replace(".h", "")
if module:
tgt_nid = _make_id(module)
- add_edge(file_nid, tgt_nid, "imports", line)
+ add_edge(file_nid, tgt_nid, "imports", line, context="import")
return
if t == "class_interface":
@@ -2890,7 +2980,7 @@ def walk(node, parent_nid: str | None = None) -> None:
for s in sub.children:
if s.type == "type_identifier":
proto_nid = _make_id(_read(s))
- add_edge(cls_nid, proto_nid, "imports", line)
+ add_edge(cls_nid, proto_nid, "imports", line, context="import")
elif child.type == "method_declaration":
walk(child, cls_nid)
return
@@ -2982,7 +3072,7 @@ def walk_calls(n) -> None:
if pair not in seen_calls and caller_nid != candidate:
seen_calls.add(pair)
add_edge(caller_nid, candidate, "calls", body_node.start_point[0] + 1,
- confidence="EXTRACTED", weight=1.0)
+ confidence="EXTRACTED", weight=1.0, context="call")
for child in n.children:
walk_calls(child)
walk_calls(body_node)
@@ -3021,10 +3111,14 @@ def add_node(nid: str, label: str, line: int) -> None:
"source_file": str_path, "source_location": f"L{line}"})
def add_edge(src: str, tgt: str, relation: str, line: int,
- confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
- edges.append({"source": src, "target": tgt, "relation": relation,
- "confidence": confidence, "source_file": str_path,
- "source_location": f"L{line}", "weight": weight})
+ confidence: str = "EXTRACTED", weight: float = 1.0,
+ context: str | None = None) -> None:
+ edge = {"source": src, "target": tgt, "relation": relation,
+ "confidence": confidence, "source_file": str_path,
+ "source_location": f"L{line}", "weight": weight}
+ if context:
+ edge["context"] = context
+ edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
@@ -3103,7 +3197,7 @@ def walk(node, parent_module_nid: str | None = None) -> None:
module_name = _get_alias_text(arguments_node)
if module_name:
tgt_nid = _make_id(module_name)
- add_edge(file_nid, tgt_nid, "imports", line)
+ add_edge(file_nid, tgt_nid, "imports", line, context="import")
return
for child in node.children:
@@ -3156,7 +3250,8 @@ def walk_calls(node, caller_nid: str) -> None:
if pair not in seen_call_pairs:
seen_call_pairs.add(pair)
add_edge(caller_nid, tgt_nid, "calls",
- node.start_point[0] + 1, confidence="EXTRACTED", weight=1.0)
+ node.start_point[0] + 1, confidence="EXTRACTED", weight=1.0,
+ context="call")
else:
raw_calls.append({
"caller_nid": caller_nid,
@@ -3366,6 +3461,7 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict:
"source": caller,
"target": tgt,
"relation": "calls",
+ "context": "call",
"confidence": "INFERRED",
"confidence_score": 0.8,
"source_file": rc.get("source_file", ""),
diff --git a/graphify/serve.py b/graphify/serve.py
index 361dec3c0..2ab1715ef 100644
--- a/graphify/serve.py
+++ b/graphify/serve.py
@@ -57,6 +57,68 @@ def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]:
return sorted(scored, reverse=True)
+_CONTEXT_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
+ ("call", ("call", "calls", "called", "invoke", "invokes", "invoked")),
+ ("import", ("import", "imports", "imported", "module", "modules")),
+ ("field", ("field", "fields", "member", "members", "property", "properties")),
+ ("parameter_type", ("parameter", "parameters", "param", "params", "argument", "arguments")),
+ ("return_type", ("return", "returns", "returned")),
+ ("generic_arg", ("generic", "generics", "template", "templates")),
+)
+
+
+def _normalize_context_filters(filters: list[str] | None) -> list[str]:
+ if not filters:
+ return []
+ normalized: list[str] = []
+ seen: set[str] = set()
+ for value in filters:
+ key = _strip_diacritics(str(value)).strip().lower()
+ if key and key not in seen:
+ seen.add(key)
+ normalized.append(key)
+ return normalized
+
+
+def _infer_context_filters(question: str) -> list[str]:
+ lowered = {
+ _strip_diacritics(token).lower()
+ for token in question.replace("?", " ").replace(",", " ").split()
+ }
+ inferred: list[str] = []
+ for context, hints in _CONTEXT_HINTS:
+ if any(hint in lowered for hint in hints):
+ inferred.append(context)
+ return inferred
+
+
+def _resolve_context_filters(question: str, explicit_filters: list[str] | None = None) -> tuple[list[str], str | None]:
+ normalized = _normalize_context_filters(explicit_filters)
+ if normalized:
+ return normalized, "explicit"
+ inferred = _infer_context_filters(question)
+ if inferred:
+ return inferred, "heuristic"
+ return [], None
+
+
+def _filter_graph_by_context(G: nx.Graph, context_filters: list[str] | None) -> nx.Graph:
+ filters = set(_normalize_context_filters(context_filters))
+ if not filters:
+ return G
+ H = G.__class__()
+ H.add_nodes_from(G.nodes(data=True))
+ if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)):
+ for u, v, key, data in G.edges(keys=True, data=True):
+ if data.get("context") in filters:
+ H.add_edge(u, v, key=key, **data)
+ else:
+ for u, v, data in G.edges(data=True):
+ if data.get("context") in filters:
+ H.add_edge(u, v, **data)
+ return H
+
+
def _bfs(G: nx.Graph, start_nodes: list[str], depth: int) -> tuple[set[str], list[tuple]]:
visited: set[str] = set(start_nodes)
frontier = set(start_nodes)
@@ -101,7 +163,13 @@ def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_bu
if u in nodes and v in nodes:
raw = G[u][v]
d = next(iter(raw.values()), {}) if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)) else raw
- line = f"EDGE {sanitize_label(G.nodes[u].get('label', u))} --{d.get('relation', '')} [{d.get('confidence', '')}]--> {sanitize_label(G.nodes[v].get('label', v))}"
+ context = d.get("context")
+ context_suffix = f" context={context}" if context else ""
+ line = (
+ f"EDGE {sanitize_label(G.nodes[u].get('label', u))} "
+ f"--{d.get('relation', '')} [{d.get('confidence', '')}{context_suffix}]--> "
+ f"{sanitize_label(G.nodes[v].get('label', v))}"
+ )
lines.append(line)
output = "\n".join(lines)
if len(output) > char_budget:
@@ -109,6 +177,34 @@ def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_bu
return output
+def _query_graph_text(
+ G: nx.Graph,
+ question: str,
+ *,
+ mode: str = "bfs",
+ depth: int = 3,
+ token_budget: int = 2000,
+ context_filters: list[str] | None = None,
+) -> str:
+ terms = [t.lower() for t in question.split() if len(t) > 2]
+ scored = _score_nodes(G, terms)
+ start_nodes = [nid for _, nid in scored[:3]]
+ if not start_nodes:
+ return "No matching nodes found."
+ resolved_filters, filter_source = _resolve_context_filters(question, context_filters)
+ traversal_graph = _filter_graph_by_context(G, resolved_filters)
+ nodes, edges = _dfs(traversal_graph, start_nodes, depth) if mode == "dfs" else _bfs(traversal_graph, start_nodes, depth)
+ header_parts = [
+ f"Traversal: {mode.upper()} depth={depth}",
+ f"Start: {[G.nodes[n].get('label', n) for n in start_nodes]}",
+ ]
+ if resolved_filters:
+ header_parts.append(f"Context: {', '.join(resolved_filters)} ({filter_source})")
+ header_parts.append(f"{len(nodes)} nodes found")
+ header = " | ".join(header_parts) + "\n\n"
+ return header + _subgraph_to_text(traversal_graph, nodes, edges, token_budget)
+
+
def _find_node(G: nx.Graph, label: str) -> list[str]:
"""Return node IDs whose label or ID matches the search term (diacritic-insensitive)."""
term = _strip_diacritics(label).lower()
@@ -175,6 +271,11 @@ async def list_tools() -> list[types.Tool]:
"description": "bfs=broad context, dfs=trace a specific path"},
"depth": {"type": "integer", "default": 3, "description": "Traversal depth (1-6)"},
"token_budget": {"type": "integer", "default": 2000, "description": "Max output tokens"},
+ "context_filter": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Optional explicit edge-context filter, e.g. ['call', 'field']",
+ },
},
"required": ["question"],
},
@@ -239,14 +340,15 @@ def _tool_query_graph(arguments: dict) -> str:
mode = arguments.get("mode", "bfs")
depth = min(int(arguments.get("depth", 3)), 6)
budget = int(arguments.get("token_budget", 2000))
- terms = [t.lower() for t in question.split() if len(t) > 2]
- scored = _score_nodes(G, terms)
- start_nodes = [nid for _, nid in scored[:3]]
- if not start_nodes:
- return "No matching nodes found."
- nodes, edges = _dfs(G, start_nodes, depth) if mode == "dfs" else _bfs(G, start_nodes, depth)
- header = f"Traversal: {mode.upper()} depth={depth} | Start: {[G.nodes[n].get('label', n) for n in start_nodes]} | {len(nodes)} nodes found\n\n"
- return header + _subgraph_to_text(G, nodes, edges, budget)
+ context_filter = arguments.get("context_filter")
+ return _query_graph_text(
+ G,
+ question,
+ mode=mode,
+ depth=depth,
+ token_budget=budget,
+ context_filters=context_filter,
+ )
def _tool_get_node(arguments: dict) -> str:
label = arguments["label"].lower()
diff --git a/tests/test_extract.py b/tests/test_extract.py
index 3d5b9f530..8150f9843 100644
--- a/tests/test_extract.py
+++ b/tests/test_extract.py
@@ -124,6 +124,13 @@ def test_calls_edges_are_extracted():
assert edge["weight"] == 1.0
+def test_python_call_edges_have_call_context():
+ result = extract_python(FIXTURES / "sample_calls.py")
+ call_edges = [e for e in result["edges"] if e["relation"] == "calls"]
+ assert call_edges
+ assert all(e.get("context") == "call" for e in call_edges)
+
+
def test_calls_no_self_loops():
result = extract_python(FIXTURES / "sample_calls.py")
for edge in result["edges"]:
diff --git a/tests/test_languages.py b/tests/test_languages.py
index 680bb4e2d..0c3301c53 100644
--- a/tests/test_languages.py
+++ b/tests/test_languages.py
@@ -25,6 +25,22 @@ def _calls(r):
}
+def _references(r):
+ node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
+ return [
+ (
+ node_by_id.get(e["source"], e["source"]),
+ node_by_id.get(e["target"], e["target"]),
+ e,
+ )
+ for e in r["edges"] if e["relation"] == "references"
+ ]
+
+
+def _edges_with_relation(r, *relations):
+ return [e for e in r["edges"] if e["relation"] in relations]
+
+
# ── Java ──────────────────────────────────────────────────────────────────────
def test_java_no_error():
@@ -49,6 +65,13 @@ def test_java_finds_imports():
r = extract_java(FIXTURES / "sample.java")
assert "imports" in _relations(r)
+
+def test_java_import_edges_have_import_context():
+ r = extract_java(FIXTURES / "sample.java")
+ import_edges = _edges_with_relation(r, "imports", "imports_from")
+ assert import_edges
+ assert all(e.get("context") == "import" for e in import_edges)
+
def test_java_no_dangling_edges():
r = extract_java(FIXTURES / "sample.java")
node_ids = {n["id"] for n in r["nodes"]}
@@ -83,6 +106,20 @@ def test_c_calls_are_extracted():
assert e["confidence"] == "EXTRACTED"
+def test_c_import_edges_have_import_context():
+ r = extract_c(FIXTURES / "sample.c")
+ import_edges = _edges_with_relation(r, "imports", "imports_from")
+ assert import_edges
+ assert all(e.get("context") == "import" for e in import_edges)
+
+
+def test_c_call_edges_have_call_context():
+ r = extract_c(FIXTURES / "sample.c")
+ call_edges = _edges_with_relation(r, "calls")
+ assert call_edges
+ assert all(e.get("context") == "call" for e in call_edges)
+
+
# ── C++ ───────────────────────────────────────────────────────────────────────
def test_cpp_no_error():
@@ -104,6 +141,13 @@ def test_cpp_finds_includes():
assert "imports" in _relations(r)
+def test_cpp_import_edges_have_import_context():
+ r = extract_cpp(FIXTURES / "sample.cpp")
+ import_edges = _edges_with_relation(r, "imports", "imports_from")
+ assert import_edges
+ assert all(e.get("context") == "import" for e in import_edges)
+
+
# ── Ruby ─────────────────────────────────────────────────────────────────────
def test_ruby_no_error():
@@ -164,6 +208,33 @@ def test_csharp_inherits_iprocessor():
assert found, "DataProcessor should have inherits edge to IProcessor"
+def test_csharp_field_type_references_have_field_context():
+ r = extract_csharp(FIXTURES / "sample.cs")
+ refs = _references(r)
+ assert any(
+ "DataProcessor" in src and "HttpClient" in tgt and edge.get("context") == "field"
+ for src, tgt, edge in refs
+ ), "DataProcessor field declarations should reference HttpClient with field context"
+
+
+def test_csharp_call_edges_have_call_context():
+ r = extract_csharp(FIXTURES / "sample.cs")
+ node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
+ assert any(
+ "Process" in node_by_id.get(e["source"], "")
+ and "Validate" in node_by_id.get(e["target"], "")
+ and e.get("context") == "call"
+ for e in r["edges"] if e["relation"] == "calls"
+ ), "C# call edges should retain call context"
+
+
+def test_csharp_import_edges_have_import_context():
+ r = extract_csharp(FIXTURES / "sample.cs")
+ import_edges = [e for e in r["edges"] if e["relation"] == "imports"]
+ assert import_edges
+ assert all(e.get("context") == "import" for e in import_edges)
+
+
# ── Kotlin ───────────────────────────────────────────────────────────────────
def test_kotlin_no_error():
@@ -210,6 +281,20 @@ def test_scala_finds_methods():
assert any("post" in l for l in labels)
+def test_scala_import_edges_have_import_context():
+ r = extract_scala(FIXTURES / "sample.scala")
+ import_edges = _edges_with_relation(r, "imports", "imports_from")
+ assert import_edges
+ assert all(e.get("context") == "import" for e in import_edges)
+
+
+def test_scala_call_edges_have_call_context():
+ r = extract_scala(FIXTURES / "sample.scala")
+ call_edges = _edges_with_relation(r, "calls")
+ assert call_edges
+ assert all(e.get("context") == "call" for e in call_edges)
+
+
# ── PHP ───────────────────────────────────────────────────────────────────────
def test_php_no_error():
@@ -234,6 +319,20 @@ def test_php_finds_imports():
r = extract_php(FIXTURES / "sample.php")
assert "imports" in _relations(r)
+
+def test_php_import_edges_have_import_context():
+ r = extract_php(FIXTURES / "sample.php")
+ import_edges = _edges_with_relation(r, "imports", "imports_from")
+ assert import_edges
+ assert all(e.get("context") == "import" for e in import_edges)
+
+
+def test_php_call_edges_have_call_context():
+ r = extract_php(FIXTURES / "sample.php")
+ call_edges = _edges_with_relation(r, "calls")
+ assert call_edges
+ assert all(e.get("context") == "call" for e in call_edges)
+
def test_php_finds_static_property_access():
r = extract_php(FIXTURES / "sample_php_static_prop.php")
assert "uses_static_prop" in _relations(r)
@@ -319,6 +418,13 @@ def test_swift_finds_imports():
r = extract_swift(FIXTURES / "sample.swift")
assert "imports" in _relations(r)
+
+def test_swift_import_edges_have_import_context():
+ r = extract_swift(FIXTURES / "sample.swift")
+ import_edges = _edges_with_relation(r, "imports", "imports_from")
+ assert import_edges
+ assert all(e.get("context") == "import" for e in import_edges)
+
def test_swift_no_dangling_edges():
r = extract_swift(FIXTURES / "sample.swift")
node_ids = {n["id"] for n in r["nodes"]}
@@ -406,6 +512,13 @@ def test_swift_emits_calls():
assert any("process" in src and "validate" in tgt for src, tgt in calls)
+def test_swift_call_edges_have_call_context():
+ r = extract_swift(FIXTURES / "sample.swift")
+ call_edges = _edges_with_relation(r, "calls")
+ assert call_edges
+ assert all(e.get("context") == "call" for e in call_edges)
+
+
# ── Elixir ────────────────────────────────────────────────────────────────────
from graphify.extract import extract_elixir
@@ -428,12 +541,26 @@ def test_elixir_finds_imports():
import_edges = [e for e in r["edges"] if e["relation"] == "imports"]
assert len(import_edges) >= 2
+
+def test_elixir_import_edges_have_import_context():
+ r = extract_elixir(FIXTURES / "sample.ex")
+ import_edges = _edges_with_relation(r, "imports", "imports_from")
+ assert import_edges
+ assert all(e.get("context") == "import" for e in import_edges)
+
def test_elixir_finds_calls():
r = extract_elixir(FIXTURES / "sample.ex")
calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"}
labels = {n["id"]: n["label"] for n in r["nodes"]}
assert any("create" in labels.get(src, "") and "validate" in labels.get(tgt, "") for src, tgt in calls)
+
+def test_elixir_call_edges_have_call_context():
+ r = extract_elixir(FIXTURES / "sample.ex")
+ call_edges = _edges_with_relation(r, "calls")
+ assert call_edges
+ assert all(e.get("context") == "call" for e in call_edges)
+
def test_elixir_method_edges():
r = extract_elixir(FIXTURES / "sample.ex")
methods = [e for e in r["edges"] if e["relation"] == "method"]
@@ -468,6 +595,13 @@ def test_objc_finds_imports():
assert len(import_edges) >= 1
+def test_objc_import_edges_have_import_context():
+ r = extract_objc(FIXTURES / "sample.m")
+ import_edges = _edges_with_relation(r, "imports", "imports_from")
+ assert import_edges
+ assert all(e.get("context") == "import" for e in import_edges)
+
+
def test_objc_inherits_edge():
r = extract_objc(FIXTURES / "sample.m")
inherits = [e for e in r["edges"] if e["relation"] == "inherits"]
@@ -543,6 +677,13 @@ def test_julia_finds_imports():
assert len(import_edges) >= 1
+def test_julia_import_edges_have_import_context():
+ r = extract_julia(FIXTURES / "sample.jl")
+ import_edges = _edges_with_relation(r, "imports", "imports_from")
+ assert import_edges
+ assert all(e.get("context") == "import" for e in import_edges)
+
+
def test_julia_finds_inherits():
r = extract_julia(FIXTURES / "sample.jl")
inherits = [e for e in r["edges"] if e["relation"] == "inherits"]
@@ -555,6 +696,13 @@ def test_julia_finds_calls():
assert len(call_edges) >= 1
+def test_julia_call_edges_have_call_context():
+ r = extract_julia(FIXTURES / "sample.jl")
+ call_edges = _edges_with_relation(r, "calls")
+ assert call_edges
+ assert all(e.get("context") == "call" for e in call_edges)
+
+
def test_julia_no_dangling_edges():
r = extract_julia(FIXTURES / "sample.jl")
node_ids = {n["id"] for n in r["nodes"]}
diff --git a/tests/test_multilang.py b/tests/test_multilang.py
index 0a67f50b3..84aa940c4 100644
--- a/tests/test_multilang.py
+++ b/tests/test_multilang.py
@@ -24,6 +24,10 @@ def _confidences(result):
return {e["confidence"] for e in result["edges"]}
+def _edges_with_relation(result, *relations):
+ return [e for e in result["edges"] if e["relation"] in relations]
+
+
# ── TypeScript ────────────────────────────────────────────────────────────────
def test_ts_finds_class():
@@ -53,6 +57,20 @@ def test_ts_calls_are_extracted():
if e["relation"] == "calls":
assert e["confidence"] == "EXTRACTED"
+
+def test_ts_import_edges_have_import_context():
+ r = extract_js(FIXTURES / "sample.ts")
+ import_edges = _edges_with_relation(r, "imports", "imports_from")
+ assert import_edges
+ assert all(e.get("context") == "import" for e in import_edges)
+
+
+def test_ts_call_edges_have_call_context():
+ r = extract_js(FIXTURES / "sample.ts")
+ call_edges = _edges_with_relation(r, "calls")
+ assert call_edges
+ assert all(e.get("context") == "call" for e in call_edges)
+
def test_ts_no_dangling_edges():
r = extract_js(FIXTURES / "sample.ts")
node_ids = {n["id"] for n in r["nodes"]}
@@ -87,6 +105,20 @@ def test_go_has_extracted_calls():
r = extract_go(FIXTURES / "sample.go")
assert "EXTRACTED" in _confidences(r)
+
+def test_go_import_edges_have_import_context():
+ r = extract_go(FIXTURES / "sample.go")
+ import_edges = _edges_with_relation(r, "imports", "imports_from")
+ assert import_edges
+ assert all(e.get("context") == "import" for e in import_edges)
+
+
+def test_go_call_edges_have_call_context():
+ r = extract_go(FIXTURES / "sample.go")
+ call_edges = _edges_with_relation(r, "calls")
+ assert call_edges
+ assert all(e.get("context") == "call" for e in call_edges)
+
def test_go_no_dangling_edges():
r = extract_go(FIXTURES / "sample.go")
node_ids = {n["id"] for n in r["nodes"]}
@@ -123,6 +155,20 @@ def test_rust_calls_are_extracted():
if e["relation"] == "calls":
assert e["confidence"] == "EXTRACTED"
+
+def test_rust_import_edges_have_import_context():
+ r = extract_rust(FIXTURES / "sample.rs")
+ import_edges = _edges_with_relation(r, "imports", "imports_from")
+ assert import_edges
+ assert all(e.get("context") == "import" for e in import_edges)
+
+
+def test_rust_call_edges_have_call_context():
+ r = extract_rust(FIXTURES / "sample.rs")
+ call_edges = _edges_with_relation(r, "calls")
+ assert call_edges
+ assert all(e.get("context") == "call" for e in call_edges)
+
def test_rust_no_dangling_edges():
r = extract_rust(FIXTURES / "sample.rs")
node_ids = {n["id"] for n in r["nodes"]}
diff --git a/tests/test_query_cli.py b/tests/test_query_cli.py
new file mode 100644
index 000000000..39d016f86
--- /dev/null
+++ b/tests/test_query_cli.py
@@ -0,0 +1,51 @@
+"""Tests for graphify query CLI context filtering."""
+from __future__ import annotations
+
+import json
+
+import networkx as nx
+from networkx.readwrite import json_graph
+
+import graphify.__main__ as mainmod
+
+
+def _write_graph(tmp_path):
+ G = nx.Graph()
+ G.add_node("n1", label="extract", source_file="extract.py", source_location="L10", community=0)
+ G.add_node("n2", label="cluster", source_file="cluster.py", source_location="L5", community=0)
+ G.add_node("n3", label="build", source_file="build.py", source_location="L1", community=1)
+ G.add_edge("n1", "n2", relation="calls", confidence="EXTRACTED", context="call")
+ G.add_edge("n2", "n3", relation="imports", confidence="EXTRACTED", context="import")
+ graph_path = tmp_path / "graph.json"
+ graph_path.write_text(json.dumps(json_graph.node_link_data(G, edges="links")))
+ return graph_path
+
+
+def test_query_cli_explicit_context_filter(monkeypatch, tmp_path, capsys):
+ graph_path = _write_graph(tmp_path)
+ monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
+ monkeypatch.setattr(
+ mainmod.sys,
+ "argv",
+ ["graphify", "query", "extract", "--context", "call", "--graph", str(graph_path)],
+ )
+ mainmod.main()
+ out = capsys.readouterr().out
+ assert "Context: call (explicit)" in out
+ assert "cluster" in out
+ assert "build" not in out
+
+
+def test_query_cli_heuristic_context_filter(monkeypatch, tmp_path, capsys):
+ graph_path = _write_graph(tmp_path)
+ monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
+ monkeypatch.setattr(
+ mainmod.sys,
+ "argv",
+ ["graphify", "query", "who calls extract", "--graph", str(graph_path)],
+ )
+ mainmod.main()
+ out = capsys.readouterr().out
+ assert "Context: call (heuristic)" in out
+ assert "cluster" in out
+ assert "build" not in out
diff --git a/tests/test_serve.py b/tests/test_serve.py
index 6457ac501..49d0a200a 100644
--- a/tests/test_serve.py
+++ b/tests/test_serve.py
@@ -9,6 +9,10 @@
_score_nodes,
_bfs,
_dfs,
+ _filter_graph_by_context,
+ _infer_context_filters,
+ _query_graph_text,
+ _resolve_context_filters,
_subgraph_to_text,
_load_graph,
)
@@ -21,8 +25,8 @@ def _make_graph() -> nx.Graph:
G.add_node("n3", label="build", source_file="build.py", source_location="L1", community=1)
G.add_node("n4", label="report", source_file="report.py", source_location="L1", community=1)
G.add_node("n5", label="isolated", source_file="other.py", source_location="L1", community=2)
- G.add_edge("n1", "n2", relation="calls", confidence="INFERRED")
- G.add_edge("n2", "n3", relation="imports", confidence="EXTRACTED")
+ G.add_edge("n1", "n2", relation="calls", confidence="INFERRED", context="call")
+ G.add_edge("n2", "n3", relation="imports", confidence="EXTRACTED", context="import")
G.add_edge("n3", "n4", relation="uses", confidence="EXTRACTED")
return G
@@ -73,6 +77,16 @@ def test_score_nodes_source_file_partial():
assert "n2" in nids
+def test_infer_context_filters_for_calls_question():
+ assert _infer_context_filters("who calls extract") == ["call"]
+
+
+def test_resolve_context_filters_explicit_overrides_heuristic():
+ filters, source = _resolve_context_filters("who calls extract", ["field"])
+ assert filters == ["field"]
+ assert source == "explicit"
+
+
# --- _bfs ---
def test_bfs_depth_1():
@@ -99,6 +113,15 @@ def test_bfs_returns_edges():
assert any(u == "n1" or v == "n1" for u, v in edges)
+def test_filter_graph_by_context_limits_traversal():
+ G = _make_graph()
+ filtered = _filter_graph_by_context(G, ["call"])
+ visited, edges = _bfs(filtered, ["n1"], depth=2)
+ assert "n2" in visited
+ assert "n3" not in visited
+ assert edges == [("n1", "n2")]
+
+
# --- _dfs ---
def test_dfs_depth_1():
@@ -135,6 +158,28 @@ def test_subgraph_to_text_edge_included():
assert "calls" in text
+def test_subgraph_to_text_includes_edge_context():
+ G = _make_graph()
+ text = _subgraph_to_text(G, {"n1", "n2"}, [("n1", "n2")])
+ assert "context=call" in text
+
+
+def test_query_graph_text_explicit_context_filter_changes_traversal():
+ G = _make_graph()
+ text = _query_graph_text(G, "extract", mode="bfs", depth=2, token_budget=2000, context_filters=["call"])
+ assert "Context: call (explicit)" in text
+ assert "cluster" in text
+ assert "build" not in text
+
+
+def test_query_graph_text_heuristic_context_filter_changes_traversal():
+ G = _make_graph()
+ text = _query_graph_text(G, "who calls extract", mode="bfs", depth=2, token_budget=2000)
+ assert "Context: call (heuristic)" in text
+ assert "cluster" in text
+ assert "build" not in text
+
+
# --- _load_graph ---
def test_load_graph_roundtrip(tmp_path):
From 770d7f54c40d7301a0166a6b7782cb03827897e5 Mon Sep 17 00:00:00 2001
From: Safi
Date: Sat, 25 Apr 2026 15:56:59 +0100
Subject: [PATCH 06/89] Add The Memory Layer book badge to README
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index 21ca8179a..22601278d 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@
+
From c3ba79f5aae7f41488646df46fcbf82232afb94b Mon Sep 17 00:00:00 2001
From: dsremo
Date: Sun, 26 Apr 2026 10:15:35 +0530
Subject: [PATCH 07/89] =?UTF-8?q?feat(tree):=20graphify=20tree=20=E2=80=94?=
=?UTF-8?q?=20D3=20v7=20collapsible-tree=20HTML=20emitter?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a new `graphify tree` subcommand that emits a self-contained D3
v7 collapsible-tree HTML view of an existing graph.json.
Why
---
The existing `graph.html` (force-directed) is great for finding hubs
and unexpected connections. But for code review and onboarding, a
hierarchical tree-of-modules view is much faster: you can collapse
everything and expand only the package you care about, the depth-
based colour palette gives instant orientation, and the layout
mirrors the on-disk structure.
UX choices include expand-all / collapse-all / reset-view buttons,
multi-line `wrapText` labels with separately-coloured name and count,
a depth-based palette, click-to-toggle subtrees, and a hover-inspector
that surfaces the top-K outbound edges per symbol.
Implementation
--------------
- `graphify/tree_html.py` (575 LOC, single file, no new runtime
dependencies). D3 v7 is loaded from cdn.jsdelivr.net at view time.
- Hierarchy is built from `source_file` longest-common-prefix;
symbols are grouped by containing module so the tree mirrors the
on-disk layout exactly.
- Inspector pre-computes top-K outbound edges per symbol so the page
works fully offline once loaded.
- `__main__.py` adds the subcommand + help text after the
`check-update` block.
Configuration
-------------
- `--graph PATH` path to graph.json (default: graphify-out/graph.json)
- `--output HTML` output path (default: graphify-out/GRAPH_TREE.html)
- `--root PATH` filesystem root (default: LCP of source_files)
- `--max-children N` cap visible children per node (default: 200)
- `--top-k-edges N` per-symbol outbound edges in inspector (default: 12)
- `--label NAME` project label shown in the page header
Tested locally on a 17 641-node graph — emits a 4.9 MB HTML file
that renders smoothly in Firefox / Chromium.
---
CHANGELOG.md | 15 ++
graphify/__main__.py | 63 +++++
graphify/tree_html.py | 576 ++++++++++++++++++++++++++++++++++++++++++
3 files changed, 654 insertions(+)
create mode 100644 graphify/tree_html.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 86dc8f127..ac05a152f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,21 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
+## Unreleased — `graphify tree` subcommand
+
+- **New:** `graphify tree` — emits a self-contained D3 v7 collapsible-tree HTML
+ view of `graph.json`. Expand-all / collapse-all / reset-view buttons;
+ multi-line `wrapText` labels with separately-coloured name + count;
+ depth-based colour palette; click-to-toggle subtree; hover inspector
+ showing top-K outbound edges per symbol.
+- Hierarchy is built from `source_file` longest-common-prefix; symbols are
+ grouped by their containing module so the tree mirrors the on-disk layout.
+- Configuration: `--graph PATH`, `--output HTML`, `--root PATH`,
+ `--max-children N` (default 200), `--top-k-edges N` (default 12),
+ `--label NAME`.
+- Implementation: `graphify/tree_html.py` (575 LOC, no external runtime
+ dependencies — D3 v7 is loaded from cdn.jsdelivr.net).
+
## 0.4.23 (2026-04-18)
- Fix: stale skill version warning persists after running `graphify install` when multiple platforms were previously installed — `graphify install` now refreshes `.graphify_version` in all other known skill directories so the warning clears across the board (#178)
diff --git a/graphify/__main__.py b/graphify/__main__.py
index be14274f8..37fe49156 100644
--- a/graphify/__main__.py
+++ b/graphify/__main__.py
@@ -1004,6 +1004,13 @@ def main() -> None:
print(" --nodes N1 N2 ... source node labels cited in the answer")
print(" --memory-dir DIR memory directory (default: graphify-out/memory)")
print(" check-update check needs_update flag and notify if semantic re-extraction is pending (cron-safe)")
+ print(" tree emit a D3 v7 collapsible-tree HTML for graph.json")
+ print(" --graph PATH path to graph.json (default graphify-out/graph.json)")
+ print(" --output HTML output path (default graphify-out/GRAPH_TREE.html)")
+ print(" --root PATH filesystem root for the hierarchy")
+ print(" --max-children N cap children per node (default 200)")
+ print(" --top-k-edges N per-symbol outbound edges in inspector (default 12)")
+ print(" --label NAME project label in header")
print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach")
print(" hook install install post-commit/post-checkout git hooks (all platforms)")
print(" hook uninstall remove git hooks")
@@ -1422,6 +1429,62 @@ def main() -> None:
from graphify.watch import check_update
check_update(Path(sys.argv[2]).resolve())
sys.exit(0)
+ elif cmd == "tree":
+ # Emit a D3 v7 collapsible-tree HTML view of graph.json:
+ # expand-all / collapse-all / reset-view buttons, multi-line
+ # wrapText labels with separately-coloured name + count,
+ # depth-based palette, click-to-toggle subtree, hover inspector
+ # showing top-K outbound edges per symbol.
+ from typing import Optional as _Opt
+ from graphify.tree_html import write_tree_html, DEFAULT_MAX_CHILDREN
+ graph_path = Path("graphify-out/graph.json")
+ output_path: "_Opt[Path]" = None
+ root: "_Opt[str]" = None
+ max_children = DEFAULT_MAX_CHILDREN
+ top_k_edges = 0
+ project_label: "_Opt[str]" = None
+ args = sys.argv[2:]
+ i_arg = 0
+ while i_arg < len(args):
+ a = args[i_arg]
+ if a == "--graph" and i_arg + 1 < len(args):
+ graph_path = Path(args[i_arg + 1]); i_arg += 2
+ elif a == "--output" and i_arg + 1 < len(args):
+ output_path = Path(args[i_arg + 1]); i_arg += 2
+ elif a == "--root" and i_arg + 1 < len(args):
+ root = args[i_arg + 1]; i_arg += 2
+ elif a == "--max-children" and i_arg + 1 < len(args):
+ max_children = int(args[i_arg + 1]); i_arg += 2
+ elif a == "--top-k-edges" and i_arg + 1 < len(args):
+ top_k_edges = int(args[i_arg + 1]); i_arg += 2
+ elif a == "--label" and i_arg + 1 < len(args):
+ project_label = args[i_arg + 1]; i_arg += 2
+ elif a in ("-h", "--help"):
+ print("Usage: graphify tree [--graph PATH] [--output HTML]")
+ print(" --graph PATH path to graph.json (default graphify-out/graph.json)")
+ print(" --output HTML output path (default graphify-out/GRAPH_TREE.html)")
+ print(" --root PATH filesystem root (default: longest common dir of all source_files)")
+ print(" --max-children N cap visible children per node (default 200)")
+ print(" --top-k-edges N pre-compute top-K outbound edges per symbol (default 12)")
+ print(" --label NAME project label shown in the page header")
+ return
+ else:
+ i_arg += 1
+ if not graph_path.is_file():
+ print(f"error: graph.json not found at {graph_path}", file=sys.stderr)
+ sys.exit(1)
+ if output_path is None:
+ output_path = graph_path.parent / "GRAPH_TREE.html"
+ out = write_tree_html(
+ graph_path=graph_path, output_path=output_path,
+ root=root, max_children=max_children,
+ top_k_edges=top_k_edges, project_label=project_label,
+ )
+ size_kb = out.stat().st_size / 1024
+ print(f"wrote {out} ({size_kb:.1f} KB)")
+ print(f"open with: xdg-open {out} (or file://{out.resolve()})")
+ sys.exit(0)
+
elif cmd == "merge-graphs":
# graphify merge-graphs graph1.json graph2.json ... --out merged.json
args = sys.argv[2:]
diff --git a/graphify/tree_html.py b/graphify/tree_html.py
new file mode 100644
index 000000000..00cdfcb64
--- /dev/null
+++ b/graphify/tree_html.py
@@ -0,0 +1,576 @@
+"""tree_html — emit a D3 v7 collapsible-tree HTML view of a graph.
+
+A self-contained printable / browseable tree-of-modules view
+intended to complement the existing force-directed ``graph.html``.
+Key visual elements:
+
+ * Expand-all / collapse-all / reset-view buttons.
+ * Multi-line label wrapping (``wrapText``) with separately-coloured
+ name and descendant-count.
+ * Depth-based colour palette (top-level directories get distinct
+ accent colours; deeper levels follow a level-specific palette).
+ * Click-to-toggle subtree.
+
+Tree-data shape:
+
+ {
+ "name": "",
+ "total_count": ,
+ "children": [ { "name", "total_count", "children": [...] }, ... ]
+ }
+
+CLI: ``graphify tree [--graph PATH] [--output HTML] [--root PATH]
+[--max-children N] [--label NAME]``.
+
+Implementation notes:
+ - ``total_count`` is the descendant leaf count, so collapsed nodes
+ can show ``(Total Count: 95)`` without needing the children loaded.
+ - ``--max-children`` (default 200) caps how many children render
+ under any one node; a synthetic ``(+N more)`` leaf appears when the
+ cap fires so very wide directories stay usable.
+ - The first-level palette is auto-populated from the live top-level
+ directories so each gets a stable accent colour.
+"""
+
+from __future__ import annotations
+
+import json
+from collections import defaultdict
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+DEFAULT_MAX_CHILDREN = 200
+
+
+# ── Tree builder (filesystem hierarchy → JSON) ──────────────────
+
+
+def _common_root(paths: List[str]) -> str:
+ if not paths:
+ return ""
+ parts = [Path(p).parts for p in paths if p]
+ if not parts:
+ return ""
+ common = parts[0]
+ for p in parts[1:]:
+ i = 0
+ while i < len(common) and i < len(p) and common[i] == p[i]:
+ i += 1
+ common = common[:i]
+ return str(Path(*common)) if common else ""
+
+
+def _make_truncation_leaf(extra: int) -> Dict[str, Any]:
+ return {"name": f"(+{extra} more)", "total_count": extra, "children": []}
+
+
+def build_tree(
+ graph: Dict[str, Any],
+ *,
+ root: Optional[str] = None,
+ max_children: int = DEFAULT_MAX_CHILDREN,
+ project_label: Optional[str] = None,
+) -> Dict[str, Any]:
+ """Build a ``{name, total_count, children}`` hierarchy.
+
+ Each leaf is either a code symbol (class / top-level function) or
+ a synthetic "(+N more)" placeholder for truncated wide directories.
+ Each interior node carries ``total_count = sum of leaf counts``.
+ """
+ nodes: List[Dict[str, Any]] = list(graph.get("nodes", []))
+ file_nodes = [n for n in nodes if n.get("source_file")]
+ if not file_nodes:
+ return {"name": "(empty graph)", "total_count": 0, "children": []}
+
+ if root is None:
+ root = _common_root([n["source_file"] for n in file_nodes])
+ root_path = Path(root)
+
+ by_file: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
+ for n in file_nodes:
+ by_file[n["source_file"]].append(n)
+
+ # Build dir tree.
+ dir_index: Dict[str, Dict[str, Any]] = {}
+ label_root = project_label or root_path.name or root or "/"
+ root_node: Dict[str, Any] = {
+ "name": label_root, "total_count": 0, "children": [],
+ }
+ dir_index[str(root_path)] = root_node
+
+ def _ensure_dir(abs_path: Path) -> Dict[str, Any]:
+ key = str(abs_path)
+ if key in dir_index:
+ return dir_index[key]
+ if abs_path == abs_path.parent:
+ return root_node
+ parent = (_ensure_dir(abs_path.parent)
+ if abs_path.parent != abs_path else root_node)
+ node = {"name": abs_path.name, "total_count": 0, "children": []}
+ dir_index[key] = node
+ parent["children"].append(node)
+ return node
+
+ for src_file, syms in sorted(by_file.items()):
+ src_path = Path(src_file)
+ try:
+ rel = src_path.relative_to(root_path)
+ parent_path = (root_path / rel).parent
+ except ValueError:
+ parent_path = root_path
+ parent_dir = _ensure_dir(parent_path)
+
+ # File node — children are the symbols.
+ sym_children: List[Dict[str, Any]] = []
+ for n in syms:
+ label = n.get("label", n.get("id", "?"))
+ # Skip the redundant file-name node graphify emits.
+ if label == src_path.name and n.get("file_type") == "code":
+ continue
+ sym_children.append({
+ "name": label,
+ "total_count": 1,
+ "children": [],
+ })
+ # Sort: code symbols first by name, then anything else.
+ sym_children.sort(key=lambda c: (
+ c["name"].startswith("_"),
+ c["name"].lower(),
+ ))
+ if len(sym_children) > max_children:
+ extra = len(sym_children) - max_children
+ sym_children = sym_children[:max_children] + [
+ _make_truncation_leaf(extra),
+ ]
+ file_node = {
+ "name": src_path.name,
+ "total_count": len(sym_children) or 1,
+ "children": sym_children,
+ }
+ parent_dir["children"].append(file_node)
+
+ # Sort each dir's children + propagate total_count up.
+ def _finalise(d: Dict[str, Any]) -> int:
+ kids = d.get("children") or []
+ kids.sort(key=lambda c: (
+ 0 if (c.get("children") and len(c["children"]) > 0) else 1,
+ c["name"].lower(),
+ ))
+ if not kids:
+ return d.get("total_count") or 1
+ n = 0
+ for c in kids:
+ n += _finalise(c)
+ d["total_count"] = n or 1
+ return d["total_count"]
+
+ _finalise(root_node)
+ return root_node
+
+
+# ── HTML emitter (single-data-blob substitution) ──────────────────
+
+
+# We emit a Python f-string with literal CSS/JS braces escaped as {{ }}.
+_HTML_TEMPLATE = r"""
+
+
+
+ {title}
+
+
+
+ {header}
+
+ Expand All
+ Collapse All
+ Reset View
+
+
+
+
+
+
+
+
+
+"""
+
+
+def emit_html(
+ tree: Dict[str, Any],
+ *,
+ title: str,
+ header: str,
+ svg_width: int = 6000,
+ svg_height: int = 8000,
+) -> str:
+ return _HTML_TEMPLATE.format(
+ title=title,
+ header=header,
+ svg_width=svg_width,
+ svg_height=svg_height,
+ data_json=json.dumps(tree, ensure_ascii=False, separators=(",", ":")),
+ )
+
+
+def write_tree_html(
+ graph_path: Path,
+ output_path: Path,
+ *,
+ root: Optional[str] = None,
+ max_children: int = DEFAULT_MAX_CHILDREN,
+ project_label: Optional[str] = None,
+ # kept for CLI compatibility with the older signature; ignored now
+ top_k_edges: int = 0,
+) -> Path:
+ graph = json.loads(graph_path.read_text(encoding="utf-8"))
+ tree = build_tree(graph, root=root, max_children=max_children,
+ project_label=project_label)
+ title = f"{tree['name']} — graphify tree viewer"
+ header = f"{tree['name']} — Knowledge Graph"
+ html = emit_html(tree, title=title, header=header)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(html, encoding="utf-8")
+ return output_path
From a52350940a1d1aae8a0b723a3d275005ceefadc6 Mon Sep 17 00:00:00 2001
From: dsremo
Date: Mon, 27 Apr 2026 11:55:21 +0530
Subject: [PATCH 08/89] fix(tree): escape title, header, and JSON blob in
emit_html
html.escape() the values that land in and , and replace
with <\/ in the JSON embedded inside sequences so embedded JSON cannot break out of the
+ #
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
// quick.install
+
+ pip install graphifyy && graphify .
+
+
+
✓ copied
+
pypi v2.1.0 · MIT · 400k+ installs
+
+
+
+
+
+
+
+
+
+
+
+
+ ∑
+ ∀x∃y
+ ∇²ψ
+ λ→∞
+ Ω
+ P(H|E)
+ ∂f/∂x
+
+ Θ
+ Ξ
+ Χ
+ Η
+
+
+
+
+
+
+
+
1,600+ QUEUED
+
+
+ persistent memory engine
+
+
+
+ digital twin of your knowledge
+
+
+
+
+ Any input.
+ One graph.
+ Complete recall.
+
+
+
+ The on-device knowledge graph engine for codebases and enterprise corpora. Every file, meeting, paper, and browser tab becomes a traversable node. Feed it once — it grows on every change. No rebuilds. No cloud required.
+
+
+
+
+
+ ▸ 38k+ github.stars
+ ·
+ ▸ 400k+ downloads
+ ·
+ ▸ on-device only
+
+
+
+
+ [ i_build_software ]
+ [ i_run_a_business ]
+ [ i_do_research ]
+
+
+
+
+
// graphify.query
+
+ $
+ graphify query
+ _
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ GRAPHIFY
+
+
+
+
+
+
+
+ auth.py
+
+
+
+
+
+ pipeline.go
+
+
+
+
+
+ router.ts
+
+
+
+
+
+
+ RFC-14.pdf
+
+
+
+
+
+ Q3-board
+
+
+
+
+
+ spec.md
+
+
+
+
+
+
+ auth-pattern
+
+
+
+
+
+ perf-issue
+
+
+
+
+
+
+ browser
+
+
+
+
+
+ slack
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0 // github.stars
+
+ 0 // total.downloads
+
+ ∞
+ // incremental.growth
+
+
+
+ 0 // languages.parsed
+
0 // waitlist.queue
+
+
+
+
+
+
+
+
+
+
+
+
// ingest.surface
+
20 languages.Every format.
+
AST-level extraction across the full polyglot stack. Docs, papers, images, and meetings. One corpus, one graph.
+
+ Python
+ TypeScript
+ Go
+ Rust
+ Java
+ C/C++
+ Ruby
+ Swift
+ Kotlin
+ PDF
+ Markdown
+ Images
+ + 8 more
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
// origin.mnemosyne
+
+ AI tools forget.
+ Graphify decodes everything.
+
+
+ Turing's Bombe found patterns in noise and broke Enigma. Graphify does the same: every document, call, commit, and browser tab decoded into a persistent, traversable knowledge graph. On your machine. No cloud. No forgetting.
+
+
+
+
+
+
+
+
+
+
+
+
// incremental.intelligence
+
Build once.Grow forever.
+
Unlike RAG pipelines that re-embed everything on every change, Graphify maintains a living graph. When a file changes, only affected nodes and edges update. The rest of your corpus stays intact, even at millions of files.
+
+
+
+
+
// day.one
+
Initial corpus build
+
Point Graphify at any directory. AST extraction, semantic analysis, Leiden clustering. Your entire codebase or document corpus becomes a traversable graph in one pass.
+
+
+
+
+
+
+
+
+
+
+
+ 5 nodes · 4 edges
+
+
+
+
+
// week.one
+
Incremental updates
+
A file changes. Graphify detects it, re-extracts only the affected subgraph, and patches the live graph. No full rebuild. The rest of your knowledge stays connected.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +2 nodes patched in
+
+
+
+
+
// month.one
+
Enterprise-scale corpus
+
After weeks of continuous growth, your graph spans millions of edges across codebases, documents, and meetings. Query traversal is instant. Nothing was ever rebuilt from scratch.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ enterprise corpus
+
+
+
+
+
+
+
+
+
+
// others: full rebuild on change
+
+
+ ▸
+ re-embed 500k documents... 4h 12m
+
+
+ ▸
+ rebuild vector index... 2h 8m
+
+
+ ▸
+ re-cluster communities... stale context
+
+
+
+
+
// graphify: incremental patch
+
+
+ ✓
+ detect changed files... 3 files
+
+
+ ✓
+ patch affected nodes only... 0.8s
+
+
+ ✓
+ graph updated. 498,752 nodes intact.
+
+
+
+
+
+
+
+
+
+
// graphify watch . — live file watcher
+
no LLM · AST only · free
+
+
+
[graphify watch] Watching ./src — press Ctrl+C to stop
+
[graphify watch] Debounce: 3.0s
+
[graphify watch] 2 file(s) changed
+
[graphify watch] auth.py · pipeline.go
+
[graphify watch] Rebuilt: 1,847 nodes, 4,203 edges
+
[graphify watch] graph.json + GRAPH_REPORT.md updated
+
[graphify watch] Watching ./src — Ctrl+C to stop
+
+
+
+ watching_
+
+
+
+
+
+
+
+
+
+
// deployment.modes
+
Open source to enterprise-grade
+
+
+ [ local / air-gapped ]
+ [ managed cloud ]
+
+
+
+
+
+ your machine
+
+
+ graphify
+
+
+ cloud ✕
+
+
+
+ team A
+
+
+ graphify cloud
+
+
+ team B
+
+
+
+
+
+
+
+
+
+
Open Source
+
MIT license, free forever
+
+
+
+ $ pip install graphifyy && graphify .
+
+
+
AST extraction, 20 languages
Python, JS, Go, Rust, Java and more
+
Docs, papers & images
Markdown, PDF, HTML, MDX, vision
+
Interactive graph visualization
HTML export, Leiden clustering, god-nodes
+
38k+ stars on GitHub · 400k+ downloads
Active community & Claude Code skill
+
+
+
+
+
+
+
+
+
Enterprise
+
join waitlist for access
+
+
+
+ $ graphify enterprise --scale=unlimited --local-or-cloud
+
+
+
Million-file corpus support
Incremental graph, no rebuilds, no limits
+
Local or cloud deployment
Air-gapped on-prem or managed cloud, your choice
+
Team knowledge graph
Shared corpus, role-based access, merge strategies
+
SSO, audit logs & SLA
SOC 2 ready, 99.9% uptime, dedicated support
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
// process.graph
+
+
+
+
+
+
+
Three steps to your knowledge graph
+
+
+
+
01
+
Feed it everything
+
Code repos, PDFs, Markdown, research papers, images, meeting transcripts. Every artifact of your working life, continuously captured.
+
+
+
02
+
Graph is decoded
+
Like Turing's Bombe finding patterns in cipher text: every entity becomes a node, every relationship an edge. Leiden clustering surfaces hidden communities.
+
+
+
03
+
Query anything
+
"What connects this meeting to that codebase?" Answers in seconds, with sources, traversal paths, and confidence scores.
+
+
+
+
+
+
+
+
+
+
// live.graph_diff
+
Paste code.Watch the graph mutate.
+
Type or edit a function below — Graphify extracts nodes and edges in real time.
+
+
+
+
+
// extracted.graph
+
+
0 nodes · 0 edges
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
// capability.map
+
+
+
+
+
+
+
Everything you touch,connected.
+
Not just meetings. Not just code. Your entire working context, captured, structured, and queryable.
+
+
+
+
+
+
+
+
Browser history
+
Chrome, Safari, Firefox. Every page becomes a node. Search patterns reveal what you were thinking and when.
+
+
+
+
+
Meetings
+
Automatic transcription and extraction. Decisions, action items, concepts, all connected to your wider context.
+
+
+
+
+
Code & AST
+
AST-level extraction across 20 languages. Functions, classes, call graphs. Your codebase becomes part of the graph.
+
+
+
+
+
Docs & papers
+
PDFs, Markdown, HTML, MDX. Papers you have read, specs you have written. All in the graph..
+
+
+
+
+
Images & diagrams
+
Whiteboards, screenshots, architecture diagrams. Vision extraction connects images regardless of language.
+
+
+
+
+
Surprising connections
+
Leiden clustering surfaces communities and god-nodes you didn't know existed, across months of work.
+
// hover to detect clusters
+
+
+
+
+
+
+
+
+
+
+
+
+
// leiden.god_nodes
+
The nodes that hold everything together.
+
Graphify surfaces your highest betweenness-centrality nodes — the ones that connect the most communities. If one breaks, here is the blast radius.
+
// fastapi OSS repo · 3,241 nodes · 8,904 edges
+
+
+
+
+
01
+
routing/router.py
+
+
812 files
+
+
+
02
+
dependencies/__init__.py
+
+
654 files
+
+
+
03
+
types/params.py
+
+
511 files
+
+
+
04
+
exceptions.py
+
+
369 files
+
+
+
05
+
middleware/base.py
+
+
260 files
+
+
+
// blast radius = files that import this node, directly or transitively
+
+
+
+
+
+
+
+
+
+
// onboarding.delta
+
New engineer. Day one.Without vs with Graphify.
+
+
+
+
// without.graphify
+
3 weeks
+
Reading docs, pinging colleagues, grepping code, still missing context.
+
+
✕ "why was this written this way?"
+
✕ "who owns this module?"
+
✕ "what breaks if I change this?"
+
+
+
+
// with.graphify + /graphify in Claude Code
+
4 min
+
Run /graphify in Claude Code. Graph built. Ask anything.
+
+
✓ full codebase graph in one pass
+
✓ query decisions, not just code
+
✓ god-nodes show blast radius instantly
+
+
+
+
+
+
+
+
+
+
+
// deployment.field
+
Built for everyonewho works with knowledge
+
If your work involves reading, writing, deciding, or advising. Your context belongs to you..
+
+
+
+
+
+
+
+ business
+ creative
+ client-facing
+ legal
+ health
+ technical
+ research
+ defence
+ personal
+
+
+
+
+
+
+
Business Leadership
founders, executives, board members
+
Your decisions span hundreds of conversations, reports, and board calls. By the time a strategy is revisited, the context that shaped it has scattered across emails, Notion pages, and faded memories. Graphify connects all of it into a living graph so you always know what led to what, and why.
+
Understand which meeting influenced a pricing change months later
Walk into board reviews with full context, not a search session
Connect team commitments across scattered documents and calls
+
+
+
// query.execute
+
"What discussions led to the Series B pricing decision?"
+
+
investor_call (Feb 12) → flagged $18/seat as SMB ceiling
+
sales_deck → revised to $15 four days later
+
browser.history → competitor pricing tab, 11× that week
+
cfo_email → "hold at 15 until pipeline clears"
+
+
▸ trace_path (click to expand)
+
+
+
+
+
+
+
+
+
+
+
+
+ call
+ deck
+ browser
+ email
+ decision
+ hops:4
+
+
+
+
+
+
+
+
+
Creative Professionals
marketers, designers, writers, content leads
+
Good creative work rarely happens in a vacuum. A brief connects to a reference deck, a Figma comment connects to a client email, a rejected concept quietly reappears in the final version. Graphify gives you the thread between ideas you didn't know were related.
+
Trace which brief led to which concept iteration
Find the reference image that inspired a campaign direction
Connect client feedback across three channels into one view
+
+
+
// query.execute
+
"What client feedback shaped the brand refresh direction?"
+
+
kickoff_call → "feels too corporate, needs warmth"
+
figma_comments → 3 flagging previous palette as cold
+
moodboard → warm beige & terracotta references saved
+
final_deck → approved, palette shifted 3 days after
+
+
+
+
+
+
+
+
Client-facing Professionals
sales, consultants, account managers
+
Every client relationship lives across dozens of calls, emails, proposals, and follow-ups. Graphify maps every touchpoint so you always know where things stand, before you open the meeting.
+
Recall every promise made across a multi-month deal
Surface objections raised on past calls before the next one
+
+
+
// query.execute
+
"What did Acme flag as blockers in Q4?"
+
+
oct3_call → procurement needs SSO before sign-off
+
nov_email → legal reviewing data residency clause
+
sso_shipped Dec 1, not yet mentioned in follow-up
+
+
+
+
+
+
+
+
Legal & Professional Services
solicitors, barristers, compliance, auditors
+
Cases span years of correspondence, precedents, filings, and instructions. Graphify builds the graph across all of it, privately on your machine.
+
Trace a contract clause back to the email that prompted it
Surface related precedents across matters without manual search
+
+
+
// query.execute
+
"What led to the indemnity cap revision in the SPA?"
+
+
client_email (Mar 4) → "cap feels too low given IP exposure"
+
counsel_note → references Henderson v. Argyle
+
spa_draft_v2 (Mar 11) → cap increased 2×
+
+
+
+
+
+
+
+
Health Professionals
doctors, therapists, clinicians, researchers
+
Graphify builds the patient picture locally, no data uploaded, no cloud dependency, so you can surface patterns without compromising confidentiality.
+
Connect symptoms across multiple appointments over time
Stays fully on-device. No data leaves your machine.
+
+
+
// query.execute
+
"When did fatigue first appear in this patient's notes?"
+
+
session_aug → "low energy, attributed to stress"
+
blood_panel_sep → ferritin borderline, not flagged
+
research_tab → iron deficiency symptoms, same week
+
+
+
+
+
+
+
+
Technical & Product
developers, engineers, product managers
+
Graphify connects your code, docs, meetings, and browser research so you can trace architectural choices back to the conversation that shaped them, without asking someone who might have left.
+
Understand why a library was chosen six months ago
Onboard to a codebase by querying its decisions, not just its code
+
+
+
// query.execute
+
"Why did we move from REST to gRPC on the data pipeline?"
+
+
RFC-14 → latency benchmarks: 3× improvement with gRPC
+
design_meeting → REST payload size flagged as bottleneck
+
pipeline.go → rewritten, commit refs RFC-14
+
+
▸ trace_path (click to expand)
+
+
+
+
+
+
+
+
+
+
+
+
+ meeting
+ RFC-14
+ bench
+ commit
+ decision
+ hops:3
+
+
+
+
+
+
+
+
+
Knowledge & Research
researchers, academics, analysts, students
+
Graphify builds a living map of your reading: papers, notes, articles, annotations, so you can see how your thinking connects, and what questions the graph is uniquely positioned to answer.
+
Find conceptual links across papers read months apart
Identify gaps: what you have not yet read but should
+
+
+
// query.execute
+
"What connects attention mechanisms to my notes on memory?"
+
+
vaswani_et_al → attention as soft memory retrieval
+
your_note_nov → "query-key-value mirrors associative recall"
+
hopfield_paper → saved same week, not yet linked
+
+
+
+
+
+
+
+
Defence & Intelligence
analysts, officers, policy advisors, planners
+
Air-gapped by design. Graphify processes everything locally: no cloud, no telemetry, no external API calls. Build structured intelligence graphs from documents and signals without anything leaving the machine.
+
Build entity graphs without network exposure
Fully offline. Runs on your hardware, stays on your hardware.
+
+
+
// query.execute
+
"What sources connect to the logistics corridor assessment?"
+
+
briefing_oct → corridor capacity flagged as constrained
+
imagery_report → same route, different framing
+
osint_note → infrastructure works started Nov 14
+
+
+
+
+
+
+
+
Personal & Household
individuals, household managers, caretakers
+
Medical appointments, financial decisions, school correspondence, care plans, scattered across apps. Graphify connects all of it into one private, searchable graph on your device.
+
Track medical history, prescriptions, and appointment notes
Connect financial decisions, warranties, and contracts over time
+
+
+
// query.execute
+
"What was said at the last GP appointment?"
+
+
notes_sep2 → dose adjusted, review in 6 weeks
+
pharmacy_email → repeat prescription issued Oct 4
+
browser → side effects page visited twice that week
+
+
+
+
+
+
+
+
+
+
+
+
+
// benchmark.compare
+
Recall vs relationships
+
Other tools find things. Graphify understands how they connect.
+
+
+
+
+
+
+
+
// capability
+
others
+
+ graphify
+
+
+
+
+
MEMORY
+
+
find_past_information() retrieve any past work by query
+
✓
+
✓
+
+
+
meeting_transcription() extract decisions and action items
+
✓
+
✓
+
+
+
+
CONTEXT
+
+
browser_history_aware() browser tabs become graph nodes
+
✕
+
✓
+
+
+
incremental_update() patch graph on change, no rebuild
+
✕
+
✓
+
+
+
+
CODE
+
+
parse_ast_20_langs() deep AST extraction across 20 languages
+
✕
+
✓
+
+
+
+
GRAPH INTELLIGENCE
+
+
relationship_graph() map how every item connects to every other
+
✕
+
✓
+
+
+
leiden_clustering() surface hidden communities and god-nodes
+
✕
+
✓
+
+
+
+
PRIVACY
+
+
runs_on_device() zero data leaves your machine
+
✕
+
✓
+
+
+
zero_cloud_training() your data never trains any model
+
✕
+
✓
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
// aegis.protocol
+
Your graph lives onyour machine.
+
We do not see your data. We cannot. It never leaves your device.
+
+
+
+
+
+
+
On-device only
Runs entirely on your hardware. Nothing leaves.
+
+
+
+
No cloud
No servers, no sync, no third-party exposure.
+
+
+
+
Never trained on
Your data is yours. We never touch it.
+
+
+
+
+
+
+
+
+
+
+ // surprising_connection.this_week
+ leiden.cross_cluster
+
+
+
+
+
+
+
+
+
+
+
+ ←
+ →
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
// access.request
+
The OSS is live.Start building now.
+
Open-source and free forever. Drop your email for enterprise access — cloud deployment, SSO, audit logs, and million-file corpus support.
+
+
+
+
// install now
+
+ pip install graphifyy
+ copy
+
+
+
+
+
+
1,600+ in enterprise queue
+
+
+
[ ACCESS GRANTED ] Enterprise access coming your way.
+
no_spam=true | unsubscribe=anytime | card_required=false
+
+
+
+
+
+
+
+
+