diff --git a/CHANGELOG.md b/CHANGELOG.md
index 86dc8f127..64318eeb9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,146 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
+## 0.6.9 (2026-05-03)
+
+- Fix: `source_file` path separators normalized to forward slashes at graph ingestion — same physical file emitted with backslashes (Windows AST extractor) and forward slashes (semantic subagents) now merges into one node instead of splitting into two disconnected components (#683)
+- Fix: two-phase cohesion re-clustering — communities with cohesion < 0.05 and ≥ 50 nodes are re-split, preventing doc-hub nodes (e.g. `CLAUDE.md`) from merging unrelated subsystems into one giant community (#683)
+- Fix: VS Code Copilot instructions rewritten to be prescriptive — agent's first tool call must read `GRAPH_REPORT.md`, explicit trigger list, narrow allowlist for raw source reads (#688)
+- Feat: `GRAPHIFY_OUT` env var overrides the output directory — accepts a relative name or absolute path, wires through `cache.py`, `watch.py`, and the CLI; useful for sharing one graph across multiple git worktrees (#686)
+- Fix: `graphify antigravity install` now auto-updates stale rules and workflow files on re-run instead of silently skipping them (#652)
+- Docs: README simplified — less dense, plain language; technical pipeline details moved to `docs/how-it-works.md`
+
+## 0.6.8 (2026-05-03)
+
+- Fix: `.graphifyignore` negation patterns (`!src/**`) now work correctly — when any `!` pattern is present, directory pruning is deferred to per-file checks so negated files inside ignored directories are reached (#676)
+- Fix: Antigravity slash command `/graphify` now appears in the command dropdown — workflow file now includes YAML frontmatter with `name: graphify` required for Antigravity discovery (#678)
+- Fix: Gemini CLI BeforeTool hook replaced `[ -f ... ] && echo` (bash-only) with cross-platform `python -c` using `json.dumps` — fixes hook failure on Windows CMD and Git Bash (#681)
+- Fix: Codex hook-check exits silently — resolves `additionalContext` rejection on Codex Desktop PreToolUse (#651)
+- Fix: `graphify install --platform codex` now writes absolute path to `graphify` executable — fixes PATH resolution in VS Code extension on Windows (#651)
+- Fix: thin communities (fewer than 3 concept nodes) are now omitted from the Communities section in `GRAPH_REPORT.md` by default; report header shows `(N total, M thin omitted)` and Knowledge Gaps collapses thin communities to one summary line (#664)
+
+## 0.6.7 (2026-05-02)
+
+- Feat: `graphify tree` — self-contained D3 v7 collapsible-tree HTML view of `graph.json`; expand/collapse controls, depth-based colours, hover inspector; XSS-safe (#557)
+- Feat: token-aware chunking with split-and-retry on truncation (#625)
+- Feat: cross-language edge context filters in MCP `query_graph` tool (#573)
+- Feat: dynamic `import()` extraction for JS/TS (#579)
+- Fix: `save_semantic_cache` crashed with `IsADirectoryError` when a node's `source_file` was a directory path — `p.exists()` → `p.is_file()` (#655)
+- Fix: `sanitize_label(None)` raised `TypeError` crashing `to_html` on graphs with null `source_file` rationale nodes — return `""` early (#656)
+- Fix: chunk-extraction prompt omitted `rationale` from valid `file_type` values — model hallucinated `concept` on every doc/paper run; explicit merge step added to all skill variants (#657)
+- Fix: `cost.json` always reported 0 tokens — chunk JSONs have placeholder zeros; orchestrator now globs and sums real token counts before merging (#658)
+## 0.6.6 (2026-05-02)
+
+- Fix: `skill-windows.md` rewritten from PowerShell to bash — Claude Code on Windows uses git-bash so PowerShell syntax (`$null`, `$LASTEXITCODE`, `Select-Object`, `& (Get-Content ...)`, `Remove-Item`) caused exit code 49 failures; now mirrors `skill.md` structure with `python` added as fallback after `python3` for Windows Conda (#39)
+- Fix: wiki `to_wiki()` now clears stale articles before regenerating, preventing orphan .md accumulation (#558)
+- Fix: `_safe_filename()` in wiki.py now strips Windows-reserved characters (`< > : " / \ | ? *`) and caps length at 200 chars (#594)
+- Fix: rationale-node leakage in cross-file INFERRED call resolution — rationale nodes now excluded from name lookup; edge direction (`calls`, `rationale_for`) preserved correctly at JSON export (#576)
+- Feat: `.graphifyinclude` hidden path allowlist — opt specific hidden dirs into traversal (e.g. `.hermes/plans/**/*.md`) (#583)
+- Feat: `--no-viz` flag wired in `cluster-only`; `GRAPHIFY_VIZ_NODE_LIMIT` env var overrides 5000-node HTML threshold (#565)
+- Fix: stray colon SyntaxError in `skill-trae.md` `--cluster-only` block (#603)
+- Docs: skill INFERRED confidence score guidance changed to discrete rubric (0.55/0.65/0.75/0.85/0.95) backed by calibration data (#546)
+- Docs: skill `--update` prune output clarified — splits no-drift vs drift cases (#544)
+- Docs: skill `--update` merge step now calls `save_manifest` to prevent deleted files reappearing (#545)
+- Feat: `graphify tree` — self-contained D3 v7 collapsible-tree HTML view of `graph.json`; expand/collapse controls, depth-based colours, hover inspector; XSS-safe via `html.escape()` and `_js_safe()` (#557)
+
+## 0.6.5 (2026-05-02)
+
+- Fix: Kotlin call-walker now accepts both `simple_identifier` and `identifier` node types — PyPI's `tree_sitter_kotlin` grammar uses `identifier` while older forks use `simple_identifier`, causing zero `calls` edges to be emitted (#659)
+- Feat: community sidebar now uses checkbox-based multi-select instead of show/hide buttons — supports indeterminate "select all" state (#647)
+- Feat: `graphify update --force` and `GRAPHIFY_FORCE=1` env var — bypass the node-count safety check after refactors that legitimately shrink the graph (#639)
+- Fix: Codex PreToolUse hook on Windows — replaced `python3 -c "..."` inline command (fails on Conda where only `python` exists, and breaks PowerShell JSON parsing) with `graphify hook-check`, a new shell-agnostic subcommand. Re-run `graphify codex install` to regenerate the hook (#651, #522)
+
+## 0.6.4 (2026-05-02)
+
+- Fix: Codex PreToolUse hook failed on Windows — `[ -f ]` is bash-only and crashes on `cmd.exe`; replaced with a cross-platform Python one-liner (`pathlib.Path.exists()`) (#651)
+
+## 0.6.3 (2026-05-02)
+
+- Fix: incremental rebuild (`graphify update`, post-commit hook) dropped INFERRED/AMBIGUOUS semantic nodes extracted from code files — node preservation now filters by ID membership in the new AST output instead of `file_type`, so LLM-extracted call/data-flow edges survive code-only rebuilds (#653)
+- Fix: post-commit and post-checkout hooks blocked `git commit` for the full rebuild duration (hours on large repos) — rebuilds now detach via `nohup & disown`, git returns in ~100ms, log written to `~/.cache/graphify-rebuild.log` (#650)
+- Fix: cross-file INFERRED `calls` resolution used a last-write-wins name map, causing common short names (`log`, `execute`, `find`) to accumulate hundreds of spurious edges and dominate god_nodes ranking — resolution now skips any callee name that matches 2+ candidates (ambiguous, no import evidence to pick the right target) (#543)
+- Fix: `cluster-only` command crashed on graphs with >5000 nodes due to unguarded `to_html` call — now wrapped in try/except ValueError matching the watch/hook path (#541)
+
+## 0.6.2 (2026-05-01)
+
+- Fix: Kimi K2.6 reasoning mode consumed entire token budget leaving `content` empty — thinking now disabled on Moonshot calls so graphs actually populate (#623)
+- Fix: `graphify update` / `graphify watch` never persisted the manifest, so every subsequent `--update` re-extracted all files — manifest now saved after each rebuild (#621)
+- Fix: inline comments in `.graphifyignore` (e.g. `vendor/ # legacy`) now stripped correctly — whitespace + `#` suffix is treated as a comment, `path#hash.py` preserved (#605)
+- Fix: `graphify query "FunctionName"` now returns the exact matching node first instead of high-degree hub modules hijacking the output — 100-point exact-match bonus + seeds render before BFS expansion (#638)
+- Fix: concurrent AST extractors raced on a shared `.tmp` cache file — each writer now gets a unique tempfile via `mkstemp`, eliminating cache corruption under parallel extraction (#589)
+- Fix: `_clone_repo` branch names starting with `-` could be interpreted as git flags — validation added, `--` separator inserted before positional args (#589)
+- Fix: replaced `html2text` (GPL-3.0) with `markdownify` (MIT) — removes the only copyleft dependency from a MIT project (#586)
+- Fix: `--update` re-extracted files whose mtime was bumped by sync tools (Obsidian, Nextcloud) without content changes — manifest now stores content hash alongside mtime; mtime bump triggers an MD5 check before re-extraction (#593)
+- Feat: R language support — `.r` files classified as code and processed via LLM semantic extraction (#617)
+- Feat: extensionless shell scripts now detected via shebang (`#!/bin/bash`, `#!/usr/bin/env python3`, etc.) and included as code (#619)
+- Fix: cross-language INFERRED `calls` edges (e.g. Python→TypeScript name collision) no longer appear as top surprising connections in GRAPH_REPORT.md (#630)
+- Fix: `cluster-only` CLI silently flipped directed graphs to undirected — `directed` flag now read from graph.json and preserved through re-clustering (#590)
+- Fix: Windows UNC / extended-length paths (`\\?\C:\...`) now normalize to consistent cache keys (#629)
+- Fix: `.graphifyignore` negation patterns (`!src/lib/secrets.ts`) now work — full last-match-wins evaluation with `!` un-ignore support (#628)
+
+## 0.6.1 (2026-05-01)
+
+- Fix: `.graphifyignore` discovery now uses correct gitignore semantics — outer rules are loaded first so inner (closer) rules always win via last-match-wins, matching standard gitignore behavior (#643)
+- Fix: without a VCS root, `.graphifyignore` discovery is now hermetic to the scan folder — no leakage across sibling projects in a shared workspace (#643)
+- Fix: anchored patterns (leading `/`) in a parent `.graphifyignore` now correctly apply only relative to their own directory, not the scan root (#643)
+- Fix: trailing spaces in patterns are now handled per gitignore spec — unescaped trailing spaces are stripped, `vendor\ ` (escaped) is preserved (#643)
+
+## 0.6.0 (2026-05-01)
+
+- Feat: SQL AST extractor — `.sql` files now processed deterministically via tree-sitter. Extracts tables, views, functions/procedures, foreign key references, and FROM/JOIN reads_from edges. No LLM needed. Requires `pip install 'graphifyy[sql]'` (#349)
+- Feat: `xlsx_extract_structure()` utility — extracts sheet names, named tables, and column headers from .xlsx files as structural nodes
+
+## 0.5.7 (2026-04-30)
+
+- Feat: YAML/YML files now indexed for semantic extraction — Kubernetes, Kustomize, Helm, and any YAML corpus now picked up automatically (#633)
+
+## 0.5.6 (2026-04-30)
+
+- Fix: `NameError: name '_os' is not defined` crash after `graphify update` — this was fixed in v5 branch but not released to PyPI (#618, #612)
+
+## 0.5.5 (2026-04-29)
+
+- Feat: Kimi K2.6 backend — `pip install 'graphifyy[kimi]'` + `MOONSHOT_API_KEY` routes semantic extraction through Kimi K2.6. 3-6x richer relation extraction at ~3x lower cost. Claude remains default; Kimi is opt-in.
+- Fix: phantom god nodes (#598) — member-call callees (`this.logger.log()` → `log`) no longer cross-file resolved. Go package-qualified calls (`pkg.Func()`) correctly preserved. Affects JS/TS, Go, Rust, Swift, Kotlin, Scala, PHP, C++, C#, Zig, Elixir.
+- Fix: `concept` file_type no longer triggers validation warnings (#601)
+- Fix: `graphify update` remembers scan root via `graphify-out/.graphify_root` — no path argument needed on subsequent runs
+- Fix: Kimi K2.6 temperature 400 error — temperature param is now skipped for Kimi backends (model enforces its own fixed value) (#610)
+- Fix: community labels deleted in Step 9 cleanup — `.graphify_labels.json` is now preserved so wiki/obsidian/HTML retain human-readable names after re-cluster (#608)
+- Fix: `NameError: name '_os' is not defined` in `graphify update` Kimi tip (#612)
+- Fix: `SyntaxWarning` in `__main__.py` for shell glob pattern with backslash escapes
+- Fix: Python upper bound removed — `requires-python = ">=3.10"` now supports Python 3.14+ (#607)
+
+## 0.5.4 (2026-04-28)
+
+- Fix: SSRF DNS rebinding — `safe_fetch` now patches `socket.getaddrinfo` for the full request duration (#591)
+- Fix: yt-dlp SSRF bypass — `download_audio` now calls `validate_url` before handing URL to yt-dlp (#592)
+
+## 0.5.3 (2026-04-27)
+
+- Fix: cache namespace — AST and semantic entries now live in `cache/ast/` and `cache/semantic/` subdirectories; flat entries read as migration fallback
+
+## 0.5.2 (2026-04-26)
+
+- Fix: PreToolUse hook now matches on `Bash` instead of `Glob|Grep` for Claude Code v2.1.117+
+
+## 0.5.1 (2026-04-25)
+
+- Fix: node ID collision for same-named files in different directories
+- Fix: `source_file` paths relativized before return so `graph.json` is portable
+- Fix: desync guard — `to_json()` returns bool; report only written on successful JSON write
+- Feat: TypeScript `@/` path aliases resolved via `tsconfig.json`
+- Feat: Show All / Hide All buttons in HTML community panel
+
+## 0.5.0 (2026-04-24)
+
+- Feat: `graphify clone ` — clone and graph any public repo
+- Feat: `graphify merge-graphs` — combine multiple `graph.json` outputs into one cross-repo graph
+- Feat: `CLAUDE_CONFIG_DIR` support in `graphify install`
+- Feat: shrink guard — `to_json()` refuses to overwrite with a smaller graph
+- Feat: `build_merge()` for safe incremental updates
+- Feat: duplicate node deduplication via `deduplicate_by_label()`
+- Fix: `graphify-out/` excluded from source scanning
+
## 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/README.md b/README.md
index efcca4de1..2849c2f82 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@
+
@@ -20,67 +21,45 @@
-**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.
+Type `/graphify` in your AI coding assistant and it maps your entire project — code, docs, PDFs, images, videos — into a knowledge graph you can query instead of grepping through files.
-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).
-
-> Andrej Karpathy keeps a `/raw` folder where he drops papers, tweets, screenshots, and notes. graphify is the answer to that problem - 71.5x fewer tokens per query vs reading the raw files, persistent across sessions, honest about what it found vs guessed.
-
-```
-/graphify . # works on any folder - your codebase, notes, papers, anything
-```
+Works in Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, and Google Antigravity.
```
-graphify-out/
-├── graph.html interactive graph - open in any browser, click nodes, search, filter by community
-├── GRAPH_REPORT.md god nodes, surprising connections, suggested questions
-├── graph.json persistent graph - query weeks later without re-reading
-└── cache/ SHA256 cache - re-runs only process changed files
+/graphify .
```
-Add a `.graphifyignore` file to exclude folders you don't want in the graph:
+That's it. You get three files:
```
-# .graphifyignore
-vendor/
-node_modules/
-dist/
-*.generated.py
+graphify-out/
+├── graph.html open in any browser — click nodes, filter, search
+├── GRAPH_REPORT.md the highlights: key concepts, surprising connections, suggested questions
+└── graph.json the full graph — query it anytime without re-reading your files
```
-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.
-
-## 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.
-
-**Clustering is graph-topology-based — no embeddings.** Leiden finds communities by edge density. The semantic similarity edges that Claude extracts (`semantically_similar_to`, marked INFERRED) are already in the graph, so they influence community detection directly. The graph structure is the similarity signal — no separate embedding step or vector database needed.
-
-Every relationship is tagged `EXTRACTED` (found directly in source), `INFERRED` (reasonable inference, with a confidence score), or `AMBIGUOUS` (flagged for review). You always know what was found vs guessed.
+---
## 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), [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)
+**Requires Python 3.10+**
```bash
-# Recommended — works on Mac and Linux with no PATH setup needed
uv tool install graphifyy && graphify install
-# or with pipx
-pipx install graphifyy && graphify install
-# or plain pip
-pip install graphifyy && graphify install
+# or: pipx install graphifyy && graphify install
+# or: pip install graphifyy && graphify install
```
-> **Official package:** The PyPI package is named `graphifyy` (install with `pip install graphifyy`). Other packages named `graphify*` on PyPI are not affiliated with this project. The only official repository is [safishamsi/graphify](https://github.com/safishamsi/graphify). The CLI and skill command are still `graphify`.
+> **Official package:** The PyPI package is `graphifyy` (double-y). Other `graphify*` packages on PyPI are not affiliated. The CLI command is still `graphify`.
-> **`graphify: command not found`?** Use `uv tool install graphifyy` (recommended) or `pipx install graphifyy` — both put the CLI in a managed location that's automatically on PATH. With plain `pip`, you may need to add `~/.local/bin` (Linux) or `~/Library/Python/3.x/bin` (Mac) to your PATH, or run `python -m graphify` instead. On Windows, pip scripts land in `%APPDATA%\Python\PythonXY\Scripts`.
+> **`graphify: command not found`?** Use `uv tool install graphifyy` or `pipx install graphifyy` — both put the CLI on PATH automatically. With plain `pip`, add `~/.local/bin` (Linux) or `~/Library/Python/3.x/bin` (Mac) to your PATH, or run `python -m graphify`.
-### Platform support
+### Pick your platform
| Platform | Install command |
|----------|----------------|
| Claude Code (Linux/Mac) | `graphify install` |
-| Claude Code (Windows) | `graphify install` (auto-detected) or `graphify install --platform windows` |
+| Claude Code (Windows) | `graphify install --platform windows` |
| Codex | `graphify install --platform codex` |
| OpenCode | `graphify install --platform opencode` |
| GitHub Copilot CLI | `graphify install --platform copilot` |
@@ -93,22 +72,18 @@ pip install graphifyy && graphify install
| Gemini CLI | `graphify install --platform gemini` |
| Hermes | `graphify install --platform hermes` |
| Kiro IDE/CLI | `graphify kiro install` |
+| Pi coding agent | `graphify install --platform pi` |
| Cursor | `graphify cursor install` |
| Google Antigravity | `graphify antigravity install` |
-Codex users also need `multi_agent = true` under `[features]` in `~/.codex/config.toml` for parallel extraction. Factory Droid uses the `Task` tool for parallel subagent dispatch. OpenClaw and Aider use sequential extraction (parallel agent support is still early on those platforms). Trae uses the Agent tool for parallel subagent dispatch and does **not** support PreToolUse hooks — AGENTS.md is the always-on mechanism. Codex supports PreToolUse hooks — `graphify codex install` installs one in `.codex/hooks.json` in addition to writing AGENTS.md.
-
-Then open your AI coding assistant and type:
-
-```
-/graphify .
-```
+> Codex users: also add `multi_agent = true` under `[features]` in `~/.codex/config.toml`.
+> Codex uses `$graphify` instead of `/graphify`.
-Note: Codex uses `$` instead of `/` for skill calling, so type `$graphify .` instead.
+---
-### Make your assistant always use the graph (recommended)
+## Make your assistant always use the graph
-After building a graph, run this once in your project:
+Run this once in your project after building a graph:
| Platform | Command |
|----------|---------|
@@ -126,319 +101,212 @@ After building a graph, run this once in your project:
| Gemini CLI | `graphify gemini install` |
| Hermes | `graphify hermes install` |
| Kiro IDE/CLI | `graphify kiro install` |
+| Pi coding agent | `graphify pi install` |
| Google Antigravity | `graphify antigravity install` |
-**Claude Code** does two things: writes a `CLAUDE.md` section telling Claude to read `graphify-out/GRAPH_REPORT.md` before answering architecture questions, and installs a **PreToolUse hook** (`settings.json`) that fires before every Glob and Grep call. If a knowledge graph exists, Claude sees: _"graphify: Knowledge graph exists. Read GRAPH_REPORT.md for god nodes and community structure before searching raw files."_ — so Claude navigates via the graph instead of grepping through every file.
+This writes a small config file that tells your assistant to read `GRAPH_REPORT.md` before answering questions about your codebase. On platforms that support hooks (Claude Code, Codex, Gemini CLI), a hook fires automatically before every file-read call — your assistant navigates by the graph instead of grepping through everything.
-**Codex** writes to `AGENTS.md` and also installs a **PreToolUse hook** in `.codex/hooks.json` that fires before every Bash tool call — same always-on mechanism as Claude Code.
+Uninstall with the matching command (e.g. `graphify claude uninstall`).
-**OpenCode** writes to `AGENTS.md` and also installs a **`tool.execute.before` plugin** (`.opencode/plugins/graphify.js` + `opencode.json` registration) that fires before bash tool calls and injects the graph reminder into tool output when the graph exists.
+---
-**Cursor** writes `.cursor/rules/graphify.mdc` with `alwaysApply: true` — Cursor includes it in every conversation automatically, no hook needed.
+## What's in the report
-**Gemini CLI** copies the skill to `~/.gemini/skills/graphify/SKILL.md`, writes a `GEMINI.md` section, and installs a `BeforeTool` hook in `.gemini/settings.json` that fires before file-read tool calls — same always-on mechanism as Claude Code.
+- **God nodes** — the most-connected concepts in your project. Everything flows through these.
+- **Surprising connections** — links between things that live in different files or modules. Ranked by how unexpected they are.
+- **The "why"** — inline comments (`# NOTE:`, `# WHY:`, `# HACK:`), docstrings, and design rationale from docs are extracted as separate nodes linked to the code they explain.
+- **Suggested questions** — 4–5 questions the graph is uniquely positioned to answer.
+- **Confidence tags** — every inferred relationship is marked `EXTRACTED`, `INFERRED`, or `AMBIGUOUS`. You always know what was found vs guessed.
-**Aider, OpenClaw, Factory Droid, Trae, and Hermes** write the same rules to `AGENTS.md` in your project root and copy the skill to the platform's global skill directory. These platforms don't support tool hooks, so AGENTS.md is the always-on mechanism.
+---
-**Kiro IDE/CLI** writes the skill to `.kiro/skills/graphify/SKILL.md` (invoked via `/graphify`) and a steering file to `.kiro/steering/graphify.md` with `inclusion: always` — Kiro injects this into every conversation automatically, no hook needed.
+## What files it handles
-**Google Antigravity** writes `.agents/rules/graphify.md` (always-on rules) and `.agents/workflows/graphify.md` (registers `/graphify` as a slash command). No hook equivalent exists in Antigravity — rules are the always-on mechanism.
+| Type | Extensions |
+|------|-----------|
+| Code (25 languages) | `.py .ts .js .jsx .tsx .go .rs .java .c .cpp .rb .cs .kt .scala .php .swift .lua .zig .ps1 .ex .exs .m .jl .vue .svelte .sql` |
+| Docs | `.md .mdx .html .txt .rst .yaml .yml` |
+| Office | `.docx .xlsx` (requires `pip install graphifyy[office]`) |
+| PDFs | `.pdf` |
+| Images | `.png .jpg .webp .gif` |
+| Video / Audio | `.mp4 .mov .mp3 .wav` and more (requires `pip install graphifyy[video]`) |
+| YouTube / URLs | any video URL (requires `pip install graphifyy[video]`) |
-**GitHub Copilot CLI** copies the skill to `~/.copilot/skills/graphify/SKILL.md`. Run `graphify copilot install` to set it up.
+Code is extracted locally with no API calls (AST via tree-sitter). Everything else goes through your AI assistant's model API.
-**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`).
+## Common commands
-**Always-on vs explicit trigger — what's the difference?**
+```bash
+/graphify . # build graph for current folder
+/graphify ./docs --update # re-extract only changed files
+/graphify . --cluster-only # rerun clustering without re-extracting
+/graphify . --no-viz # skip the HTML, just the report + JSON
+/graphify . --wiki # build a markdown wiki from the graph
-The always-on hook surfaces `GRAPH_REPORT.md` — a one-page summary of god nodes, communities, and surprising connections. Your assistant reads this before searching files, so it navigates by structure instead of keyword matching. That covers most everyday questions.
+/graphify query "what connects auth to the database?"
+/graphify path "UserService" "DatabasePool"
+/graphify explain "RateLimiter"
-`/graphify query`, `/graphify path`, and `/graphify explain` go deeper: they traverse the raw `graph.json` hop by hop, trace exact paths between nodes, and surface edge-level detail (relation type, confidence score, source location). Use them when you want a specific question answered from the graph rather than a general orientation.
+/graphify add https://arxiv.org/abs/1706.03762 # fetch a paper and add it
+/graphify add # transcribe and add a video
-Think of it this way: the always-on hook gives your assistant a map. The `/graphify` commands let it navigate the map precisely.
+graphify hook install # auto-rebuild on git commit
+graphify merge-graphs a.json b.json # combine two graphs
+```
-### Team workflows
+See the [full command reference](#full-command-reference) below.
-`graphify-out/` is designed to be committed to git so every teammate starts with a fresh map.
+---
-**Recommended `.gitignore` additions:**
-```
-# keep graph outputs, skip heavy/local-only files
-graphify-out/cache/ # optional: commit for shared extraction speed, skip to keep repo small
-graphify-out/manifest.json # mtime-based, invalid after git clone — always gitignore this
-graphify-out/cost.json # local token tracking, not useful to share
-```
-
-**Shared setup:**
-1. One person runs `/graphify .` to build the initial graph and commits `graphify-out/`.
-2. Everyone else pulls — their assistant reads `GRAPH_REPORT.md` immediately with no extra steps.
-3. Install the post-commit hook (`graphify hook install`) so the graph rebuilds automatically after code changes — no LLM calls needed for code-only updates.
-4. For doc/paper changes, whoever edits the files runs `/graphify --update` to refresh semantic nodes.
+## Ignoring files
-**Excluding paths** — create `.graphifyignore` in your project root (same syntax as `.gitignore`). Files matching those patterns are skipped during detection and extraction.
+Create a `.graphifyignore` in your project root — same syntax as `.gitignore`, including `!` negation:
```
-# .graphifyignore example
-AGENTS.md # graphify install files — don't extract your own instructions as knowledge
-CLAUDE.md
-GEMINI.md
-.gemini/
-.opencode/
-docs/translations/ # generated content you don't want in the graph
-```
+# .graphifyignore
+node_modules/
+dist/
+*.generated.py
-## Using `graph.json` with an LLM
+# only index src/, ignore everything else
+*
+!src/
+!src/**
+```
-`graph.json` is not meant to be pasted into a prompt all at once. The useful
-workflow is:
+---
-1. Start with `graphify-out/GRAPH_REPORT.md` for the high-level overview.
-2. Use `graphify query` to pull a smaller subgraph for the specific question
- you want to answer.
-3. Give that focused output to your assistant instead of dumping the full raw
- corpus.
+## Team setup
-For example, after running graphify on a project:
+`graphify-out/` is meant to be committed to git so everyone on the team starts with a map.
-```bash
-graphify query "show the auth flow" --graph graphify-out/graph.json
-graphify query "what connects DigestAuth to Response?" --graph graphify-out/graph.json
+**Recommended `.gitignore` additions:**
+```
+graphify-out/manifest.json # mtime-based, breaks after git clone
+graphify-out/cost.json # local only
+# graphify-out/cache/ # optional: commit for speed, skip to keep repo small
```
-The output includes node labels, edge types, confidence tags, source files, and
-source locations. That makes it a good intermediate context block for an LLM:
+**Workflow:**
+1. One person runs `/graphify .` and commits `graphify-out/`.
+2. Everyone pulls — their assistant reads the graph immediately.
+3. Run `graphify hook install` to auto-rebuild after each commit (AST only, no API cost).
+4. When docs or papers change, run `/graphify --update` to refresh those nodes.
-```text
-Use this graph query output to answer the question. Prefer the graph structure
-over guessing, and cite the source files when possible.
-```
+---
-If your assistant supports tool calling or MCP, use the graph directly instead
-of pasting text. graphify can expose `graph.json` as an MCP server:
+## Using the graph directly
```bash
+# query the graph from the terminal
+graphify query "show the auth flow"
+graphify query "what connects DigestAuth to Response?" --graph graphify-out/graph.json
+
+# expose the graph as an MCP server (for repeated tool-call access)
python -m graphify.serve graphify-out/graph.json
```
-That gives the assistant structured graph access for repeated queries such as
-`query_graph`, `get_node`, `get_neighbors`, and `shortest_path`.
+The MCP server gives your assistant structured access: `query_graph`, `get_node`, `get_neighbors`, `shortest_path`.
-> **WSL / Linux note:** Ubuntu ships `python3`, not `python`. Install into a project venv to avoid PEP 668 conflicts, and use the full venv path in your `.mcp.json`:
+> **WSL / Linux note:** Ubuntu ships `python3`, not `python`. Use a venv to avoid conflicts:
> ```bash
> python3 -m venv .venv && .venv/bin/pip install "graphifyy[mcp]"
> ```
-> ```json
-> { "mcpServers": { "graphify": { "type": "stdio", "command": ".venv/bin/python3", "args": ["-m", "graphify.serve", "graphify-out/graph.json"] } } }
-> ```
-> Also note: the PyPI package is `graphifyy` (double-y) — `pip install graphify` installs an unrelated package.
-
-Manual install (curl)
+---
-```bash
-mkdir -p ~/.claude/skills/graphify
-curl -fsSL https://raw.githubusercontent.com/safishamsi/graphify/v4/graphify/skill.md \
- > ~/.claude/skills/graphify/SKILL.md
-```
+## Privacy
-Add to `~/.claude/CLAUDE.md`:
+- **Code files** — processed locally via tree-sitter. Nothing leaves your machine.
+- **Video / audio** — transcribed locally with faster-whisper. Nothing leaves your machine.
+- **Docs, PDFs, images** — sent to your AI assistant's model API (Anthropic, OpenAI, etc.) using your own API key.
+- No telemetry, no usage tracking, no analytics.
-```
-- **graphify** (`~/.claude/skills/graphify/SKILL.md`) - any input to knowledge graph. Trigger: `/graphify`
-When the user types `/graphify`, invoke the Skill tool with `skill: "graphify"` before doing anything else.
-```
+---
-
-
-## Usage
+## Full command reference
```
/graphify # run on current directory
/graphify ./raw # run on a specific folder
-/graphify ./raw --mode deep # more aggressive INFERRED edge extraction
-/graphify ./raw --update # re-extract only changed files, merge into existing graph
-/graphify ./raw --directed # build directed graph (preserves edge direction: source→target)
-/graphify ./raw --cluster-only # rerun clustering on existing graph, no re-extraction
-/graphify ./raw --no-viz # skip HTML, just produce report + JSON
-/graphify ./raw --obsidian # also generate Obsidian vault (opt-in)
-/graphify ./raw --obsidian --obsidian-dir ~/vaults/myproject # write vault to a specific directory
-
-/graphify add https://arxiv.org/abs/1706.03762 # fetch a paper, save, update graph
-/graphify add https://x.com/karpathy/status/... # fetch a tweet
-/graphify add # download audio, transcribe, add to graph
-/graphify add https://... --author "Name" # tag the original author
-/graphify add https://... --contributor "Name" # tag who added it to the corpus
-
-/graphify query "what connects attention to the optimizer?"
-/graphify query "what connects attention to the optimizer?" --dfs # trace a specific path
-/graphify query "what connects attention to the optimizer?" --budget 1500 # cap at N tokens
-/graphify path "DigestAuth" "Response"
-/graphify explain "SwinTransformer"
-
-/graphify ./raw --watch # auto-sync graph as files change (code: instant, docs: notifies you)
-/graphify ./raw --wiki # build agent-crawlable wiki (index.md + article per community)
+/graphify ./raw --mode deep # more aggressive relationship extraction
+/graphify ./raw --update # re-extract only changed files
+/graphify ./raw --directed # preserve edge direction
+/graphify ./raw --cluster-only # rerun clustering on existing graph
+/graphify ./raw --no-viz # skip HTML visualization
+/graphify ./raw --obsidian # generate Obsidian vault
+/graphify ./raw --wiki # build agent-crawlable markdown wiki
/graphify ./raw --svg # export graph.svg
-/graphify ./raw --graphml # export graph.graphml (Gephi, yEd)
+/graphify ./raw --graphml # export for Gephi / yEd
/graphify ./raw --neo4j # generate cypher.txt for Neo4j
-/graphify ./raw --neo4j-push bolt://localhost:7687 # push directly to a running Neo4j instance
+/graphify ./raw --neo4j-push bolt://localhost:7687
+/graphify ./raw --watch # auto-sync as files change
/graphify ./raw --mcp # start MCP stdio server
-# git hooks - platform-agnostic, rebuild graph on commit and branch switch
-graphify hook install
-graphify hook uninstall
-graphify hook status
-
-# always-on assistant instructions - platform-specific
-graphify claude install # CLAUDE.md + PreToolUse hook (Claude Code)
-graphify claude uninstall
-graphify codex install # AGENTS.md + PreToolUse hook in .codex/hooks.json (Codex)
-graphify opencode install # AGENTS.md + tool.execute.before plugin (OpenCode)
-graphify cursor install # .cursor/rules/graphify.mdc (Cursor)
-graphify cursor uninstall
-graphify gemini install # GEMINI.md + BeforeTool hook (Gemini CLI)
-graphify gemini uninstall
-graphify copilot install # skill file (GitHub Copilot CLI)
-graphify copilot uninstall
-graphify aider install # AGENTS.md (Aider)
-graphify aider uninstall
-graphify claw install # AGENTS.md (OpenClaw)
-graphify droid install # AGENTS.md (Factory Droid)
-graphify trae install # AGENTS.md (Trae)
-graphify trae uninstall
-graphify trae-cn install # AGENTS.md (Trae CN)
-graphify trae-cn uninstall
-graphify hermes install # AGENTS.md + ~/.hermes/skills/ (Hermes)
-graphify hermes uninstall
-graphify kiro install # .kiro/skills/ + .kiro/steering/graphify.md (Kiro IDE/CLI)
-graphify kiro uninstall
-graphify antigravity install # .agents/rules + .agents/workflows (Google Antigravity)
-graphify antigravity uninstall
-
-# query and navigate the graph directly from the terminal (no AI assistant needed)
-graphify query "what connects attention to the optimizer?"
-graphify query "show the auth flow" --dfs
-graphify query "what is CfgNode?" --budget 500
-graphify query "..." --graph path/to/graph.json
-graphify path "DigestAuth" "Response" # shortest path between two nodes
-graphify explain "SwinTransformer" # plain-language explanation of a node
-
-# add content and update the graph from the terminal
-graphify add https://arxiv.org/abs/1706.03762 # fetch paper, save to ./raw, update graph
-graphify add https://... --author "Name" --contributor "Name"
-
-# 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)
-graphify update ./src # re-extract code files, no LLM needed
-graphify cluster-only ./my-project # rerun clustering on existing graph.json
-```
-
-Works with any mix of file types:
-
-| Type | Extensions | Extraction |
-|------|-----------|------------|
-| Code | `.py .ts .js .jsx .tsx .mjs .go .rs .java .c .cpp .rb .cs .kt .scala .php .swift .lua .zig .ps1 .ex .exs .m .mm .jl .vue .svelte` | AST via tree-sitter + call-graph (cross-file for all languages) + Java extends/implements + docstring/comment rationale |
-| Docs | `.md .mdx .html .txt .rst` | Concepts + relationships + design rationale via Claude |
-| Office | `.docx .xlsx` | Converted to markdown then extracted via Claude (requires `pip install graphifyy[office]`) |
-| Papers | `.pdf` | Citation mining + concept extraction |
-| Images | `.png .jpg .webp .gif` | Claude vision - screenshots, diagrams, any language |
-| Video / Audio | `.mp4 .mov .mkv .webm .avi .m4v .mp3 .wav .m4a .ogg` | Transcribed locally with faster-whisper, transcript fed into Claude extraction (requires `pip install graphifyy[video]`) |
-| YouTube / URLs | any video URL | Audio downloaded via yt-dlp, then same Whisper pipeline (requires `pip install graphifyy[video]`) |
-
-## Video and audio corpus
-
-Drop video or audio files into your corpus folder alongside your code and docs — graphify picks them up automatically:
-
-```bash
-pip install 'graphifyy[video]' # one-time setup
-/graphify ./my-corpus # transcribes any video/audio files it finds
-```
-
-Add a YouTube video (or any public video URL) directly:
-
-```bash
+/graphify add https://arxiv.org/abs/1706.03762
/graphify add
-```
+/graphify add https://... --author "Name" --contributor "Name"
-yt-dlp downloads audio-only (fast, small), Whisper transcribes it locally, and the transcript is fed into the same extraction pipeline as your other docs. Transcripts are cached in `graphify-out/transcripts/` so re-runs skip already-transcribed files.
+/graphify query "what connects attention to the optimizer?"
+/graphify query "..." --dfs --budget 1500
+/graphify path "DigestAuth" "Response"
+/graphify explain "SwinTransformer"
-For better accuracy on technical content, use a larger model:
+graphify hook install # post-commit + post-checkout hooks
+graphify hook uninstall
+graphify hook status
-```bash
-/graphify ./my-corpus --whisper-model medium
+graphify claude install / uninstall
+graphify codex install / uninstall
+graphify opencode install
+graphify cursor install / uninstall
+graphify gemini install / uninstall
+graphify copilot install / uninstall
+graphify aider install / uninstall
+graphify claw install / uninstall
+graphify droid install / uninstall
+graphify trae install / uninstall
+graphify trae-cn install / uninstall
+graphify hermes install / uninstall
+graphify kiro install / uninstall
+graphify antigravity install / uninstall
+
+graphify clone https://github.com/karpathy/nanoGPT
+graphify merge-graphs a.json b.json --out merged.json
+graphify watch ./src
+graphify check-update ./src
+graphify update ./src
+graphify cluster-only ./my-project
```
-Audio never leaves your machine. All transcription runs locally.
-
-## What you get
-
-**God nodes** - highest-degree concepts (what everything connects through)
-
-**Surprising connections** - ranked by composite score. Code-paper edges rank higher than code-code. Each result includes a plain-English why.
-
-**Suggested questions** - 4-5 questions the graph is uniquely positioned to answer
-
-**The "why"** - docstrings, inline comments (`# NOTE:`, `# IMPORTANT:`, `# HACK:`, `# WHY:`), and design rationale from docs are extracted as `rationale_for` nodes. Not just what the code does - why it was written that way.
-
-**Confidence scores** - every INFERRED edge has a `confidence_score` (0.0-1.0). You know not just what was guessed but how confident the model was. EXTRACTED edges are always 1.0.
-
-**Semantic similarity edges** - cross-file conceptual links with no structural connection. Two functions solving the same problem without calling each other, a class in code and a concept in a paper describing the same algorithm.
-
-**Hyperedges** - group relationships connecting 3+ nodes that pairwise edges can't express. All classes implementing a shared protocol, all functions in an auth flow, all concepts from a paper section forming one idea.
+---
-**Token benchmark** - printed automatically after every run. On a mixed corpus (Karpathy repos + papers + images): **71.5x** fewer tokens per query vs reading raw files. The first run extracts and builds the graph (this costs tokens). Every subsequent query reads the compact graph instead of raw files — that's where the savings compound. The SHA256 cache means re-runs only re-process changed files.
+## Learn more
-**Auto-sync** (`--watch`) - run in a background terminal and the graph updates itself as your codebase changes. Code file saves trigger an instant rebuild (AST only, no LLM). Doc/image changes notify you to run `--update` for the LLM re-pass.
+- [How it works](docs/how-it-works.md) — the extraction pipeline, community detection, confidence scoring, benchmarks
+- [ARCHITECTURE.md](ARCHITECTURE.md) — module breakdown, how to add a language
+- [Optional integrations](docs/docker-mcp-sqlite.md) — Docker MCP Toolkit + SQLite
-**Git hooks** (`graphify hook install`) - installs post-commit and post-checkout hooks. Graph rebuilds automatically after every commit and every branch switch. If a rebuild fails, the hook exits with a non-zero code so git surfaces the error instead of silently continuing. No background process needed.
-
-**Wiki** (`--wiki`) - Wikipedia-style markdown articles per community and god node, with an `index.md` entry point. Point any agent at `index.md` and it can navigate the knowledge base by reading files instead of parsing JSON.
-
-## Worked examples
-
-| Corpus | Files | Reduction | Output |
-|--------|-------|-----------|--------|
-| Karpathy repos + 5 papers + 4 images | 52 | **71.5x** | [`worked/karpathy-repos/`](worked/karpathy-repos/) |
-| graphify source + Transformer paper | 4 | **5.4x** | [`worked/mixed-corpus/`](worked/mixed-corpus/) |
-| httpx (synthetic Python library) | 6 | ~1x | [`worked/httpx/`](worked/httpx/) |
-
-Token reduction scales with corpus size. 6 files fits in a context window anyway, so graph value there is structural clarity, not compression. At 52 files (code + papers + images) you get 71x+. Each `worked/` folder has the raw input files and the actual output (`GRAPH_REPORT.md`, `graph.json`) so you can run it yourself and verify the numbers.
-
-## Privacy
-
-graphify sends file contents to your AI coding assistant's underlying model API for semantic extraction of docs, papers, and images — Anthropic (Claude Code), OpenAI (Codex), or whichever provider your platform uses. Code files are processed locally via tree-sitter AST — no file contents leave your machine for code. Video and audio files are transcribed locally with faster-whisper — audio never leaves your machine. No telemetry, usage tracking, or analytics of any kind. The only network calls are to your platform's model API during extraction, using your own API key.
-
-## Tech stack
-
-NetworkX + Leiden (graspologic) + tree-sitter + vis.js. Semantic extraction via Claude (Claude Code), GPT-4 (Codex), or whichever model your platform runs. Video transcription via faster-whisper + yt-dlp (optional, `pip install graphifyy[video]`). No Neo4j required, no server, runs entirely locally.
+---
## Built on graphify — Penpax
-[**Penpax**](https://safishamsi.github.io/penpax.ai) is the enterprise layer on top of graphify. Where graphify turns a folder of files into a knowledge graph, Penpax applies the same graph to your entire working life — continuously.
-
-| | graphify | Penpax |
-|---|---|---|
-| Input | A folder of files | Browser history, meetings, emails, files, code — everything |
-| Runs | On demand | Continuously in the background |
-| Scope | A project | Your entire working life |
-| Query | CLI / MCP / AI skill | Natural language, always on |
-| Privacy | Local by default | Fully on-device, no cloud |
-
-Built for lawyers, consultants, executives, doctors, researchers — anyone whose work lives across hundreds of conversations and documents they can never fully reconstruct.
+[**Penpax**](https://graphifylabs.ai) is the always-on layer built on top of graphify — it applies the same graph approach to your entire working life: meetings, browser history, emails, files, and code, updating continuously in the background.
-**Free trial launching soon.** [Join the waitlist →](https://safishamsi.github.io/penpax.ai)
+Built for people whose work lives across hundreds of conversations and documents they can never fully reconstruct. No cloud, fully on-device.
-## What we are building next
+**Free trial launching soon.** [Join the waitlist →](https://graphifylabs.ai)
-graphify is the graph layer. Penpax is the always-on layer on top of it — an on-device digital twin that connects your meetings, browser history, files, emails, and code into one continuously updating knowledge graph. No cloud, no training on your data. [Join the waitlist.](https://safishamsi.github.io/penpax.ai)
+---
Contributing
-**Worked examples** are the most trust-building contribution. Run `/graphify` on a real corpus, save output to `worked/{slug}/`, write an honest `review.md` evaluating what the graph got right and wrong, submit a PR.
+**Worked examples** are the most useful contribution. Run `/graphify` on a real corpus, save the output to `worked/{slug}/`, write an honest `review.md` covering what the graph got right and wrong, and open a PR.
-**Extraction bugs** - open an issue with the input file, the cache entry (`graphify-out/cache/`), and what was missed or invented.
+**Extraction bugs** — open an issue with the input file, the cache entry (`graphify-out/cache/`), and what was missed or wrong.
See [ARCHITECTURE.md](ARCHITECTURE.md) for module responsibilities and how to add a language.
diff --git a/docs/docker-mcp-sqlite.md b/docs/docker-mcp-sqlite.md
new file mode 100644
index 000000000..6cdf52b06
--- /dev/null
+++ b/docs/docker-mcp-sqlite.md
@@ -0,0 +1,138 @@
+# Docker MCP Toolkit + SQLite MCP server
+
+A reproducible runbook for installing the **SQLite MCP server** into the
+[Docker MCP Toolkit](https://docs.docker.com/desktop/features/mcp/) so any
+connected MCP client (Claude Code, Claude Desktop, Cursor, VS Code, etc.) gains
+six SQLite tools: `read_query`, `write_query`, `create_table`, `list_tables`,
+`describe_table`, and `append_insight`.
+
+This document is *not* required to use graphify — it lives here as a known-good
+recipe for users who want a lightweight, persistent SQL workspace exposed to
+their AI clients alongside graphify's knowledge-graph tools.
+
+## Why SQLite (and not `sqlite-mcp-server`)
+At time of writing the catalog ships two SQLite MCP images:
+
+| Catalog name | Image | Status |
+| ------------------- | ---------------------- | ------ |
+| `SQLite` | `mcp/sqlite` | Marked "Archived" in catalog metadata, but **boots and serves correctly** |
+| `sqlite-mcp-server` | `mcp/sqlite-mcp-server`| **Broken**: entrypoint `/app/.venv/bin/mcp-server-sqlite` does not exist in the published layer |
+
+Use `SQLite` (`mcp/sqlite`) until the newer image is fixed upstream.
+
+## Prerequisites
+- Docker Desktop running and healthy
+ - `docker info` returns a `Server Version`
+ - Public socket present at `/var/run/docker.sock` (or its symlink to
+ `~/.docker/run/docker.sock`)
+- Docker MCP Toolkit CLI plugin (`docker mcp`)
+ - Bundled with recent Docker Desktop releases; `docker mcp --version` should
+ print a version string
+
+## Install
+```bash
+# Add the working SQLite server to the default MCP profile
+docker mcp profile server add default \
+ --server catalog://mcp/docker-mcp-catalog/SQLite
+
+# Pre-pull the image so the first tool call is fast
+docker pull mcp/sqlite:latest
+```
+
+Verify the profile now contains both `fetch` (built-in) and `SQLite`:
+```bash
+docker mcp profile show default | grep -E '^[[:space:]]+name:'
+```
+
+Expected output:
+```
+ name: fetch
+ name: SQLite
+```
+
+The Docker MCP gateway should now expose 6 additional tools:
+```bash
+docker mcp tools count
+# → 15 tools (was 9 before adding SQLite)
+```
+
+## Smoke test
+The CLI can call MCP tools directly (each call boots a fresh gateway, ~5s
+overhead per call):
+```bash
+docker mcp tools call list_tables
+docker mcp tools call create_table \
+ query='CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, body TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP)'
+docker mcp tools call write_query \
+ query="INSERT INTO notes(body) VALUES ('first row'), ('second row')"
+docker mcp tools call read_query \
+ query='SELECT * FROM notes ORDER BY id'
+docker mcp tools call describe_table table_name=notes
+docker mcp tools call append_insight insight='3 rows inserted; aggregates work.'
+```
+
+`read_query` should return the inserted rows with timestamps.
+
+## Storage layout
+Database file lives in a Docker named volume `mcp-sqlite`, mounted at `/mcp`
+inside containers:
+```
+mcp-sqlite (named volume) → /mcp/db.sqlite
+```
+
+Inspect from the host:
+```bash
+docker volume inspect mcp-sqlite
+docker run --rm -v mcp-sqlite:/mcp:ro alpine ls -la /mcp
+docker run --rm -v mcp-sqlite:/mcp:ro keinos/sqlite3 \
+ sqlite3 /mcp/db.sqlite '.schema'
+```
+
+The volume persists across `docker run --rm` invocations of the SQLite MCP
+container, so writes from one MCP tool call are visible to the next.
+
+## Wiring into MCP clients
+Connect once per client; the gateway exposes every server in the active profile:
+```bash
+docker mcp client connect claude-code # already connected for many users
+docker mcp client connect cursor
+docker mcp client connect vscode
+docker mcp client connect claude-desktop
+# Supported: claude-code, claude-desktop, cline, codex, continue, crush,
+# cursor, gemini, goose, gordon, kiro, lmstudio, opencode, sema4,
+# vscode, zed
+```
+
+Verify wiring:
+```bash
+docker mcp client ls
+```
+
+## Uninstall / reset
+```bash
+# Remove server from the profile
+docker mcp profile server remove default SQLite
+
+# Drop the database volume (irreversible)
+docker volume rm mcp-sqlite
+
+# Remove the image
+docker rmi mcp/sqlite:latest
+```
+
+## Troubleshooting
+- **`starting client: calling "initialize": EOF`** — the requested server
+ failed its MCP handshake. Run the image directly to see the error:
+ ```bash
+ printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0.0"}}}\n' \
+ | docker run --rm -i -v mcp-sqlite:/mcp --db-path /mcp/db.sqlite
+ ```
+ Common causes: missing entrypoint binary in the image (the
+ `sqlite-mcp-server` failure mode) or missing required env/secrets.
+- **`cannot use --enable-all-servers with --servers flag`** — these gateway
+ args are mutually exclusive; pick one.
+- **No new tools appear in `docker mcp tools count` after install** — the
+ gateway may be running with `dynamic-tools` enabled, exposing only meta-tools
+ (`mcp-add`, `mcp-find`, …) until a profile is activated mid-session. Either
+ invoke `docker mcp tools` (which spins up an ephemeral gateway against the
+ default profile) or call `mcp-activate-profile` from inside an MCP session.
diff --git a/docs/how-it-works.md b/docs/how-it-works.md
new file mode 100644
index 000000000..c79ab5cfc
--- /dev/null
+++ b/docs/how-it-works.md
@@ -0,0 +1,90 @@
+# How graphify works
+
+## The three passes
+
+graphify processes your files in three passes:
+
+**Pass 1 — Code structure (free, no API calls)**
+Tree-sitter parses your code files and extracts classes, functions, imports, call graphs, and inline comments. This runs locally with no LLM involved. 25 languages supported. SQL files get special treatment: tables, views, foreign keys, and JOIN relationships are extracted deterministically.
+
+**Pass 2 — Video and audio (local, no API calls)**
+Video and audio files are transcribed with faster-whisper. To focus the transcript on your domain, the transcription prompt is seeded with your top god nodes (the most-connected concepts in your code graph so far). Transcripts are cached — re-runs skip already-processed files.
+
+**Pass 3 — Docs, papers, images (Claude subagents, costs tokens)**
+Claude runs in parallel over markdown, PDFs, images, and transcripts. Each subagent reads a batch of files and outputs a JSON fragment: nodes, edges, and any group relationships. The fragments are merged into a single graph.
+
+---
+
+## How community detection works
+
+Communities are found using the [Leiden algorithm](https://www.nature.com/articles/s41598-019-41695-z) — a graph-clustering method that groups nodes by edge density. Nodes with many connections between them end up in the same community.
+
+**No embeddings needed.** The semantic similarity edges that Claude extracts (`semantically_similar_to`) are already in the graph, so they influence community shape directly. The graph structure is the similarity signal — there's no separate embedding step or vector database.
+
+---
+
+## Confidence tagging
+
+Every relationship is tagged with one of three labels:
+
+| Tag | Meaning |
+|-----|---------|
+| `EXTRACTED` | Found directly in the source (e.g. a function call, an import) |
+| `INFERRED` | A reasonable inference Claude made, with a `confidence_score` (0.0–1.0) |
+| `AMBIGUOUS` | Uncertain — flagged in the report for manual review |
+
+EXTRACTED edges always have confidence 1.0. INFERRED edges use a discrete rubric:
+- **0.95** — near-certain (explicit cross-file reference, one plausible target)
+- **0.85** — strong evidence (naming + context align)
+- **0.75** — reasonable (contextual but not explicit)
+- **0.65** — weak (naming similarity only)
+- **0.55** — speculative
+
+---
+
+## Token benchmark
+
+The first run extracts and builds the graph — this costs tokens. Every subsequent query reads the compact graph instead of raw files. That's where the savings compound.
+
+On a mixed corpus (Karpathy repos + 5 papers + 4 images, 52 files): **71.5x fewer tokens per query** vs reading the raw files directly.
+
+| Corpus | Files | Reduction |
+|--------|-------|-----------|
+| Karpathy repos + papers + images | 52 | **71.5x** |
+| graphify source + Transformer paper | 4 | **5.4x** |
+| httpx (synthetic Python library) | 6 | ~1x |
+
+Token reduction scales with corpus size. Six files already fits in a context window — the graph value there is structural clarity, not compression. At 52 files the savings compound quickly.
+
+Each `worked/` folder in the repo has the raw input files and actual output (`GRAPH_REPORT.md`, `graph.json`) so you can run it yourself and verify.
+
+---
+
+## Parallel extraction
+
+Code files are extracted in parallel using `ProcessPoolExecutor` — bypasses Python's GIL for genuine multiprocessing. Doc/paper/image batches are dispatched as parallel Claude subagents. On a corpus of 84 code files, parallel AST extraction runs in about 1.66x less time than sequential.
+
+---
+
+## SHA256 cache
+
+Every extracted file is fingerprinted by content hash. Re-runs skip unchanged files entirely — only new or modified files go through extraction again. The cache lives in `graphify-out/cache/`.
+
+---
+
+## The graph format
+
+The output `graph.json` uses NetworkX's node-link format. Each node has:
+- `id` — stable identifier
+- `label` — human-readable name
+- `file_type` — `code`, `document`, `paper`, `image`, `rationale`
+- `source_file` — where it came from
+
+Each edge has:
+- `source`, `target` — node IDs
+- `relation` — verb phrase (e.g. `calls`, `imports`, `implements`, `semantically_similar_to`)
+- `confidence` — `EXTRACTED`, `INFERRED`, or `AMBIGUOUS`
+- `confidence_score` — float (INFERRED only)
+- `source_file` — where the relationship was found
+
+Hyperedges (group relationships connecting 3+ nodes) live in `G.graph["hyperedges"]`.
diff --git a/graphify/__main__.py b/graphify/__main__.py
index 9abaf5a55..2724d8c6d 100644
--- a/graphify/__main__.py
+++ b/graphify/__main__.py
@@ -1,6 +1,7 @@
"""graphify CLI - `graphify install` sets up the Claude Code skill."""
from __future__ import annotations
import json
+import os
import platform
import re
import shutil
@@ -13,6 +14,10 @@
except Exception:
__version__ = "unknown"
+# Output directory — override with GRAPHIFY_OUT env var for worktrees or shared-output setups.
+# Accepts a relative name ("graphify-out-feature") or an absolute path ("/shared/graphify-out").
+_GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out")
+
def _check_skill_version(skill_dst: Path) -> None:
"""Warn if the installed skill is from an older graphify version."""
@@ -37,14 +42,22 @@ def _refresh_all_version_stamps() -> None:
vf.write_text(__version__, encoding="utf-8")
_SETTINGS_HOOK = {
- "matcher": "Glob|Grep",
+ # Claude Code v2.1.117+ removed dedicated Grep/Glob tools; searches now go through Bash.
+ # We match on Bash and inspect the command string to avoid firing on every shell call.
+ "matcher": "Bash",
"hooks": [
{
"type": "command",
"command": (
- "[ -f graphify-out/graph.json ] && "
- r"""echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files."}}' """
- "|| true"
+ "CMD=$(python3 -c \""
+ "import json,sys; d=json.load(sys.stdin); "
+ "print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); "
+ "case \"$CMD\" in "
+ r"*grep*|*rg\ *|*ripgrep*|*find\ *|*fd\ *|*ack\ *|*ag\ *) "
+ " [ -f graphify-out/graph.json ] && "
+ r""" echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files."}}' """
+ " || true ;; "
+ "esac"
),
}
],
@@ -115,9 +128,14 @@ def _refresh_all_version_stamps() -> None:
"skill_dst": Path(".kiro") / "skills" / "graphify" / "SKILL.md",
"claude_md": False,
},
+ "pi": {
+ "skill_file": "skill-pi.md",
+ "skill_dst": Path(".pi") / "agent" / "skills" / "graphify" / "SKILL.md",
+ "claude_md": False,
+ },
"antigravity": {
"skill_file": "skill.md",
- "skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md",
+ "skill_dst": Path(".agent") / "skills" / "graphify" / "SKILL.md",
"claude_md": False,
},
"windows": {
@@ -148,7 +166,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")
@@ -233,9 +256,13 @@ def install(platform: str = "claude") -> None:
{
"type": "command",
"command": (
- "[ -f graphify-out/graph.json ] && "
- r"""echo '{"decision":"allow","additionalContext":"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files."}' """
- r"""|| echo '{"decision":"allow"}'"""
+ 'python -c "'
+ "import sys,pathlib,json;"
+ "e=pathlib.Path('graphify-out/graph.json').exists();"
+ "d={'decision':'allow'};"
+ "e and d.update({'additionalContext':'graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files.'});"
+ "sys.stdout.write(json.dumps(d))"
+ '"'
),
}
],
@@ -347,9 +374,17 @@ def gemini_uninstall(project_dir: Path | None = None) -> None:
_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.
+For any question about this repo's architecture, structure, components, or how to add/modify/find
+code, your **first tool call must be** to read `graphify-out/GRAPH_REPORT.md` (if it exists).
+
+Triggers: "how do I…", "where is…", "what does … do", "add/modify a ",
+"explain the architecture", or anything that depends on how files or classes relate.
+
+After reading the report (and `graphify-out/wiki/index.md` for deep questions), answer from the
+graph. Only read source files when (a) modifying/debugging specific code, (b) the graph lacks
+the needed detail, or (c) the graph is missing or stale.
+
+Type `/graphify` in Copilot Chat to build or update the graph.
"""
@@ -412,8 +447,8 @@ def vscode_uninstall(project_dir: Path | None = None) -> None:
print(f" {instructions} -> deleted (was empty after removal)")
-_ANTIGRAVITY_RULES_PATH = Path(".agents") / "rules" / "graphify.md"
-_ANTIGRAVITY_WORKFLOW_PATH = Path(".agents") / "workflows" / "graphify.md"
+_ANTIGRAVITY_RULES_PATH = Path(".agent") / "rules" / "graphify.md"
+_ANTIGRAVITY_WORKFLOW_PATH = Path(".agent") / "workflows" / "graphify.md"
_ANTIGRAVITY_RULES = """\
## graphify
@@ -429,12 +464,14 @@ def vscode_uninstall(project_dir: Path | None = None) -> None:
"""
_ANTIGRAVITY_WORKFLOW = """\
+---
+name: graphify
+description: Turn any folder of files into a navigable knowledge graph
+---
+
# Workflow: graphify
-**Command:** /graphify
-**Description:** Turn any folder of files into a navigable knowledge graph
-## Steps
-Follow the graphify skill installed at ~/.agents/skills/graphify/SKILL.md to run the full pipeline.
+Follow the graphify skill installed at ~/.agent/skills/graphify/SKILL.md to run the full pipeline.
If no path argument is given, use `.` (current directory).
"""
@@ -504,8 +541,8 @@ def _kiro_uninstall(project_dir: Path) -> None:
def _antigravity_install(project_dir: Path) -> None:
- """Install graphify for Google Antigravity: skill + .agents/rules + .agents/workflows."""
- # 1. Copy skill file to ~/.agents/skills/graphify/SKILL.md
+ """Install graphify for Google Antigravity: skill + .agent/rules + .agent/workflows."""
+ # 1. Copy skill file to ~/.agent/skills/graphify/SKILL.md
install(platform="antigravity")
# 1.5. Inject YAML frontmatter for native Antigravity tool discovery
@@ -516,20 +553,30 @@ def _antigravity_install(project_dir: Path) -> None:
frontmatter = "---\nname: graphify-manager\ndescription: Rebuild the code graph or perform manual CLI queries when MCP server is offline.\n---\n\n"
skill_dst.write_text(frontmatter + content, encoding="utf-8")
- # 2. Write .agents/rules/graphify.md
+ # 2. Write .agent/rules/graphify.md
rules_path = project_dir / _ANTIGRAVITY_RULES_PATH
rules_path.parent.mkdir(parents=True, exist_ok=True)
if rules_path.exists():
- print(f"graphify rule already exists at {rules_path} (no change)")
+ existing = rules_path.read_text(encoding="utf-8")
+ if _ANTIGRAVITY_RULES.strip() != existing.strip():
+ rules_path.write_text(_ANTIGRAVITY_RULES, encoding="utf-8")
+ print(f"graphify rule updated at {rules_path.resolve()}")
+ else:
+ print(f"graphify rule already up to date at {rules_path.resolve()}")
else:
rules_path.write_text(_ANTIGRAVITY_RULES, encoding="utf-8")
print(f"graphify rule written to {rules_path.resolve()}")
- # 3. Write .agents/workflows/graphify.md
+ # 3. Write .agent/workflows/graphify.md
wf_path = project_dir / _ANTIGRAVITY_WORKFLOW_PATH
wf_path.parent.mkdir(parents=True, exist_ok=True)
if wf_path.exists():
- print(f"graphify workflow already exists at {wf_path} (no change)")
+ existing = wf_path.read_text(encoding="utf-8")
+ if _ANTIGRAVITY_WORKFLOW.strip() != existing.strip():
+ wf_path.write_text(_ANTIGRAVITY_WORKFLOW, encoding="utf-8")
+ print(f"graphify workflow updated at {wf_path.resolve()}")
+ else:
+ print(f"graphify workflow already up to date at {wf_path.resolve()}")
else:
wf_path.write_text(_ANTIGRAVITY_WORKFLOW, encoding="utf-8")
print(f"graphify workflow written to {wf_path.resolve()}")
@@ -704,11 +751,11 @@ def _uninstall_opencode_plugin(project_dir: Path) -> None:
"hooks": [
{
"type": "command",
- "command": (
- "[ -f graphify-out/graph.json ] && "
- r"""echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files."}}' """
- "|| true"
- ),
+ # Use the graphify CLI itself so the hook is shell-agnostic:
+ # no [ -f ] bash syntax, no python3 vs python Conda issue,
+ # no JSON escaping inside PowerShell strings. Works on
+ # Windows (PowerShell/cmd.exe), macOS, and Linux.
+ "command": "graphify hook-check",
}
],
}
@@ -717,6 +764,26 @@ def _uninstall_opencode_plugin(project_dir: Path) -> None:
}
+def _resolve_graphify_exe() -> str:
+ """Return the absolute path to the graphify executable.
+
+ Falls back to bare 'graphify' if resolution fails. Using an absolute path
+ ensures the hook works in environments where the venv Scripts/ directory is
+ not on PATH (e.g. VS Code Codex extension on Windows).
+ """
+ import shutil
+ found = shutil.which("graphify")
+ if found:
+ return found
+ # Derive from sys.executable: same Scripts/ (Windows) or bin/ (Unix) dir
+ scripts_dir = Path(sys.executable).parent
+ for name in ("graphify.exe", "graphify"):
+ candidate = scripts_dir / name
+ if candidate.exists():
+ return str(candidate)
+ return "graphify"
+
+
def _install_codex_hook(project_dir: Path) -> None:
"""Add graphify PreToolUse hook to .codex/hooks.json."""
hooks_path = project_dir / ".codex" / "hooks.json"
@@ -730,11 +797,23 @@ def _install_codex_hook(project_dir: Path) -> None:
else:
existing = {}
+ graphify_exe = _resolve_graphify_exe()
+ hook_entry = {
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [{"type": "command", "command": f"{graphify_exe} hook-check"}],
+ }
+ ]
+ }
+ }
+
pre_tool = existing.setdefault("hooks", {}).setdefault("PreToolUse", [])
existing["hooks"]["PreToolUse"] = [h for h in pre_tool if "graphify" not in str(h)]
- existing["hooks"]["PreToolUse"].extend(_CODEX_HOOK["hooks"]["PreToolUse"])
+ existing["hooks"]["PreToolUse"].extend(hook_entry["hooks"]["PreToolUse"])
hooks_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
- print(f" .codex/hooks.json -> PreToolUse hook registered")
+ print(f" .codex/hooks.json -> PreToolUse hook registered ({graphify_exe} hook-check)")
def _uninstall_codex_hook(project_dir: Path) -> None:
@@ -852,7 +931,7 @@ def _install_claude_hook(project_dir: Path) -> None:
hooks = settings.setdefault("hooks", {})
pre_tool = hooks.setdefault("PreToolUse", [])
- hooks["PreToolUse"] = [h for h in pre_tool if not (h.get("matcher") == "Glob|Grep" and "graphify" in str(h))]
+ hooks["PreToolUse"] = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash") and "graphify" in str(h))]
hooks["PreToolUse"].append(_SETTINGS_HOOK)
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
print(f" .claude/settings.json -> PreToolUse hook registered")
@@ -868,7 +947,7 @@ def _uninstall_claude_hook(project_dir: Path) -> None:
except json.JSONDecodeError:
return
pre_tool = settings.get("hooks", {}).get("PreToolUse", [])
- filtered = [h for h in pre_tool if not (h.get("matcher") == "Glob|Grep" and "graphify" in str(h))]
+ filtered = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash") and "graphify" in str(h))]
if len(filtered) == len(pre_tool):
return
settings["hooks"]["PreToolUse"] = filtered
@@ -906,6 +985,63 @@ 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 branch and branch.startswith("-"):
+ print(f"error: invalid branch name: {branch!r}", file=sys.stderr)
+ sys.exit(1)
+
+ 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).
@@ -918,20 +1054,29 @@ def main() -> None:
print("Usage: graphify ")
print()
print("Commands:")
- print(" install [--platform P] copy skill to platform config dir (claude|windows|codex|opencode|aider|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro)")
+ print(" install [--platform P] copy skill to platform config dir (claude|windows|codex|opencode|aider|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|pi)")
print(" path \"A\" \"B\" shortest path between two nodes in graph.json")
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(" 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")
print(" --author \"Name\" tag the author of the content")
print(" --contributor \"Name\" tag who added it to the corpus")
print(" --dir target directory (default: ./raw)")
print(" watch watch a folder and rebuild the graph on code changes")
print(" update re-extract code files and update the graph (no LLM needed)")
+ print(" --force overwrite graph.json even if the rebuild has fewer nodes")
+ print(" (also: GRAPHIFY_FORCE=1 env var; use after refactors that delete code)")
print(" cluster-only rerun clustering on an existing graph.json and regenerate report")
+ print(" --no-viz skip graph.html generation (useful for >5000 node graphs / CI)")
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")
@@ -941,6 +1086,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")
@@ -969,12 +1121,14 @@ def main() -> None:
print(" trae uninstall remove graphify section from AGENTS.md")
print(" trae-cn install write graphify section to AGENTS.md (Trae CN)")
print(" trae-cn uninstall remove graphify section from AGENTS.md")
- print(" antigravity install write .agents/rules + .agents/workflows + skill (Google Antigravity)")
- print(" antigravity uninstall remove .agents/rules, .agents/workflows, and skill")
+ print(" antigravity install write .agent/rules + .agent/workflows + skill (Google Antigravity)")
+ print(" antigravity uninstall remove .agent/rules, .agent/workflows, and skill")
print(" hermes install write skill to ~/.hermes/skills/graphify/ (Hermes)")
print(" hermes uninstall remove skill from ~/.hermes/skills/graphify/")
print(" kiro install write skill to .kiro/skills/graphify/ + steering file (Kiro IDE/CLI)")
print(" kiro uninstall remove skill + steering file")
+ print(" pi install write skill to ~/.pi/agent/skills/graphify/ (Pi coding agent)")
+ print(" pi uninstall remove skill from ~/.pi/agent/skills/graphify/")
print()
return
@@ -1062,6 +1216,26 @@ def main() -> None:
else:
print("Usage: graphify kiro [install|uninstall]", file=sys.stderr)
sys.exit(1)
+ elif cmd == "pi":
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
+ if subcmd == "install":
+ install("pi")
+ elif subcmd == "uninstall":
+ skill_dst = Path.home() / ".pi" / "agent" / "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
+ else:
+ print("Usage: graphify pi [install|uninstall]", file=sys.stderr)
+ sys.exit(1)
elif cmd in ("aider", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"):
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
@@ -1096,15 +1270,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):
@@ -1122,6 +1297,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:
@@ -1144,14 +1325,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
@@ -1307,6 +1490,9 @@ def main() -> None:
elif cmd == "cluster-only":
watch_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(".")
+ no_viz = "--no-viz" in sys.argv
+ _min_cs_arg = next((a for a in sys.argv if a.startswith("--min-community-size=")), None)
+ min_community_size = int(_min_cs_arg.split("=")[1]) if _min_cs_arg else 3
graph_json = watch_path / "graphify-out" / "graph.json"
if not graph_json.exists():
print(f"error: no graph found at {graph_json} — run /graphify first", file=sys.stderr)
@@ -1319,7 +1505,8 @@ def main() -> None:
from graphify.export import to_json, to_html
print("Loading existing graph...")
_raw = json.loads(graph_json.read_text(encoding="utf-8"))
- G = build_from_json(_raw)
+ _directed = bool(_raw.get("directed", False))
+ G = build_from_json(_raw, directed=_directed)
print(f"Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
print("Re-clustering...")
communities = cluster(G)
@@ -1331,27 +1518,65 @@ def main() -> None:
tokens = {"input": 0, "output": 0}
report = generate(G, communities, cohesion, labels, gods, surprises,
{"warning": "cluster-only mode — file stats not available"},
- tokens, str(watch_path), suggested_questions=questions)
+ tokens, str(watch_path), suggested_questions=questions,
+ min_community_size=min_community_size)
out = watch_path / "graphify-out"
(out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8")
to_json(G, communities, str(out / "graph.json"))
- to_html(G, communities, str(out / "graph.html"), community_labels=labels or None)
- print(f"Done — {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.")
+
+ # Mirror watch.py pattern: gate to_html so core outputs (graph.json +
+ # GRAPH_REPORT.md) always land. Honor --no-viz explicitly; otherwise
+ # fall back to ValueError handling so an oversized graph doesn't crash
+ # the CLI mid-write and leave a stale graph.html on disk.
+ html_target = out / "graph.html"
+ if no_viz:
+ if html_target.exists():
+ html_target.unlink()
+ print(f"Done — {len(communities)} communities. GRAPH_REPORT.md and graph.json updated (--no-viz; graph.html removed).")
+ else:
+ try:
+ to_html(G, communities, str(html_target), community_labels=labels or None)
+ print(f"Done — {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.")
+ except ValueError as viz_err:
+ if html_target.exists():
+ html_target.unlink()
+ print(f"Skipped graph.html: {viz_err}")
+ print(f"Done — {len(communities)} communities. GRAPH_REPORT.md and graph.json updated.")
elif cmd == "update":
- watch_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(".")
+ force = os.environ.get("GRAPHIFY_FORCE", "").lower() in ("1", "true", "yes")
+ argv = list(sys.argv)
+ if "--force" in argv[2:]:
+ force = True
+ argv = [a for a in argv if a != "--force"]
+ if len(argv) > 2:
+ watch_path = Path(argv[2])
+ else:
+ # Try to recover the scan root saved by the last full build
+ saved = Path(_GRAPHIFY_OUT) / ".graphify_root"
+ if saved.exists():
+ watch_path = Path(saved.read_text(encoding="utf-8").strip())
+ else:
+ watch_path = Path(".")
if not watch_path.exists():
print(f"error: path not found: {watch_path}", file=sys.stderr)
sys.exit(1)
from graphify.watch import _rebuild_code
print(f"Re-extracting code files in {watch_path} (no LLM needed)...")
- ok = _rebuild_code(watch_path)
+ ok = _rebuild_code(watch_path, force=force)
if ok:
print("Code graph updated. For doc/paper/image changes run /graphify --update in your AI assistant.")
+ if not os.environ.get("MOONSHOT_API_KEY") and not os.environ.get("GRAPHIFY_NO_TIPS"):
+ print("Tip: set MOONSHOT_API_KEY to use Kimi K2.6 for semantic extraction — 3x cheaper, richer graphs. pip install 'graphifyy[kimi]'")
else:
print("Nothing to update or rebuild failed — check output above.", file=sys.stderr)
sys.exit(1)
+ elif cmd == "hook-check":
+ # Codex Desktop rejects hookSpecificOutput.additionalContext on PreToolUse.
+ # Keep this as a cross-platform no-op so installed hooks never break Bash
+ # tool calls. Graph guidance reaches the agent via AGENTS.md / skill instead.
+ sys.exit(0)
elif cmd == "check-update":
if len(sys.argv) < 3:
print("Usage: graphify check-update ", file=sys.stderr)
@@ -1359,6 +1584,122 @@ 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:]
+ 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)
+ 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/analyze.py b/graphify/analyze.py
index 4f480c5bc..de07cc132 100644
--- a/graphify/analyze.py
+++ b/graphify/analyze.py
@@ -1,7 +1,34 @@
"""Graph analysis: god nodes (most connected), surprising connections (cross-community), suggested questions."""
from __future__ import annotations
+from pathlib import Path
import networkx as nx
+# Language families — extensions sharing a runtime can legitimately call each other
+_LANG_FAMILY: dict[str, str] = {
+ **{e: "python" for e in (".py", ".pyw")},
+ **{e: "js" for e in (".js", ".jsx", ".mjs", ".ejs", ".ts", ".tsx", ".vue", ".svelte")},
+ **{e: "go" for e in (".go",)},
+ **{e: "rust" for e in (".rs",)},
+ **{e: "jvm" for e in (".java", ".kt", ".kts", ".scala")},
+ **{e: "c" for e in (".c", ".h", ".cpp", ".cc", ".cxx", ".hpp")},
+ **{e: "ruby" for e in (".rb",)},
+ **{e: "swift" for e in (".swift",)},
+ **{e: "dotnet" for e in (".cs",)},
+ **{e: "php" for e in (".php",)},
+ **{e: "r" for e in (".r",)},
+}
+
+
+def _cross_language(src_a: str, src_b: str) -> bool:
+ """Return True if two source files belong to different language families."""
+ ext_a = Path(src_a).suffix.lower()
+ ext_b = Path(src_b).suffix.lower()
+ fam_a = _LANG_FAMILY.get(ext_a)
+ fam_b = _LANG_FAMILY.get(ext_b)
+ if fam_a is None or fam_b is None:
+ return False
+ return fam_a != fam_b
+
def _node_community_map(communities: dict[int, list[str]]) -> dict[str, int]:
"""Invert communities dict: node_id -> community_id."""
@@ -143,7 +170,13 @@ def _surprise_score(
# 1. Confidence weight - uncertain connections are more noteworthy
conf = data.get("confidence", "EXTRACTED")
+ relation = data.get("relation", "")
conf_bonus = {"AMBIGUOUS": 3, "INFERRED": 2, "EXTRACTED": 1}.get(conf, 1)
+
+ # Cross-language INFERRED calls are likely resolver pollution, not real surprises
+ if conf == "INFERRED" and relation == "calls" and _cross_language(u_source, v_source):
+ conf_bonus = 0 # downgrade: don't promote likely false positives
+
score += conf_bonus
if conf in ("AMBIGUOUS", "INFERRED"):
reasons.append(f"{conf.lower()} connection - not explicitly stated in source")
diff --git a/graphify/build.py b/graphify/build.py
index 19b5b1867..373662712 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
@@ -37,6 +39,12 @@ def _normalize_id(s: str) -> str:
return cleaned.strip("_").lower()
+def _norm_source_file(p: str | None) -> str | None:
+ """Normalize path separators to forward slashes so Windows backslash paths
+ and POSIX paths from semantic subagents resolve to the same node identity."""
+ return p.replace("\\", "/") if p else p
+
+
def build_from_json(extraction: dict, *, directed: bool = False) -> nx.Graph:
"""Build a NetworkX graph from an extraction dict.
@@ -50,6 +58,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)
@@ -59,6 +79,8 @@ def build_from_json(extraction: dict, *, directed: bool = False) -> nx.Graph:
print(f"[graphify] Extraction warning ({len(real_errors)} issues): {real_errors[0]}", file=sys.stderr)
G: nx.Graph = nx.DiGraph() if directed else nx.Graph()
for node in extraction.get("nodes", []):
+ if "source_file" in node:
+ node["source_file"] = _norm_source_file(node["source_file"])
G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
node_set = set(G.nodes())
# Normalized ID map: lets edges survive when the LLM generates IDs with
@@ -81,6 +103,8 @@ def build_from_json(extraction: dict, *, directed: bool = False) -> nx.Graph:
if src not in node_set or tgt not in node_set:
continue # skip edges to external/stdlib nodes - expected, not an error
attrs = {k: v for k, v in edge.items() if k not in ("source", "target")}
+ if "source_file" in attrs:
+ attrs["source_file"] = _norm_source_file(attrs["source_file"])
# Preserve original edge direction - undirected graphs lose it otherwise,
# causing display functions to show edges backwards.
attrs["_src"] = src
@@ -111,3 +135,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/cache.py b/graphify/cache.py
index af153d93d..ef7582d03 100644
--- a/graphify/cache.py
+++ b/graphify/cache.py
@@ -4,8 +4,14 @@
import hashlib
import json
import os
+import tempfile
from pathlib import Path
+# Output directory name — override with GRAPHIFY_OUT env var for worktrees or
+# shared-output setups. Accepts a relative name ("graphify-out-feature") or an
+# absolute path ("/shared/graphify-out").
+_GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out")
+
def _body_content(content: bytes) -> bytes:
"""Strip YAML frontmatter from Markdown content, returning only the body."""
@@ -17,6 +23,17 @@ def _body_content(content: bytes) -> bytes:
return content
+def _normalize_path(path: Path) -> Path:
+ """Normalize path for consistent cache keys across Windows path spellings."""
+ import sys
+ if sys.platform != "win32":
+ return path
+ s = str(path)
+ if s.startswith("\\\\?\\"):
+ s = s[4:] # strip extended-length prefix \\?\
+ return Path(os.path.normcase(s))
+
+
def file_hash(path: Path, root: Path = Path(".")) -> str:
"""SHA256 of file contents + path relative to root.
@@ -27,7 +44,8 @@ def file_hash(path: Path, root: Path = Path(".")) -> str:
For Markdown files (.md), only the body below the YAML frontmatter is hashed,
so metadata-only changes (e.g. reviewed, status, tags) do not invalidate the cache.
"""
- p = Path(path)
+ p = _normalize_path(Path(path))
+ root = _normalize_path(Path(root))
if not p.is_file():
raise IsADirectoryError(f"file_hash requires a file, got: {p}")
raw = p.read_bytes()
@@ -43,37 +61,54 @@ def file_hash(path: Path, root: Path = Path(".")) -> str:
return h.hexdigest()
-def cache_dir(root: Path = Path(".")) -> Path:
- """Returns graphify-out/cache/ - creates it if needed."""
- d = Path(root).resolve() / "graphify-out" / "cache"
+def cache_dir(root: Path = Path("."), kind: str = "ast") -> Path:
+ """Returns graphify-out/cache/{kind}/ - creates it if needed.
+
+ kind is "ast" or "semantic". Separate subdirectories prevent semantic cache
+ entries from overwriting AST cache entries for the same source_file (#582).
+ """
+ _out = Path(_GRAPHIFY_OUT)
+ base = _out if _out.is_absolute() else Path(root).resolve() / _out
+ d = base / "cache" / kind
d.mkdir(parents=True, exist_ok=True)
return d
-def load_cached(path: Path, root: Path = Path(".")) -> dict | None:
+def load_cached(path: Path, root: Path = Path("."), kind: str = "ast") -> dict | None:
"""Return cached extraction for this file if hash matches, else None.
Cache key: SHA256 of file contents.
- Cache value: stored as graphify-out/cache/{hash}.json
+ Cache value: stored as graphify-out/cache/{kind}/{hash}.json
+
+ For kind="ast", also checks the legacy flat cache/ directory so users
+ upgrading from pre-0.5.3 don't lose their existing AST cache entries.
Returns None if no cache entry or file has changed.
"""
try:
h = file_hash(path, root)
except OSError:
return None
- entry = cache_dir(root) / f"{h}.json"
- if not entry.exists():
- return None
- try:
- return json.loads(entry.read_text(encoding="utf-8"))
- except (json.JSONDecodeError, OSError):
- return None
-
-
-def save_cached(path: Path, result: dict, root: Path = Path(".")) -> None:
+ entry = cache_dir(root, kind) / f"{h}.json"
+ if entry.exists():
+ try:
+ return json.loads(entry.read_text(encoding="utf-8"))
+ except (json.JSONDecodeError, OSError):
+ return None
+ # Migration fallback: check legacy flat cache/ dir for AST entries
+ if kind == "ast":
+ legacy = Path(root).resolve() / _GRAPHIFY_OUT / "cache" / f"{h}.json"
+ if legacy.exists():
+ try:
+ return json.loads(legacy.read_text(encoding="utf-8"))
+ except (json.JSONDecodeError, OSError):
+ return None
+ return None
+
+
+def save_cached(path: Path, result: dict, root: Path = Path("."), kind: str = "ast") -> None:
"""Save extraction result for this file.
- Stores as graphify-out/cache/{hash}.json where hash = SHA256 of current file contents.
+ Stores as graphify-out/cache/{kind}/{hash}.json where hash = SHA256 of current file contents.
result should be a dict with 'nodes' and 'edges' lists.
No-ops if `path` is not a regular file. Subagent-produced semantic fragments
@@ -84,34 +119,60 @@ def save_cached(path: Path, result: dict, root: Path = Path(".")) -> None:
if not p.is_file():
return
h = file_hash(p, root)
- entry = cache_dir(root) / f"{h}.json"
- tmp = entry.with_suffix(".tmp")
+ target_dir = cache_dir(root, kind)
+ entry = target_dir / f"{h}.json"
+ fd, tmp_path = tempfile.mkstemp(dir=target_dir, prefix=f"{h}.", suffix=".tmp")
try:
- tmp.write_text(json.dumps(result), encoding="utf-8")
+ os.write(fd, json.dumps(result).encode())
+ os.close(fd)
try:
- os.replace(tmp, entry)
+ os.replace(tmp_path, entry)
except PermissionError:
# Windows: os.replace can fail with WinError 5 if the target is
# briefly locked. Fall back to copy-then-delete.
import shutil
- shutil.copy2(tmp, entry)
- tmp.unlink(missing_ok=True)
+ shutil.copy2(tmp_path, entry)
+ os.unlink(tmp_path)
except Exception:
- tmp.unlink(missing_ok=True)
+ try:
+ os.close(fd)
+ except OSError:
+ pass
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
raise
def cached_files(root: Path = Path(".")) -> set[str]:
- """Return set of file paths that have a valid cache entry (hash still matches)."""
- d = cache_dir(root)
- return {p.stem for p in d.glob("*.json")}
+ """Return set of file hashes that have a valid cache entry (any kind)."""
+ base = Path(root).resolve() / _GRAPHIFY_OUT / "cache"
+ hashes: set[str] = set()
+ # Legacy flat entries
+ if base.is_dir():
+ hashes.update(p.stem for p in base.glob("*.json"))
+ # Namespaced entries
+ for kind in ("ast", "semantic"):
+ d = base / kind
+ if d.is_dir():
+ hashes.update(p.stem for p in d.glob("*.json"))
+ return hashes
def clear_cache(root: Path = Path(".")) -> None:
- """Delete all graphify-out/cache/*.json files."""
- d = cache_dir(root)
- for f in d.glob("*.json"):
- f.unlink()
+ """Delete all cache entries (ast/, semantic/, and legacy flat entries)."""
+ base = Path(root).resolve() / _GRAPHIFY_OUT / "cache"
+ # Legacy flat entries
+ if base.is_dir():
+ for f in base.glob("*.json"):
+ f.unlink()
+ # Namespaced entries
+ for kind in ("ast", "semantic"):
+ d = base / kind
+ if d.is_dir():
+ for f in d.glob("*.json"):
+ f.unlink()
def check_semantic_cache(
@@ -129,7 +190,7 @@ def check_semantic_cache(
uncached: list[str] = []
for fpath in files:
- result = load_cached(Path(fpath), root)
+ result = load_cached(Path(fpath), root, kind="semantic")
if result is not None:
cached_nodes.extend(result.get("nodes", []))
cached_edges.extend(result.get("edges", []))
@@ -148,7 +209,9 @@ def save_semantic_cache(
) -> int:
"""Save semantic extraction results to cache, keyed by source_file.
- Groups nodes and edges by source_file, then saves one cache entry per file.
+ Groups nodes and edges by source_file, then saves one cache entry per file
+ under cache/semantic/ (separate from AST entries in cache/ast/) to prevent
+ hash-key collisions (#582).
Returns the number of files cached.
"""
from collections import defaultdict
@@ -173,6 +236,6 @@ def save_semantic_cache(
if not p.is_absolute():
p = Path(root) / p
if p.is_file():
- save_cached(p, result, root)
+ save_cached(p, result, root, kind="semantic")
saved += 1
return saved
diff --git a/graphify/cluster.py b/graphify/cluster.py
index 9227c0d13..b5555a85b 100644
--- a/graphify/cluster.py
+++ b/graphify/cluster.py
@@ -54,6 +54,8 @@ def _partition(G: nx.Graph) -> dict[str, int]:
_MAX_COMMUNITY_FRACTION = 0.25 # communities larger than 25% of graph get split
_MIN_SPLIT_SIZE = 10 # only split if community has at least this many nodes
+_COHESION_SPLIT_THRESHOLD = 0.05 # re-split communities with cohesion below this
+_COHESION_SPLIT_MIN_SIZE = 50 # only cohesion-split if community has at least this many nodes
def cluster(G: nx.Graph) -> dict[int, list[str]]:
@@ -99,6 +101,17 @@ def cluster(G: nx.Graph) -> dict[int, list[str]]:
else:
final_communities.append(nodes)
+ # Second pass: re-split low-cohesion communities caused by doc-hub nodes
+ # that bridge otherwise-unrelated subsystems (e.g. CLAUDE.md connected to everything).
+ second_pass: list[list[str]] = []
+ for nodes in final_communities:
+ if len(nodes) >= _COHESION_SPLIT_MIN_SIZE and cohesion_score(G, nodes) < _COHESION_SPLIT_THRESHOLD:
+ splits = _split_community(G, nodes)
+ second_pass.extend(splits if len(splits) > 1 else [nodes])
+ else:
+ second_pass.append(nodes)
+ final_communities = second_pass
+
# Re-index by size descending for deterministic ordering
final_communities.sort(key=len, reverse=True)
return {i: sorted(nodes) for i, nodes in enumerate(final_communities)}
diff --git a/graphify/detect.py b/graphify/detect.py
index 392625877..e1df09de1 100644
--- a/graphify/detect.py
+++ b/graphify/detect.py
@@ -18,8 +18,8 @@ class FileType(str, Enum):
_MANIFEST_PATH = "graphify-out/manifest.json"
-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', '.mdx', '.txt', '.rst', '.html'}
+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', '.sql', '.r'}
+DOC_EXTENSIONS = {'.md', '.mdx', '.txt', '.rst', '.html', '.yaml', '.yml'}
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
OFFICE_EXTENSIONS = {'.docx', '.xlsx'}
@@ -78,11 +78,42 @@ def _looks_like_paper(path: Path) -> bool:
_ASSET_DIR_MARKERS = {".imageset", ".xcassets", ".appiconset", ".colorset", ".launchimage"}
+_SHEBANG_CODE_INTERPRETERS = {
+ "python", "python3", "python2",
+ "ruby", "perl", "node", "nodejs",
+ "bash", "sh", "dash", "zsh", "fish", "ksh", "tcsh",
+ "lua", "php", "julia", "Rscript",
+}
+
+
+def _shebang_file_type(path: Path) -> FileType | None:
+ """Peek at the first line of an extensionless file for a shebang."""
+ try:
+ with path.open("rb") as f:
+ first = f.read(128)
+ if not first.startswith(b"#!"):
+ return None
+ line = first.split(b"\n")[0].decode(errors="replace")
+ parts = line[2:].strip().split()
+ if not parts:
+ return None
+ interp = parts[0].split("/")[-1] # /usr/bin/env → env
+ if interp == "env" and len(parts) > 1:
+ interp = parts[1].split("/")[-1]
+ if interp in _SHEBANG_CODE_INTERPRETERS:
+ return FileType.CODE
+ except OSError:
+ pass
+ return None
+
+
def classify_file(path: Path) -> FileType | None:
# Compound extensions must be checked before simple suffix lookup
if path.name.lower().endswith(".blade.php"):
return FileType.CODE
ext = path.suffix.lower()
+ if not ext:
+ return _shebang_file_type(path)
if ext in CODE_EXTENSIONS:
return FileType.CODE
if ext in PAPER_EXTENSIONS:
@@ -169,7 +200,6 @@ def xlsx_to_markdown(path: Path) -> str:
ws = wb[sheet_name]
rows = []
for row in ws.iter_rows(values_only=True):
- # Skip entirely empty rows
if all(cell is None for cell in row):
continue
rows.append([str(cell) if cell is not None else "" for cell in row])
@@ -190,6 +220,91 @@ def xlsx_to_markdown(path: Path) -> str:
return ""
+def xlsx_extract_structure(path: Path) -> dict:
+ """Extract structural nodes (sheets, named tables, column headers) from an .xlsx file.
+
+ Returns a nodes/edges dict compatible with the graphify extract pipeline.
+ Used in addition to xlsx_to_markdown so Claude sees both structure and content.
+ """
+ def _nid(*parts: str) -> str:
+ return re.sub(r"[^a-z0-9_]", "_", "_".join(p.lower() for p in parts).strip("_"))
+
+ try:
+ import openpyxl
+ except ImportError:
+ return {"nodes": [], "edges": []}
+
+ try:
+ wb = openpyxl.load_workbook(str(path), read_only=False, data_only=True)
+ except Exception:
+ return {"nodes": [], "edges": []}
+
+ stem = _re.sub(r"[^a-z0-9]", "_", path.stem.lower())
+ str_path = str(path)
+ file_nid = _nid(str_path)
+ nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "document",
+ "source_file": str_path, "source_location": None}]
+ edges: list[dict] = []
+ seen: set[str] = {file_nid}
+
+ def _add(nid: str, label: str) -> None:
+ if nid not in seen:
+ seen.add(nid)
+ nodes.append({"id": nid, "label": label, "file_type": "document",
+ "source_file": str_path, "source_location": None})
+
+ def _edge(src: str, tgt: str, relation: str) -> None:
+ edges.append({"source": src, "target": tgt, "relation": relation,
+ "confidence": "EXTRACTED", "source_file": str_path,
+ "source_location": None, "weight": 1.0})
+
+ for sheet_name in wb.sheetnames:
+ ws = wb[sheet_name]
+ sheet_nid = _nid(stem, sheet_name)
+ _add(sheet_nid, f"{sheet_name} (sheet)")
+ _edge(file_nid, sheet_nid, "contains")
+
+ # Named Excel Tables (ListObjects)
+ if hasattr(ws, "tables"):
+ for tbl in ws.tables.values():
+ tbl_nid = _nid(stem, sheet_name, tbl.name)
+ _add(tbl_nid, tbl.name)
+ _edge(sheet_nid, tbl_nid, "contains")
+ # Column headers from table header row
+ ref = tbl.ref # e.g. "A1:D10"
+ if ref:
+ try:
+ from openpyxl.utils import range_boundaries
+ min_col, min_row, max_col, _ = range_boundaries(ref)
+ header_row = list(ws.iter_rows(min_row=min_row, max_row=min_row,
+ min_col=min_col, max_col=max_col,
+ values_only=True))
+ if header_row:
+ for col_name in header_row[0]:
+ if col_name:
+ col_nid = _nid(stem, tbl.name, str(col_name))
+ _add(col_nid, str(col_name))
+ _edge(tbl_nid, col_nid, "contains")
+ except Exception:
+ pass
+ else:
+ # Fallback: first non-empty row as column headers
+ for row in ws.iter_rows(max_row=1, values_only=True):
+ for cell in row:
+ if cell:
+ col_nid = _nid(stem, sheet_name, str(cell))
+ _add(col_nid, str(cell))
+ _edge(sheet_nid, col_nid, "contains")
+ break
+
+ try:
+ wb.close()
+ except Exception:
+ pass
+
+ return {"nodes": nodes, "edges": edges}
+
+
def convert_office_file(path: Path, out_dir: Path) -> Path | None:
"""Convert a .docx or .xlsx to a markdown sidecar in out_dir.
@@ -241,6 +356,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
@@ -262,38 +378,87 @@ def _is_noise_dir(part: str) -> bool:
return False
-def _load_graphifyignore(root: Path) -> list[tuple[Path, str]]:
- """Read .graphifyignore from root **and ancestor directories**.
+_VCS_MARKERS = (".git", ".hg", ".svn", "_darcs", ".fossil")
+
- Returns a list of (anchor_dir, pattern) pairs. Each pattern is matched
- against paths relative to both the scan root and the anchor_dir where
- the .graphifyignore file was found — so patterns written relative to a
- parent directory still work when graphify is run on a subfolder.
+def _parse_gitignore_line(raw: str) -> str:
+ """Parse one raw line from a .graphifyignore file per gitignore spec.
- Walks upward from *root* towards the filesystem root, stopping at a
- ``.git`` boundary. Lines starting with # are comments; blank lines ignored.
+ - Strip newline chars
+ - Strip inline comments (whitespace + # suffix), but only when # is
+ preceded by whitespace — so path#with#hash.py is preserved
+ - Unescape \\# to literal #
+ - Remove trailing spaces unless escaped with backslash
+ - Strip leading whitespace
+ - Return empty string for blank lines and full-line comments
"""
- patterns: list[tuple[Path, str]] = []
- current = root.resolve()
+ line = raw.rstrip("\n\r")
+ line = line.lstrip()
+ if not line or line.startswith("#"):
+ return ""
+ # Strip inline comments: require whitespace before # (gitignore extension)
+ line = re.sub(r"\s+#+[^\\].*$", "", line)
+ # Unescape \# → literal #
+ line = line.replace("\\#", "#")
+ # Remove unescaped trailing spaces (per gitignore spec)
+ line = re.sub(r"(? Path | None:
+ """Walk upward from start; return the first directory containing a VCS marker."""
+ current = start.resolve()
+ home = Path.home()
while True:
- ignore_file = current / ".graphifyignore"
- if ignore_file.exists():
- for line in ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines():
- line = line.strip()
- if line and not line.startswith("#"):
- patterns.append((current, line))
- # Stop climbing once we've processed the git repo root
- if (current / ".git").exists():
- break
+ if any((current / m).exists() for m in _VCS_MARKERS):
+ return current
parent = current.parent
- if parent == current:
- break # filesystem root
+ if parent == current or current == home:
+ return None
current = parent
+
+
+def _load_graphifyignore(root: Path) -> list[tuple[Path, str]]:
+ """Read .graphifyignore files and return (anchor_dir, pattern) pairs.
+
+ Patterns are returned outer-first so that inner (closer) rules are
+ appended last and win via last-match-wins semantics — matching gitignore
+ behavior exactly.
+
+ Walk ceiling: the nearest VCS root if inside a repo, otherwise the scan
+ root itself (hermetic — no leakage across unrelated sibling projects).
+ """
+ root = root.resolve()
+ ceiling = _find_vcs_root(root) or root
+
+ # Collect ancestor dirs from ceiling down to root (outer → inner)
+ dirs: list[Path] = []
+ current = root
+ while True:
+ dirs.append(current)
+ if current == ceiling:
+ break
+ current = current.parent
+ dirs.reverse() # ceiling first, scan root last
+
+ patterns: list[tuple[Path, str]] = []
+ for d in dirs:
+ ignore_file = d / ".graphifyignore"
+ if ignore_file.exists():
+ for raw in ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines():
+ line = _parse_gitignore_line(raw)
+ if line:
+ patterns.append((d, line))
return patterns
def _is_ignored(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool:
- """Return True if path matches any .graphifyignore pattern."""
+ """Return True if the path should be ignored per .graphifyignore patterns.
+
+ Uses gitignore last-match-wins semantics: all patterns are evaluated in
+ order; the final matching pattern determines the result. Negation patterns
+ (starting with !) un-ignore a previously ignored path.
+ """
if not patterns:
return False
@@ -310,26 +475,146 @@ def _matches(rel: str, p: str) -> bool:
return True
return False
+ result = False
for anchor, pattern in patterns:
- p = pattern.strip("/")
+ negated = pattern.startswith("!")
+ raw = pattern[1:] if negated else pattern
+ anchored = raw.startswith("/")
+ p = raw.strip("/")
if not p:
continue
- # Try path relative to the scan root
- try:
- rel = str(path.relative_to(root)).replace(os.sep, "/")
- if _matches(rel, p):
+
+ matched = False
+ if anchored:
+ try:
+ rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/")
+ matched = _matches(rel_anchor, p)
+ except ValueError:
+ pass
+ else:
+ try:
+ rel = str(path.relative_to(root)).replace(os.sep, "/")
+ matched = _matches(rel, p)
+ except ValueError:
+ pass
+ if not matched and anchor != root:
+ try:
+ rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/")
+ matched = _matches(rel_anchor, p)
+ except ValueError:
+ pass
+
+ if matched:
+ result = not negated # last match wins; ! flips to un-ignore
+ return result
+
+
+def _load_graphifyinclude(root: Path) -> list[tuple[Path, str]]:
+ """Read .graphifyinclude allowlist patterns from root and ancestors.
+
+ Include patterns opt matching hidden files/dirs into traversal. Sensitive
+ files and hard-skipped noise directories are still excluded later.
+ Uses the same VCS-root ceiling logic as _load_graphifyignore.
+ """
+ root = root.resolve()
+ ceiling = _find_vcs_root(root) or root
+
+ dirs: list[Path] = []
+ current = root
+ while True:
+ dirs.append(current)
+ if current == ceiling:
+ break
+ current = current.parent
+ dirs.reverse()
+
+ patterns: list[tuple[Path, str]] = []
+ for d in dirs:
+ include_file = d / ".graphifyinclude"
+ if include_file.exists():
+ for raw in include_file.read_text(encoding="utf-8", errors="ignore").splitlines():
+ line = _parse_gitignore_line(raw)
+ if line:
+ patterns.append((d, line))
+ return patterns
+
+
+def _is_included(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool:
+ """Return True if path matches any .graphifyinclude allowlist pattern."""
+ if not patterns:
+ return False
+
+ def _matches(rel: str, p: str) -> bool:
+ parts = rel.split("/")
+ if fnmatch.fnmatch(rel, p):
+ return True
+ if fnmatch.fnmatch(path.name, p):
+ return True
+ for i, part in enumerate(parts):
+ if fnmatch.fnmatch(part, p):
return True
- except ValueError:
- pass
- # Also try relative to the anchor dir (the .graphifyignore's location),
- # so patterns written at a parent level still fire when running on a subfolder
- if anchor != root:
+ if fnmatch.fnmatch("/".join(parts[:i + 1]), p):
+ return True
+ return False
+
+ for anchor, pattern in patterns:
+ anchored = pattern.startswith("/")
+ p = pattern.strip("/")
+ if not p:
+ continue
+ if anchored:
try:
rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/")
if _matches(rel_anchor, p):
return True
except ValueError:
pass
+ else:
+ try:
+ rel = str(path.relative_to(root)).replace(os.sep, "/")
+ if _matches(rel, p):
+ return True
+ except ValueError:
+ pass
+ if anchor != root:
+ try:
+ rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/")
+ if _matches(rel_anchor, p):
+ return True
+ except ValueError:
+ pass
+ return False
+
+
+def _could_contain_included_path(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool:
+ """Return True if a directory may contain files matched by .graphifyinclude."""
+ if not patterns:
+ return False
+
+ rels: list[str] = []
+ try:
+ rels.append(str(path.relative_to(root)).replace(os.sep, "/"))
+ except ValueError:
+ pass
+ for anchor, _ in patterns:
+ if anchor != root:
+ try:
+ rels.append(str(path.relative_to(anchor)).replace(os.sep, "/"))
+ except ValueError:
+ pass
+
+ for rel in rels:
+ rel = rel.strip("/")
+ if not rel:
+ return True
+ for _, pattern in patterns:
+ p = pattern.strip("/")
+ if not p:
+ continue
+ if p == rel or p.startswith(rel + "/"):
+ return True
+ if fnmatch.fnmatch(rel, p):
+ return True
return False
@@ -346,6 +631,7 @@ def detect(root: Path, *, follow_symlinks: bool = False) -> dict:
skipped_sensitive: list[str] = []
ignore_patterns = _load_graphifyignore(root)
+ include_patterns = _load_graphifyinclude(root)
# Always include graphify-out/memory/ - query results filed back into the graph
memory_dir = root / "graphify-out" / "memory"
@@ -367,12 +653,17 @@ def detect(root: Path, *, follow_symlinks: bool = False) -> dict:
dirnames.clear()
continue
if not in_memory_tree:
- # Prune noise dirs in-place so os.walk never descends into them
+ # Prune noise dirs in-place so os.walk never descends into them.
+ # Hidden dirs are allowed through if they could contain an
+ # explicitly included path (.graphifyinclude allowlist).
+ # When negation patterns (!) exist, skip directory-level ignore
+ # pruning so negated files inside can still be reached.
+ has_negation = any(p.startswith("!") for _, p in ignore_patterns)
dirnames[:] = [
d for d in dirnames
- if not d.startswith(".")
+ if (not d.startswith(".") or _could_contain_included_path(dp / d, root, include_patterns))
and not _is_noise_dir(d)
- and not _is_ignored(dp / d, root, ignore_patterns)
+ and (has_negation or not _is_ignored(dp / d, root, ignore_patterns))
]
for fname in filenames:
if fname in _SKIP_FILES:
@@ -389,8 +680,9 @@ def detect(root: Path, *, follow_symlinks: bool = False) -> dict:
in_memory = memory_dir.exists() and str(p).startswith(str(memory_dir))
if not in_memory:
# Hidden files are already excluded via dir pruning above,
- # but catch hidden files at the root level
- if p.name.startswith("."):
+ # but catch hidden files at the root level. A .graphifyinclude
+ # entry can opt a specific hidden file back in.
+ if p.name.startswith(".") and not _is_included(p, root, include_patterns):
continue
# Skip files inside our own converted/ dir (avoid re-processing sidecars)
if str(p).startswith(str(converted_dir)):
@@ -444,8 +736,21 @@ def detect(root: Path, *, follow_symlinks: bool = False) -> dict:
}
-def load_manifest(manifest_path: str = _MANIFEST_PATH) -> dict[str, float]:
- """Load the file modification time manifest from a previous run."""
+def _md5_file(path: Path) -> str:
+ """MD5 of file contents streamed in 64KB chunks — for change detection only."""
+ import hashlib as _hl
+ h = _hl.md5(usedforsecurity=False)
+ try:
+ with path.open("rb") as f:
+ for chunk in iter(lambda: f.read(65536), b""):
+ h.update(chunk)
+ except OSError:
+ return ""
+ return h.hexdigest()
+
+
+def load_manifest(manifest_path: str = _MANIFEST_PATH) -> dict:
+ """Load the manifest from a previous run. Returns {} on any error."""
try:
return json.loads(Path(manifest_path).read_text(encoding="utf-8"))
except Exception:
@@ -453,12 +758,13 @@ def load_manifest(manifest_path: str = _MANIFEST_PATH) -> dict[str, float]:
def save_manifest(files: dict[str, list[str]], manifest_path: str = _MANIFEST_PATH) -> None:
- """Save current file mtimes so the next --update run can diff against them."""
- manifest: dict[str, float] = {}
+ """Save current file mtimes + content hashes for change detection on --update."""
+ manifest: dict[str, dict] = {}
for file_list in files.values():
for f in file_list:
try:
- manifest[f] = Path(f).stat().st_mtime
+ p = Path(f)
+ manifest[f] = {"mtime": p.stat().st_mtime, "hash": _md5_file(p)}
except OSError:
pass # file deleted between detect() and manifest write - skip it
Path(manifest_path).parent.mkdir(parents=True, exist_ok=True)
@@ -468,8 +774,11 @@ def save_manifest(files: dict[str, list[str]], manifest_path: str = _MANIFEST_PA
def detect_incremental(root: Path, manifest_path: str = _MANIFEST_PATH) -> dict:
"""Like detect(), but returns only new or modified files since the last run.
- Compares current file mtimes against the stored manifest.
- Use for --update mode: re-extract only what changed, merge into existing graph.
+ Fast path: mtime unchanged → unchanged (free, no hash).
+ Slow path: mtime bumped → compare MD5. Same hash = sync tool touched mtime,
+ treat as unchanged. Different hash = actually changed, re-extract.
+
+ Backwards compatible with legacy manifests storing plain float mtime values.
"""
full = detect(root)
manifest = load_manifest(manifest_path)
@@ -487,12 +796,26 @@ def detect_incremental(root: Path, manifest_path: str = _MANIFEST_PATH) -> dict:
for ftype, file_list in full["files"].items():
for f in file_list:
- stored_mtime = manifest.get(f)
+ stored = manifest.get(f)
try:
current_mtime = Path(f).stat().st_mtime
except Exception:
current_mtime = 0
- if stored_mtime is None or current_mtime > stored_mtime:
+
+ # Legacy manifest: plain float value
+ if isinstance(stored, (int, float)):
+ changed = stored is None or current_mtime > stored
+ elif isinstance(stored, dict):
+ stored_mtime = stored.get("mtime")
+ if stored_mtime is None or current_mtime != stored_mtime:
+ # mtime bumped — verify with content hash before re-extracting
+ changed = _md5_file(Path(f)) != stored.get("hash", "")
+ else:
+ changed = False
+ else:
+ changed = True # unknown format, re-extract to be safe
+
+ if changed:
new_files[ftype].append(f)
else:
unchanged_files[ftype].append(f)
diff --git a/graphify/export.py b/graphify/export.py
index 12cafb874..14587addb 100644
--- a/graphify/export.py
+++ b/graphify/export.py
@@ -25,6 +25,22 @@ def _strip_diacritics(text: str) -> str:
MAX_NODES_FOR_VIZ = 5_000
+def _viz_node_limit() -> int:
+ """Return the effective viz node limit, honoring GRAPHIFY_VIZ_NODE_LIMIT env var.
+
+ Falls back to MAX_NODES_FOR_VIZ when the env var is unset, empty, or non-integer.
+ Set to 0 to disable HTML viz unconditionally (useful for CI runners).
+ """
+ import os
+ raw = os.environ.get("GRAPHIFY_VIZ_NODE_LIMIT")
+ if raw is None or not raw.strip():
+ return MAX_NODES_FOR_VIZ
+ try:
+ return int(raw)
+ except ValueError:
+ return MAX_NODES_FOR_VIZ
+
+
def _html_styles() -> str:
return """"""
@@ -240,15 +264,42 @@ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
}});
const hiddenCommunities = new Set();
+
+const selectAllCb = document.getElementById('select-all-cb');
+
+function updateSelectAllState() {{
+ const total = LEGEND.length;
+ const hidden = hiddenCommunities.size;
+ selectAllCb.checked = hidden === 0;
+ selectAllCb.indeterminate = hidden > 0 && hidden < total;
+}}
+
+function toggleAllCommunities(hide) {{
+ document.querySelectorAll('.legend-item').forEach(item => {{
+ hide ? item.classList.add('dimmed') : item.classList.remove('dimmed');
+ }});
+ document.querySelectorAll('.legend-cb').forEach(cb => {{
+ cb.checked = !hide;
+ }});
+ LEGEND.forEach(c => {{
+ if (hide) hiddenCommunities.add(c.cid); else hiddenCommunities.delete(c.cid);
+ }});
+ const updates = RAW_NODES.map(n => ({{ id: n.id, hidden: hide }}));
+ nodesDS.update(updates);
+ updateSelectAllState();
+}}
+
const legendEl = document.getElementById('legend');
LEGEND.forEach(c => {{
const item = document.createElement('div');
item.className = 'legend-item';
- item.innerHTML = `
- ${{c.label}}
- ${{c.count}}`;
- item.onclick = () => {{
- if (hiddenCommunities.has(c.cid)) {{
+ const cb = document.createElement('input');
+ cb.type = 'checkbox';
+ cb.className = 'legend-cb';
+ cb.checked = true;
+ cb.addEventListener('change', (e) => {{
+ e.stopPropagation();
+ if (cb.checked) {{
hiddenCommunities.delete(c.cid);
item.classList.remove('dimmed');
}} else {{
@@ -257,8 +308,18 @@ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
}}
const updates = RAW_NODES
.filter(n => n.community === c.cid)
- .map(n => ({{ id: n.id, hidden: hiddenCommunities.has(c.cid) }}));
+ .map(n => ({{ id: n.id, hidden: !cb.checked }}));
nodesDS.update(updates);
+ updateSelectAllState();
+ }});
+ item.innerHTML = `
+ ${{c.label}}
+ ${{c.count}}`;
+ item.prepend(cb);
+ item.onclick = (e) => {{
+ if (e.target === cb) return;
+ cb.checked = !cb.checked;
+ cb.dispatchEvent(new Event('change'));
}};
legendEl.appendChild(item);
}});
@@ -279,7 +340,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) -> bool:
+ # 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 False
+ 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")
@@ -292,9 +373,19 @@ def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str) ->
if "confidence_score" not in link:
conf = link.get("confidence", "EXTRACTED")
link["confidence_score"] = _CONFIDENCE_SCORE_DEFAULTS.get(conf, 1.0)
+ # Restore original edge direction. Undirected NetworkX storage may
+ # canonicalize endpoint order, flipping `calls` and other directional
+ # edges in graph.json. The build path stashes the true endpoints in
+ # _src/_tgt for exactly this purpose (#563).
+ true_src = link.pop("_src", None)
+ true_tgt = link.pop("_tgt", None)
+ if true_src is not None and true_tgt is not None:
+ link["source"] = true_src
+ link["target"] = true_tgt
data["hyperedges"] = getattr(G, "graph", {}).get("hyperedges", [])
- with open(output_path, "w", encoding="utf-8") as f:
+ with open(output_path, "w", encoding="utf-8") as f: # nosec
json.dump(data, f, indent=2)
+ return True
def prune_dangling_edges(graph_data: dict) -> tuple[dict, int]:
@@ -335,7 +426,7 @@ def to_cypher(G: nx.Graph, output_path: str) -> None:
f"MATCH (a {{id: '{u_esc}'}}), (b {{id: '{v_esc}'}}) "
f"MERGE (a)-[:{rel} {{confidence: '{conf}'}}]->(b);"
)
- with open(output_path, "w", encoding="utf-8") as f:
+ with open(output_path, "w", encoding="utf-8") as f: # nosec
f.write("\n".join(lines))
@@ -355,10 +446,12 @@ def to_html(
If member_counts is provided (aggregated community view), node sizes are
based on community member counts rather than graph degree.
"""
- if G.number_of_nodes() > MAX_NODES_FOR_VIZ:
+ limit = _viz_node_limit()
+ if G.number_of_nodes() > limit:
raise ValueError(
- f"Graph has {G.number_of_nodes()} nodes - too large for HTML viz. "
- f"Use --no-viz or reduce input size."
+ f"Graph has {G.number_of_nodes()} nodes - too large for HTML viz "
+ f"(limit: {limit}). Use --no-viz, raise GRAPHIFY_VIZ_NODE_LIMIT, "
+ f"or reduce input size."
)
node_community = _node_community_map(communities)
@@ -395,14 +488,19 @@ def to_html(
"degree": deg,
})
- # Build edges list
+ # Build edges list. Restore original edge direction from _src/_tgt
+ # (stashed by build.py for exactly this reason): undirected NetworkX
+ # canonicalizes endpoint order, which would otherwise flip the arrow
+ # for `calls` and `rationale_for` in the rendered graph (#563).
vis_edges = []
for u, v, data in G.edges(data=True):
confidence = data.get("confidence", "EXTRACTED")
relation = data.get("relation", "")
+ true_src = data.get("_src", u)
+ true_tgt = data.get("_tgt", v)
vis_edges.append({
- "from": u,
- "to": v,
+ "from": true_src,
+ "to": true_tgt,
"label": relation,
"title": _html.escape(f"{relation} [{confidence}]"),
"dashes": confidence != "EXTRACTED",
@@ -451,6 +549,9 @@ def _js_safe(obj) -> str: