diff --git a/CHANGELOG.md b/CHANGELOG.md index efae2936..1bdc5a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.4.15 (2026-04-15) + +- Feat: VS Code Copilot Chat support — `graphify vscode install` installs a Python-only skill (works on Windows PowerShell) and writes `.github/copilot-instructions.md` for always-on graph context (#206) +- Fix: OpenCode plugin path used backslashes on Windows causing duplicate entries in `opencode.json` — now uses forward slashes via `.as_posix()` (#378) +- Fix: Gemini CLI on Windows now installs skill to `~/.agents/skills/` (higher priority) instead of `~/.gemini/skills/` (#368) +- Fix: `.mjs` and `.ejs` files now recognised by the AST extractor as JavaScript (#365, #372) +- Fix: `god_nodes()` field renamed from `edges` to `degree` for clarity — updated in report, wiki, serve, and all tests (#375) +- Fix: macOS `graphify watch` now uses `PollingObserver` by default to avoid missed events with FSEvents (#373) + ## 0.4.14 (2026-04-15) - Fix: cross-file call edges now emitted for all languages (Swift, Go, Rust, Java, C#, Kotlin, Scala, Ruby, PHP, and others) — previously only Python had cross-file resolution; unresolved call sites are now saved per file and resolved against a global label map in a post-pass (#348) diff --git a/README.md b/README.md index c47c1870..091435e8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![Sponsor](https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors)](https://github.com/sponsors/safishamsi) [![LinkedIn](https://img.shields.io/badge/LinkedIn-Safi%20Shamsi-0077B5?logo=linkedin)](https://www.linkedin.com/in/safi-shamsi) -**An AI coding assistant skill.** Type `/graphify` in Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, or Google Antigravity - it reads your files, builds a knowledge graph, and gives you back structure you didn't know was there. Understand a codebase faster. Find the "why" behind architectural decisions. +**An AI coding assistant skill.** Type `/graphify` in Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, or Google Antigravity - it reads your files, builds a knowledge graph, and gives you back structure you didn't know was there. Understand a codebase faster. Find the "why" behind architectural decisions. Fully multimodal. Drop in code, PDFs, markdown, screenshots, diagrams, whiteboard photos, images in other languages, or video and audio files - graphify extracts concepts and relationships from all of it and connects them into one graph. Videos are transcribed with Whisper using a domain-aware prompt derived from your corpus. 25 languages supported via tree-sitter AST (Python, JS, TS, Go, Rust, Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Lua, Zig, PowerShell, Elixir, Objective-C, Julia, Verilog, SystemVerilog, Vue, Svelte, Dart). @@ -48,7 +48,7 @@ Every relationship is tagged `EXTRACTED` (found directly in source), `INFERRED` ## Install -**Requires:** Python 3.10+ and one of: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli), [Aider](https://aider.chat), [OpenClaw](https://openclaw.ai), [Factory Droid](https://factory.ai), [Trae](https://trae.ai), [Kiro](https://kiro.dev), Hermes, or [Google Antigravity](https://antigravity.google) +**Requires:** Python 3.10+ and one of: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli), [VS Code Copilot Chat](https://code.visualstudio.com/docs/copilot/overview), [Aider](https://aider.chat), [OpenClaw](https://openclaw.ai), [Factory Droid](https://factory.ai), [Trae](https://trae.ai), [Kiro](https://kiro.dev), Hermes, or [Google Antigravity](https://antigravity.google) ```bash pip install graphifyy && graphify install @@ -65,6 +65,7 @@ pip install graphifyy && graphify install | Codex | `graphify install --platform codex` | | OpenCode | `graphify install --platform opencode` | | GitHub Copilot CLI | `graphify install --platform copilot` | +| VS Code Copilot Chat | `graphify vscode install` | | Aider | `graphify install --platform aider` | | OpenClaw | `graphify install --platform claw` | | Factory Droid | `graphify install --platform droid` | @@ -96,6 +97,7 @@ After building a graph, run this once in your project: | Codex | `graphify codex install` | | OpenCode | `graphify opencode install` | | GitHub Copilot CLI | `graphify copilot install` | +| VS Code Copilot Chat | `graphify vscode install` | | Aider | `graphify aider install` | | OpenClaw | `graphify claw install` | | Factory Droid | `graphify droid install` | @@ -125,6 +127,8 @@ After building a graph, run this once in your project: **GitHub Copilot CLI** copies the skill to `~/.copilot/skills/graphify/SKILL.md`. Run `graphify copilot install` to set it up. +**VS Code Copilot Chat** installs a Python-only skill (works on Windows PowerShell and macOS/Linux alike) and writes `.github/copilot-instructions.md` in your project root — VS Code reads this automatically every session, making graph context always-on without any hook mechanism. Run `graphify vscode install`. Note: this configures the chat panel in VS Code, not the Copilot CLI terminal tool. + Uninstall with the matching uninstall command (e.g. `graphify claude uninstall`). **Always-on vs explicit trigger — what's the difference?** diff --git a/graphify/__main__.py b/graphify/__main__.py index ce3f3e2d..3472b707 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -225,8 +225,12 @@ _GEMINI_HOOK = { def gemini_install(project_dir: Path | None = None) -> None: """Copy skill file to ~/.gemini/skills/graphify/, write GEMINI.md section, and install BeforeTool hook.""" # Copy skill file to ~/.gemini/skills/graphify/SKILL.md + # On Windows, Gemini CLI prioritises ~/.agents/skills/ over ~/.gemini/skills/ skill_src = Path(__file__).parent / "skill.md" - skill_dst = Path.home() / ".gemini" / "skills" / "graphify" / "SKILL.md" + if platform.system() == "Windows": + skill_dst = Path.home() / ".agents" / "skills" / "graphify" / "SKILL.md" + else: + skill_dst = Path.home() / ".gemini" / "skills" / "graphify" / "SKILL.md" 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") @@ -284,8 +288,11 @@ def _uninstall_gemini_hook(project_dir: Path) -> None: def gemini_uninstall(project_dir: Path | None = None) -> None: """Remove the graphify section from GEMINI.md, uninstall hook, and remove skill file.""" - # Remove skill file - skill_dst = Path.home() / ".gemini" / "skills" / "graphify" / "SKILL.md" + # Remove skill file (mirror the install path detection) + if platform.system() == "Windows": + skill_dst = Path.home() / ".agents" / "skills" / "graphify" / "SKILL.md" + else: + skill_dst = Path.home() / ".gemini" / "skills" / "graphify" / "SKILL.md" if skill_dst.exists(): skill_dst.unlink() print(f" skill removed -> {skill_dst}") @@ -316,6 +323,75 @@ def gemini_uninstall(project_dir: Path | None = None) -> None: _uninstall_gemini_hook(project_dir or Path(".")) +_VSCODE_INSTRUCTIONS_MARKER = "## graphify" +_VSCODE_INSTRUCTIONS_SECTION = """\ +## graphify + +Before answering architecture or codebase questions, read `graphify-out/GRAPH_REPORT.md` if it exists. +If `graphify-out/wiki/index.md` exists, navigate it for deep questions. +Type `/graphify` in Copilot Chat to build or update the knowledge graph. +""" + + +def vscode_install(project_dir: Path | None = None) -> None: + """Install graphify skill for VS Code Copilot Chat + write .github/copilot-instructions.md.""" + skill_src = Path(__file__).parent / "skill-vscode.md" + if not skill_src.exists(): + skill_src = Path(__file__).parent / "skill-copilot.md" + skill_dst = Path.home() / ".copilot" / "skills" / "graphify" / "SKILL.md" + 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") + print(f" skill installed -> {skill_dst}") + + instructions = (project_dir or Path(".")) / ".github" / "copilot-instructions.md" + instructions.parent.mkdir(parents=True, exist_ok=True) + if instructions.exists(): + content = instructions.read_text(encoding="utf-8") + if _VSCODE_INSTRUCTIONS_MARKER in content: + print(f" {instructions} -> already configured (no change)") + else: + instructions.write_text(content.rstrip() + "\n\n" + _VSCODE_INSTRUCTIONS_SECTION, encoding="utf-8") + print(f" {instructions} -> graphify section added") + else: + instructions.write_text(_VSCODE_INSTRUCTIONS_SECTION, encoding="utf-8") + print(f" {instructions} -> created") + + print() + print("VS Code Copilot Chat configured. Type /graphify in the chat panel to build the graph.") + print("Note: for GitHub Copilot CLI (terminal), use: graphify copilot install") + + +def vscode_uninstall(project_dir: Path | None = None) -> None: + """Remove graphify VS Code Copilot Chat skill and .github/copilot-instructions.md section.""" + skill_dst = Path.home() / ".copilot" / "skills" / "graphify" / "SKILL.md" + if skill_dst.exists(): + skill_dst.unlink() + print(f" skill removed -> {skill_dst}") + version_file = skill_dst.parent / ".graphify_version" + if version_file.exists(): + version_file.unlink() + for d in (skill_dst.parent, skill_dst.parent.parent, skill_dst.parent.parent.parent): + try: + d.rmdir() + except OSError: + break + + instructions = (project_dir or Path(".")) / ".github" / "copilot-instructions.md" + if not instructions.exists(): + return + content = instructions.read_text(encoding="utf-8") + if _VSCODE_INSTRUCTIONS_MARKER not in content: + return + cleaned = re.sub(r"\n*## graphify\n.*?(?=\n## |\Z)", "", content, flags=re.DOTALL).rstrip() + if cleaned: + instructions.write_text(cleaned + "\n", encoding="utf-8") + print(f" graphify section removed from {instructions}") + else: + instructions.unlink() + print(f" {instructions} -> deleted (was empty after removal)") + + _ANTIGRAVITY_RULES_PATH = Path(".agent") / "rules" / "graphify.md" _ANTIGRAVITY_WORKFLOW_PATH = Path(".agent") / "workflows" / "graphify.md" @@ -566,7 +642,7 @@ def _install_opencode_plugin(project_dir: Path) -> None: config = {} plugins = config.setdefault("plugin", []) - entry = str(_OPENCODE_PLUGIN_PATH) + entry = _OPENCODE_PLUGIN_PATH.as_posix() if entry not in plugins: plugins.append(entry) config_file.write_text(json.dumps(config, indent=2), encoding="utf-8") @@ -590,7 +666,7 @@ def _uninstall_opencode_plugin(project_dir: Path) -> None: except json.JSONDecodeError: return plugins = config.get("plugin", []) - entry = str(_OPENCODE_PLUGIN_PATH) + entry = _OPENCODE_PLUGIN_PATH.as_posix() if entry in plugins: plugins.remove(entry) if not plugins: @@ -861,6 +937,8 @@ def main() -> None: print(" aider uninstall remove graphify section from AGENTS.md") print(" copilot install copy graphify skill to ~/.copilot/skills (GitHub Copilot CLI)") print(" copilot uninstall remove graphify skill from ~/.copilot/skills") + print(" vscode install configure VS Code Copilot Chat (skill + .github/copilot-instructions.md)") + print(" vscode uninstall remove VS Code Copilot Chat configuration") print(" claw install write graphify section to AGENTS.md (OpenClaw)") print(" claw uninstall remove graphify section from AGENTS.md") print(" droid install write graphify section to AGENTS.md (Factory Droid)") @@ -922,6 +1000,15 @@ def main() -> None: else: print("Usage: graphify cursor [install|uninstall]", file=sys.stderr) sys.exit(1) + elif cmd == "vscode": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + vscode_install() + elif subcmd == "uninstall": + vscode_uninstall() + else: + print("Usage: graphify vscode [install|uninstall]", file=sys.stderr) + sys.exit(1) elif cmd == "copilot": subcmd = sys.argv[2] if len(sys.argv) > 2 else "" if subcmd == "install": diff --git a/graphify/analyze.py b/graphify/analyze.py index d9bd479f..a47d82aa 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -51,7 +51,7 @@ def god_nodes(G: nx.Graph, top_n: int = 10) -> list[dict]: result.append({ "id": node_id, "label": G.nodes[node_id].get("label", node_id), - "edges": deg, + "degree": deg, }) if len(result) >= top_n: break diff --git a/graphify/detect.py b/graphify/detect.py index 0e51d93d..2f3473a6 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -18,7 +18,7 @@ class FileType(str, Enum): _MANIFEST_PATH = "graphify-out/manifest.json" -CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.dart', '.v', '.sv'} +CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.mjs', '.ejs', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.dart', '.v', '.sv'} DOC_EXTENSIONS = {'.md', '.txt', '.rst'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} diff --git a/graphify/report.py b/graphify/report.py index 180233d2..5ebd2ea6 100644 --- a/graphify/report.py +++ b/graphify/report.py @@ -72,7 +72,7 @@ def generate( "## God Nodes (most connected - your core abstractions)", ] for i, node in enumerate(god_node_list, 1): - lines.append(f"{i}. `{node['label']}` - {node['edges']} edges") + lines.append(f"{i}. `{node['label']}` - {node['degree']} edges") lines += ["", "## Surprising Connections (you probably didn't know these)"] if surprise_list: diff --git a/graphify/serve.py b/graphify/serve.py index d1f1960d..361dec3c 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -295,7 +295,7 @@ def serve(graph_path: str = "graphify-out/graph.json") -> None: from .analyze import god_nodes as _god_nodes nodes = _god_nodes(G, top_n=int(arguments.get("top_n", 10))) lines = ["God nodes (most connected):"] - lines += [f" {i}. {n['label']} - {n['edges']} edges" for i, n in enumerate(nodes, 1)] + lines += [f" {i}. {n['label']} - {n['degree']} edges" for i, n in enumerate(nodes, 1)] return "\n".join(lines) def _tool_graph_stats(_: dict) -> str: diff --git a/graphify/skill-vscode.md b/graphify/skill-vscode.md new file mode 100644 index 00000000..7d427ac9 --- /dev/null +++ b/graphify/skill-vscode.md @@ -0,0 +1,253 @@ +--- +name: graphify +description: any input (code, docs, papers, images) → knowledge graph → clustered communities → HTML + JSON + audit report +trigger: /graphify +--- + +# /graphify + +Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md. + +## Usage + +``` +/graphify # full pipeline on current directory +/graphify # full pipeline on specific path +/graphify --update # incremental - re-extract only new/changed files +/graphify --no-viz # skip visualization, just report + JSON +/graphify --wiki # build agent-crawlable wiki +/graphify query "" # BFS traversal - broad context +``` + +## What You Must Do When Invoked + +If no path was given, use `.` (current directory). Do not ask the user for a path. + +Follow these steps in order. Do not skip steps. + +**All commands use `python -c "..."` syntax — no bash heredocs, no shell redirects, no `&&`/`||`. This runs correctly on Windows PowerShell and macOS/Linux alike.** + +### Step 1 - Ensure graphify is installed + +```python +python -c "import graphify; import sys; from pathlib import Path; Path('graphify-out').mkdir(exist_ok=True); Path('graphify-out/.graphify_python').write_text(sys.executable)" +``` + +If the import fails, install first: + +```python +python -m pip install graphifyy -q +``` + +Then re-run the Step 1 command. + +### Step 2 - Detect files + +```python +python -c " +import json, sys +from graphify.detect import detect +from pathlib import Path + +result = detect(Path('INPUT_PATH')) +Path('graphify-out/.graphify_detect.json').write_text(json.dumps(result, indent=2)) +total = result.get('total_files', 0) +words = result.get('total_words', 0) +print(f'Corpus: {total} files, ~{words} words') +for ftype, files in result.get('files', {}).items(): + if files: + print(f' {ftype}: {len(files)} files') +" +``` + +Replace `INPUT_PATH` with the actual path. Present a clean summary — do not dump the raw JSON. + +- If `total_files` is 0: stop with "No supported files found in [path]." +- If `total_words` > 2,000,000 OR `total_files` > 200: warn the user and ask which subfolder to run on. +- Otherwise: proceed to Step 3. + +### Step 3 - Extract entities and relationships + +#### Part A - Structural extraction (AST, free, no API cost) + +```python +python -c " +import json +from graphify.extract import collect_files, extract +from pathlib import Path + +detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text()) +code_files = [] +for f in detect.get('files', {}).get('code', []): + p = Path(f) + code_files.extend(collect_files(p) if p.is_dir() else [p]) + +if code_files: + result = extract(code_files) + Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2)) + print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') +else: + Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0})) + print('No code files - skipping AST extraction') +" +``` + +#### Part B - Semantic extraction (AI, costs tokens) + +Skip if corpus is code-only (no docs, papers, or images). + +Check cache first: + +```python +python -c " +import json +from graphify.cache import check_semantic_cache +from pathlib import Path + +detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text()) +all_files = [f for files in detect['files'].values() for f in files] +cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files) + +if cached_nodes or cached_edges: + Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges})) +Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached)) +print(f'Cache: {len(all_files)-len(uncached)} hit, {len(uncached)} need extraction') +" +``` + +For each chunk of uncached files (20-25 files per chunk), dispatch a subagent with this prompt: + +``` +You are a graphify extraction subagent. Read the files listed and extract a knowledge graph fragment. +Output ONLY valid JSON: {"nodes": [...], "edges": [...], "hyperedges": [...]} + +Each node: {"id": "unique_id", "label": "Human Name", "file_type": "code|document|paper|image"} +Each edge: {"source": "id", "target": "id", "relation": "verb_phrase", "confidence": "EXTRACTED|INFERRED|AMBIGUOUS"} +hyperedges: [] unless you find a genuine group relationship + +Files: +FILE_LIST +``` + +Collect all subagent responses and merge them: + +```python +python -c " +import json +from pathlib import Path + +# Merge: combine AST + cached + all semantic chunk results +all_nodes, all_edges, all_hyperedges = [], [], [] + +ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text()) +all_nodes.extend(ast.get('nodes', [])) +all_edges.extend(ast.get('edges', [])) + +cached_path = Path('graphify-out/.graphify_cached.json') +if cached_path.exists(): + cached = json.loads(cached_path.read_text()) + all_nodes.extend(cached.get('nodes', [])) + all_edges.extend(cached.get('edges', [])) + all_hyperedges.extend(cached.get('hyperedges', [])) + +# PASTE each subagent response here as chunk_1, chunk_2, etc. +for chunk_json in []: # replace [] with your chunk results + chunk = json.loads(chunk_json) if isinstance(chunk_json, str) else chunk_json + all_nodes.extend(chunk.get('nodes', [])) + all_edges.extend(chunk.get('edges', [])) + all_hyperedges.extend(chunk.get('hyperedges', [])) + +merged = {'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges, 'input_tokens': 0, 'output_tokens': 0} +Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2)) +print(f'Merged: {len(all_nodes)} nodes, {len(all_edges)} edges') +" +``` + +### Step 4 - Build graph and cluster + +```python +python -c " +import json +from graphify.build import build_from_json +from graphify.cluster import cluster +from graphify.analyze import god_nodes, surprising_connections +from pathlib import Path + +extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text()) +G = build_from_json(extraction) +communities = cluster(G) +gods = god_nodes(G) +surprises = surprising_connections(G, communities) + +import networkx as nx +from networkx.readwrite import json_graph +graph_data = json_graph.node_link_data(G) +Path('graphify-out/graph.json').write_text(json.dumps(graph_data, indent=2)) +Path('graphify-out/.graphify_analysis.json').write_text(json.dumps({ + 'communities': {str(k): v for k, v in communities.items()}, + 'cohesion': {}, + 'god_nodes': gods, + 'surprises': surprises, +}, indent=2)) +print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities') +print(f'God nodes: {[g[\"label\"] for g in gods[:5]]}') +" +``` + +### Step 5 - Generate report and visualization + +```python +python -c " +import json +from graphify.build import build_from_json +from graphify.cluster import cluster +from graphify.analyze import god_nodes, surprising_connections +from graphify.report import generate +from pathlib import Path + +extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text()) +analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text()) + +G = build_from_json(extraction) +communities = {int(k): v for k, v in analysis['communities'].items()} +gods = god_nodes(G) +surprises = surprising_connections(G, communities) + +report = generate(G, communities, {}, {}, gods, surprises, extraction) +Path('graphify-out/GRAPH_REPORT.md').write_text(report) +print('GRAPH_REPORT.md written') +" +``` + +```python +python -c " +import json +from graphify.build import build_from_json +from graphify.cluster import cluster +from graphify.export import to_html +from pathlib import Path + +extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text()) +G = build_from_json(extraction) +communities = cluster(G) + +try: + to_html(G, communities, 'graphify-out/graph.html') + print('graph.html written') +except ValueError as e: + print(f'Visualization skipped: {e}') +" +``` + +### After completing all steps + +Print this summary: + +``` +graphify complete + graph.json — GraphRAG-ready, queryable by MCP or CLI + graph.html — interactive visualization (open in browser) + GRAPH_REPORT.md — plain-language architecture summary +``` + +Read `graphify-out/GRAPH_REPORT.md` and share the **God Nodes** and **Surprising Connections** sections directly in the chat — do not ask the user to open the file themselves. diff --git a/graphify/watch.py b/graphify/watch.py index 45d03a9b..7f36da09 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -120,6 +120,7 @@ def watch(watch_path: Path, debounce: float = 3.0) -> None: """ try: from watchdog.observers import Observer + from watchdog.observers.polling import PollingObserver from watchdog.events import FileSystemEventHandler except ImportError as e: raise ImportError("watchdog not installed. Run: pip install watchdog") from e @@ -145,7 +146,8 @@ def watch(watch_path: Path, debounce: float = 3.0) -> None: changed.add(path) handler = Handler() - observer = Observer() + # Use polling observer on macOS — FSEvents can miss rapid saves in some editors + observer = PollingObserver() if sys.platform == "darwin" else Observer() observer.schedule(handler, str(watch_path), recursive=True) observer.start() diff --git a/graphify/wiki.py b/graphify/wiki.py index 898a8ec5..732444f7 100644 --- a/graphify/wiki.py +++ b/graphify/wiki.py @@ -154,7 +154,7 @@ def _index_md( if god_nodes_data: lines += ["## God Nodes", "(most connected concepts — the load-bearing abstractions)", ""] for node in god_nodes_data: - lines.append(f"- [[{node['label']}]] — {node['edges']} connections") + lines.append(f"- [[{node['label']}]] — {node['degree']} connections") lines.append("") lines += [ diff --git a/pyproject.toml b/pyproject.toml index dee4c50c..4a49d7a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.4.14" +version = "0.4.15" 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" } @@ -60,4 +60,4 @@ where = ["."] include = ["graphify*"] [tool.setuptools.package-data] -graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-aider.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md"] +graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-aider.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md"] diff --git a/tests/test_analyze.py b/tests/test_analyze.py index 2d496139..1017da8b 100644 --- a/tests/test_analyze.py +++ b/tests/test_analyze.py @@ -23,7 +23,7 @@ def test_god_nodes_returns_list(): def test_god_nodes_sorted_by_degree(): G = make_graph() result = god_nodes(G, top_n=10) - degrees = [r["edges"] for r in result] + degrees = [r["degree"] for r in result] assert degrees == sorted(degrees, reverse=True) @@ -32,7 +32,7 @@ def test_god_nodes_have_required_keys(): result = god_nodes(G, top_n=1) assert "id" in result[0] assert "label" in result[0] - assert "edges" in result[0] + assert "degree" in result[0] def test_surprising_connections_cross_source_multi_file(): diff --git a/tests/test_hypergraph.py b/tests/test_hypergraph.py index dc7d40ae..dda8ac79 100644 --- a/tests/test_hypergraph.py +++ b/tests/test_hypergraph.py @@ -166,7 +166,7 @@ def _make_report(G): communities = {0: list(G.nodes())} cohesion = {0: 1.0} labels = {0: "All"} - gods = [{"label": "BasicAuth", "edges": 2}] + gods = [{"label": "BasicAuth", "degree": 2}] surprises = [] return generate(G, communities, cohesion, labels, gods, surprises, SAMPLE_DETECTION, {"input": 10, "output": 5}, ".") diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 9f7335b6..ce6055d8 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -51,7 +51,7 @@ def run_pipeline(tmp_path: Path) -> dict: # Step 5: analyze gods = god_nodes(G) assert len(gods) > 0 - assert all("id" in g and "edges" in g for g in gods) + assert all("id" in g and "degree" in g for g in gods) surprises = surprising_connections(G, communities) assert isinstance(surprises, list) diff --git a/tests/test_wiki.py b/tests/test_wiki.py index 3b29cf5b..48335958 100644 --- a/tests/test_wiki.py +++ b/tests/test_wiki.py @@ -20,7 +20,7 @@ def _make_graph(): COMMUNITIES = {0: ["n1", "n2"], 1: ["n3", "n4"]} LABELS = {0: "Parsing Layer", 1: "Rendering Layer"} COHESION = {0: 0.85, 1: 0.72} -GOD_NODES = [{"id": "n1", "label": "parse", "edges": 2}] +GOD_NODES = [{"id": "n1", "label": "parse", "degree": 2}] def test_to_wiki_writes_index(tmp_path): @@ -105,7 +105,7 @@ def test_god_node_article_links_community(tmp_path): def test_to_wiki_skips_missing_god_node_ids(tmp_path): """God node with bad ID should not crash.""" G = _make_graph() - bad_gods = [{"id": "nonexistent", "label": "ghost", "edges": 99}] + bad_gods = [{"id": "nonexistent", "label": "ghost", "degree": 99}] n = to_wiki(G, COMMUNITIES, tmp_path, community_labels=LABELS, god_nodes_data=bad_gods) # 2 communities + 0 god nodes (nonexistent skipped) = 2 assert n == 2