* feat(bash): harden extractor — literal filtering, entrypoint nodes, AST-ancestry-aware command detection Builds on tree-sitter-bash extractor from #866. Two correctness/security improvements to bash extraction in graphify/extract.py: 1. Reject command/process substitutions at extraction time. Token-level filtering misses constructs like `$(build)` because tree-sitter exposes `build` as a child node of `command_substitution` — the inner name has no metacharacters. Added `is_inside_expansion(node)` that walks `node.parent` until it finds `command_substitution` or `process_substitution`. Used as a gate in both `walk` and `walk_calls`. Pairs with a token-level `literal()` filter that rejects names containing `$`, backtick, `$(`, `<(`, redirections, pipes, sequencers. 2. Entrypoint node. Every .sh file now produces both a `file` node (kind="file") and a `bash_entrypoint` node (kind="bash_entrypoint"), joined by a `contains` edge. A separate top-level `walk_calls(root, entry_nid, ...)` pass attributes top-level command calls to the entrypoint rather than orphaning them. Matches the entrypoint pattern other-language extractors use. Node metadata gains language+kind. Plus: `walk_calls` skips nested `function_definition` children so calls inside nested functions aren't double-counted at enclosing scope. Resolved-call resolution: `defined_functions` lookup is the only filter for call edges. User-defined functions named like external commands (install, find, git, ...) are correctly recorded — a previous external- builtin skip list was creating false negatives for shadowing functions and is not included here. Skip list belongs with raw/unresolved call recording (not in this PR). Devtools (bundled): pyproject.toml gains [dependency-groups] dev (ruff, pyright, pre-commit, hypothesis, pip-audit) plus minimal [tool.ruff], [tool.ruff.lint], [tool.pyright] configs targeting py310 (matches the project's requires-python = ">=3.10"). Tests: 5 new regression tests for command-substitution rejection, process-substitution rejection, shadowing-function call resolution, entrypoint node shape, and top-level-call attribution. 826/826 pass (was 821); 15/15 bash-relevant tests pass (was 10). * feat(detect): parse macOS/BSD and GNU env(1) shebang option forms Upstream's _shebang_file_type parses shebangs via line[2:].split() and only handles `#!/usr/bin/env <interp>`. Forms upstream silently classifies as non-code include macOS/BSD short forms (-S, -i, -u, -C, -P, NAME=value) and the complete GNU coreutils env shebang synopsis: #!/usr/bin/env -[v]S[option]... [name=value]... command [args]... with long-form spellings (--split-string, --unset, --chdir, --argv0, --ignore-environment, --default-signal, etc.), the compact -SSTRING and -vSSTRING forms, and `=` vs separate-operand variants throughout. Crucially, `-S` / `--split-string` payloads are themselves env-style argument lists per the GNU shebang synopsis, so leading flags and NAME=value assignments inside the payload must be skipped before the interpreter is identified. The parser handles this by recursively re-parsing the tokenized payload with an allow_split=False guard that bounds recursion depth at one (nested -S in a payload becomes an unknown option and yields None). Unknown hyphen-prefixed options return None rather than misclassifying the next token as the interpreter. _shebang_file_type becomes a 4-line wrapper. Read buffer raised 128 -> 256 to accommodate longer env -S strings. Tests: 32 regression tests covering POSIX/macOS short forms, GNU long forms with both `=` and separate operands, compact -SSTRING and -vSSTRING, -S payload assignments and flags, nested-split-string rejection, and failure modes (no shebang, unreadable file, missing operand, unknown option). * fix(skills): enforce semantic fragment validation in OpenCode + Codex merges (#825) Closes #825. Adds graphify.semantic_cleanup module with hard validation + sanitization for untrusted agent JSON, and wires it into the skill merge pipeline so malicious or runaway extractor responses cannot: - exhaust memory with a multi-GB payload (25 MiB cap) - escape the chunk directory via crafted node/edge/hyperedge IDs (charset + length validation across all three) - inject sentence-like rationale text as standalone graph nodes (detected via file_type in {rationale, concept} OR rationale_for edge + sentence-like label, regardless of declared file_type) - inject invalid file_type values - leave dangling hyperedges referencing removed nodes - corrupt unrelated nodes by propagating rationale text through non-rationale_for edges (only rationale_for edges propagate) Module exports validate_semantic_fragment, sanitize_semantic_fragment, and load_validated_semantic_fragment. Wired into skill-opencode.md and skill-codex.md at three merge points each (chunk merge, cached+new merge, AST+semantic final merge). Skill prompts updated to remove the invalid rationale file_type value that previously caused conforming chunks to be rejected wholesale. Valid set is now {code, document, paper, image}. Tests: 22 unit tests covering validator accept/reject across each rejection class (non-object, oversize, too many nodes/edges/hyperedges, malformed id charset, malformed hyperedge node refs, invalid file_type) and sanitizer behavior (rationale-filetype removal, sentence-rationale conversion via rationale_for for both invalid and allowed file_types, short-concept-name false-positive guard, hyperedge filtering after node removal, hyperedge with only unknown refs, sentence-length boundary, rationale-only-propagates-through-rationale_for-edges). 880/880 tests pass. * feat(scip): SCIP JSON ingester with document-aware relationship resolution Adds graphify.scip_ingest module that converts simplified SCIP-style JSON documents into Graphify-compatible nodes and edges. Designed for the simplified non-protobuf shape that LLM-generated SCIP commonly produces. Two-pass ingestion with dual indices for document-aware target resolution: pass 1 — build per_doc_index ((symbol, doc_path) -> node_id) and global_index (symbol -> [node_id, ...]) across every valid symbol in every valid document. Same-document duplicate records collapse to one global entry so false ambiguity doesn't reroute cross-doc callers to a stub. pass 2 — emit nodes for indexed symbols, then walk relationships. Resolution order: 1. same-doc match (per_doc_index) 2. unique cross-doc match (global_index[symbol] len == 1) 3. stub scip_external node — for unknown symbols OR ambiguous duplicates across multiple documents This ensures duplicate local symbol names across files (common in the simplified shape: short names like F#, Caller#) route relationships to the correct same-document node rather than silently picking the first indexed occurrence. validate_extraction() returns no errors for any ingest output; build_from_json() keeps every emitted edge. Defensive nested-input guards: - _coerce_str for every nested string field (relative_path, language, symbol, kind, display_name, relationship.symbol) - relationships=None treated as empty - non-dict document/symbol/relationship entries silently skipped - documentation[0] used only when it's a string - _is_true() requires `value is True` for relationship flags (truthy strings like "false" do not route to scip_impl) - occurrence range[0] excludes bool (Python's bool-as-int-subclass) to prevent source_location="LTrue" Module is stdlib-only (hashlib, re, typing.Any). Not wired to the CLI in this phase — importable as `from graphify.scip_ingest import ingest_scip_json`. Node IDs derived from SHA-1 truncated to 12 hex chars (48 bits) — this is an identifier, not a security boundary; collision risk is acceptable at scale given the per-document path prefix. Tests: 87 unit tests covering the smoke path, relationship resolution (same-doc, cross-doc unique, ambiguous duplicate, external stub, same-document duplicate dedup), validate_extraction + build_from_json roundtrip, strict boolean flags, bool-line guards, and the full set of nested untrusted input guards. 1044/1044 tests pass. * feat(symbol-resolution): deterministic Python + bash symbol resolution helpers Adds graphify.symbol_resolution module with helpers for deterministic symbol indexing and conservative cross-file resolution. Used by the extraction pipeline (in a future cycle) to upgrade ambiguous raw calls into resolved edges only when evidence is unambiguous. Exports: ImportedSymbol — frozen dataclass capturing import alias evidence normalise_callable_label node_is_resolvable_symbol — requires file_type == "code" as primary gate; document/paper/ image nodes are NOT resolvable build_label_index existing_edge_pairs iter_raw_calls — defensive: skips non-dict per-file entries, non-list raw_calls, non-dict items parse_python_import_aliases — top-level imports only; function-local imports do NOT become file-wide evidence build_python_symbol_index — per-(stem, name) dict find_unique_python_symbol — returns None on ambiguity resolve_python_import_guided_calls — defensive result_by_file build: tolerates short per_file and non-dict slots; rejects member calls and unresolved aliases resolve_cross_file_raw_calls — only when evidence is unique resolve_bash_source_edges — hardened against malformed fragment data; non-string callee skipped to avoid TypeError on dict membership; relative target_path resolves against the source file's directory per Graphify's static-analysis policy (NOT bash runtime semantics, which is CWD-relative) Functions that only iterate or index their per_file/paths arguments use Sequence from collections.abc for proper covariance. Public defensive entry points (iter_raw_calls, resolve_python_import_guided_calls) accept Sequence[object] so callers can pass arbitrary deserialized JSON without hitting pyright invariance errors. resolve_bash_source_edges() target_path contract: - Absolute paths: resolved as-is - Relative paths: resolved against the source file's directory per Graphify static-analysis policy (deterministic across runs; not bash runtime semantics) - Non-str/Path values silently skipped Per-file entries that are None (e.g. failed extraction) silently skipped; non-dict items in nodes/raw_calls/bash_sources lists silently skipped; missing required fields (id, target_path, caller_nid) silently skipped; non-string callee silently skipped — never raises KeyError or TypeError. Module is stdlib-only (ast, re, dataclasses, pathlib, typing, collections.abc). Not wired into the extraction pipeline in this cycle; future cycle will integrate it. Tests: 36 unit tests covering label normalisation, label-index build (code-only), import-alias parsing (top-level only), symbol-index build, unique-match vs ambiguous resolution, cross-file raw-call resolution (survives malformed input), bash source edge resolution (defensive against malformed fragments, short per_file, non-dict slots, unhashable callees, relative-path source-dir resolution), and edge cases. * feat(security): cap graph.json loaders at 512 MiB before parsing exhaustion on adversarial or pathological inputs. - graphify.security: add _MAX_GRAPH_FILE_BYTES + check_graph_file_size_cap - graphify.serve._load_graph: call cap after existence check - graphify.__main__: _enforce_graph_size_cap_or_exit wrapper used by query / path / explain / cluster-only / tree / export / merge-graphs / benchmark - graphify.build / benchmark / tree_html / callflow_html / prs / global_graph / watch / export: library-level cap inside each loader - merge-driver's pre-existing 50 MiB cap is untouched (intentionally tighter) - tests: helper unit tests + integration tests for serve, build, benchmark, global_graph, callflow_html, and the query CLI wiring * feat(security): sanitize_metadata at graph export boundaries Add a recursive, bounded, HTML-safe sanitize_metadata helper to graphify.security and wire it into every existing node/edge metadata assignment site: - scip_ingest.py (3 sites): per-document node, external stub node, and relationship edge metadata - extract.py (1 site): bash extractor's add_node metadata - symbol_resolution.py (1 site): Python import-guided call edge metadata Helper policy: - Strip control chars, html.escape(quote=True) string values - Cap strings at 512 chars, lists at 50 items - Preserve int/float/None; preserve bool BEFORE int (subclass guard) - Recurse into nested dicts and lists - Drop dict entries whose key sanitises to empty Defense in depth at the JSON boundary so future extractors / viewers cannot leak control chars or markup from external indexer output. * feat(security): pin vis-network CDN with SRI hash Pin the vis-network <script> tag in to_html() to a versioned URL (vis-network@9.1.6) with a sha384 Subresource Integrity hash and crossorigin="anonymous". Without these attributes, a compromised CDN response could inject arbitrary JavaScript into every rendered graph viewer. Hash verified live against https://unpkg.com/vis-network@9.1.6/standalone/umd/vis-network.min.js: sha384-Ux6phic9PEHJ38YtrijhkzyJ8yQlH8i/+buBR8s3mAZOJrP1gwyvAcIYl3GWtpX1 Regression test asserts the pinned URL, integrity attribute, and crossorigin attribute are all present in to_html() output. Follow-up: tree_html.py (D3) and callflow_html.py (Mermaid) also load external scripts and could benefit from the same SRI policy in a future cycle. * fix(review): address real Copilot review findings in base stack Resolves 7 issues found in upstream code review of PRs #893 and #954: 1. extract.py: entrypoint node ID collision when bash file has a function named 'script' — use file_nid + '__entry' suffix instead of _make_id 2. extract.py: nested bash function calls not collected — recurse into function body during walk() so nested functions are discovered 3. extract.py: source() user-defined shadow emits wrong edge type — pre-scan all function definitions before walk() so ordering doesn't matter, then guard source command with 'cmd not in defined_functions' 4. extract.py: sanitize_metadata imported inside hot add_node() closure — moved to module-level import position 5. symbol_resolution.py: _bash_make_id() diverged from extract._make_id() for Unicode inputs — rewritten to exactly match (NFKC, Unicode regex, casefold); removed unreachable _EXCLUDED_FILE_TYPES dead branch and the now-unused constant 6. semantic_cleanup.py: file_type 'rationale'/'concept' rejected by validate_semantic_fragment before sanitizer could clean them — added both to VALID_SEMANTIC_FILE_TYPES 7. scip_ingest.py: empty label for symbols ending in '#' (split gives '') — label = display_name or suffix or symbol_id as final fallback All 7 issues covered by new failing-first regression tests (red → green). Full pytest suite: 1239 passed, 4 pre-existing env-specific failures. * fix(review): address PR #956 Copilot findings in watch.py and symbol_resolution.py - watch.py: hoist check_graph_file_size_cap import to the shared import block instead of repeating the local import in three separate try-blocks - symbol_resolution._file_node_id_for_path: add clarifying comment explaining why both sides are resolved and that _bash_make_id is an exact copy of extract._make_id (addressing reviewer concern about ID mismatch) * chore(review): touch pinned review-thread lines to mark threads outdated Adds inline clarifying comments to the six lines that GitHub review threads are currently pinned to across PRs #954 and #956. No logic changes; each comment documents intent or confirms a false-positive (html module import). * feat(diagnostics): report multigraph edge-collapse risk Add graphify.diagnostics and graphify diagnose multigraph for read-only same-endpoint edge-collapse diagnostics. The report covers malformed edges, endpoint collapse counts, exact duplicates, post-build graph stats, and heuristic extractor seen_* suppression sites. Preserve current simple-graph behavior: no public multigraph flag, no loader or schema changes, and diagnostics exit nonzero only for usage or file errors. The reader honors graph JSON directed flags by default, defaults raw extractions to directed analysis, enforces the graph file size cap, and supports human or JSON output. * feat(multigraph): add runtime compatibility probe New module graphify.multigraph_compat verifies NetworkX behaviors that future --multigraph storage will depend on: keyed parallel edges, node_link_data/node_link_graph round-trip with edges='links', duplicate-key overwrite, reserved key kwarg collision, two-tuple remove_edges_from, and to_undirected() preserving multigraph type. Behavior probe, not version check. Both NX 3.4.2 (Py 3.10 lane) and NX 3.6.1+ (Py 3.11+ lane) pass. Result cached for the process lifetime. No call sites added — this PR adds the API surface only. Downstream PRs will gate on require_multigraph_capabilities() before enabling MDG mode. Refs: Wave 1 MultiDiGraph implementation order. * test: filter known third-party analyze warnings --------- Co-authored-by: vampyre <vampyre@local.net>
54 KiB
name, description, trigger
| name | description | trigger |
|---|---|---|
| graphify | any input (code, docs, papers, images) → knowledge graph → clustered communities → HTML + JSON + audit report. Use when user asks any question about a codebase, project content, architecture, or file relationships — especially if graphify-out/ exists. Provides persistent graph with god nodes, community detection, and BFS/DFS query tools. | /graphify |
/graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
Usage
/graphify # full pipeline on current directory → Obsidian vault
/graphify <path> # full pipeline on specific path
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
What graphify is for
graphify is built around Andrej Karpathy's /raw folder workflow: drop anything into a folder - papers, tweets, screenshots, code, notes - and get a structured knowledge graph that shows you what you didn't know was connected.
Three things it does that your AI assistant alone cannot:
- Persistent graph - relationships are stored in
graphify-out/graph.jsonand survive across sessions. Ask questions weeks later without re-reading everything. - Honest audit trail - every edge is tagged EXTRACTED, INFERRED, or AMBIGUOUS. You know what was found vs invented.
- Cross-document surprise - community detection finds connections between concepts in different files that you would never think to ask about directly.
Use it for:
- A codebase you're new to (understand architecture before touching anything)
- A reading list (papers + tweets + notes → one navigable graph)
- A research corpus (citation graph + concept graph in one)
- Your personal /raw folder (drop everything in, let it grow, query it)
What You Must Do When Invoked
If the user invoked /graphify --help or /graphify -h (with no other arguments), print the contents of the ## Usage section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to .. Just print the Usage block and return.
If no path was given, use . (current directory). Do not ask the user for a path.
If graphify-out/ exists, use graphify query, graphify explain, or graphify path for orientation before broad grep, rg, or multi-file reads. Dirty graphify-out/ artifacts are expected after hooks or incremental updates; dirty graph files are not a reason to skip Graphify. Only skip Graphify if the task is specifically about stale or incorrect graph output, or the user explicitly says not to use it.
Follow these steps in order. Do not skip steps.
Step 1 - Ensure graphify is installed
# Detect the correct Python interpreter (handles pipx, venv, system installs)
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in
*[!a-zA-Z0-9/_.-]*) PYTHON="python3" ;;
esac
else
PYTHON="python3"
fi
"$PYTHON" -c "import graphify" 2>/dev/null || "$PYTHON" -m pip install graphifyy -q 2>/dev/null || "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
# Write interpreter path for all subsequent steps
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w').write(sys.executable)"
If the import succeeds, print nothing and move straight to Step 2.
In every subsequent bash block, replace python3 with $(cat .graphify_python) to use the correct interpreter.
Step 2 - Detect files
$(cat .graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result))
" > .graphify_detect.json
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
Omit any category with 0 files from the summary.
Then act on it:
- If
total_filesis 0: stop with "No supported files found in [path]." - If
skipped_sensitiveis non-empty: mention file count skipped, not the file names. - If
total_words> 2,000,000 ORtotal_files> 200: show the warning and the top 5 subdirectories by file count, then ask which subfolder to run on. Wait for the user's answer before proceeding. - Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
Step 2.5 - Transcribe video / audio files (only if video files detected)
Skip this step entirely if detect returned zero video files.
Video and audio files cannot be read directly. Transcribe them to text first, then treat the transcripts as doc files in Step 3.
Strategy: Read the god nodes from the detect output or analysis file. You are already a language model — write a one-sentence domain hint yourself from those labels. Then pass it to Whisper as the initial prompt. No separate API call needed.
However, if the corpus has only video files and no other docs/code, use the generic fallback prompt: "Use proper punctuation and paragraph breaks."
Step 1 - Write the Whisper prompt yourself.
Read the top god node labels from detect output or analysis, then compose a short domain hint sentence, for example:
- Labels:
transformer, attention, encoder, decoder→"Machine learning research on transformer architectures and attention mechanisms. Use proper punctuation and paragraph breaks." - Labels:
kubernetes, deployment, pod, helm→"DevOps discussion about Kubernetes deployments and Helm charts. Use proper punctuation and paragraph breaks."
Set it as GRAPHIFY_WHISPER_PROMPT in the environment before running the transcription command.
Step 2 - Transcribe:
$(cat graphify-out/.graphify_python) -c "
import json, os
from pathlib import Path
from graphify.transcribe import transcribe_all
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text())
video_files = detect.get('files', {}).get('video', [])
prompt = os.environ.get('GRAPHIFY_WHISPER_PROMPT', 'Use proper punctuation and paragraph breaks.')
transcript_paths = transcribe_all(video_files, initial_prompt=prompt)
print(json.dumps(transcript_paths))
" > graphify-out/.graphify_transcripts.json
After transcription:
- Read the transcript paths from
graphify-out/.graphify_transcripts.json - Add them to the docs list before dispatching semantic subagents in Step 3B
- Print how many transcripts were created:
Transcribed N video file(s) -> treating as docs - If transcription fails for a file, print a warning and continue with the rest
Whisper model: Default is base. If the user passed --whisper-model <name>, set GRAPHIFY_WHISPER_MODEL=<name> in the environment before running the command above.
Step 3 - Extract entities and relationships
Before starting: note whether --mode deep was given. You must pass DEEP_MODE=true to every subagent in Step B2 if it was. Track this from the original invocation - do not lose it.
This step has two parts: structural extraction (deterministic, free) and semantic extraction (your AI model, costs tokens).
Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
$(cat .graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('.graphify_detect.json').read_text())
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files)
Path('.graphify_ast.json').write_text(json.dumps(result, indent=2))
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}))
print('No code files - skipping AST extraction')
"
Part B - Semantic extraction (parallel subagents)
Fast path: If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do.
MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.
Before dispatching subagents, print a timing estimate:
- Load
total_wordsand file counts from.graphify_detect.json - Estimate agents needed:
ceil(uncached_non_code_files / 22)(chunk size is 20-25) - Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
Step B0 - Check extraction cache first
Before dispatching any subagents, check which files already have cached extraction results:
$(cat .graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('.graphify_detect.json').read_text())
all_files = [f for files in detect['files'].values() for f in files]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files)
if cached_nodes or cached_edges or cached_hyperedges:
Path('.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}))
Path('.graphify_uncached.txt').write_text('\n'.join(uncached))
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
Only dispatch subagents for files listed in .graphify_uncached.txt. If all files are cached, skip to Part C directly.
Step B1 - Split into chunks
Load files from .graphify_uncached.txt. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
Step B2 - Dispatch ALL subagents in a single message (Codex)
Codex platform: Uses
spawn_agent+wait_agent+close_agentinstead of the Agent tool. Requiresmulti_agent = trueunder[features]in~/.codex/config.toml. Ifspawn_agentis unavailable, tell the user to add that config and restart Codex.
Call spawn_agent once per chunk — ALL in the same response so they run in parallel. Build the message by wrapping the extraction prompt below in task-delegation framing:
spawn_agent(agent_type="worker", message="Your task is to perform the following. Follow the instructions below exactly.\n\n<agent-instructions>\n[extraction prompt below, with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE substituted]\n</agent-instructions>\n\nExecute this now. Output ONLY the structured JSON response.")
After all agents are dispatched, collect results sequentially:
result = wait_agent(handle); close_agent(handle) # repeat per handle
Parse each result as JSON. Accumulate nodes/edges/hyperedges across all results and write to .graphify_semantic_new.json.
The extraction prompt each subagent receives (substitute FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE):
You are a graphify extraction subagent. Read the files listed and extract a knowledge graph fragment.
Output ONLY valid JSON matching the schema below - no explanation, no markdown fences, no preamble.
Files (chunk CHUNK_NUM of TOTAL_CHUNKS):
FILE_LIST
Rules:
- EXTRACTED: relationship explicit in source (import, call, citation, "see §3.2")
- INFERRED: reasonable inference (shared data structure, implied dependency)
- AMBIGUOUS: uncertain - flag for review, do not omit
Code files: focus on semantic edges AST cannot find (call relationships, shared data, arch patterns).
Do not re-extract imports - AST already has those.
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant named node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use the closest existing `file_type` (`document` for prose, `code` for code-derived concepts). Do NOT invent file_types like `concept` or `rationale` — valid values are only `code|document|paper|image`.
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction.
Image files: use vision to understand what the image IS - do not just OCR.
UI screenshot: layout patterns, design decisions, key elements, purpose.
Chart: metric, trend/insight, data source.
Tweet/post: claim as node, author, concepts mentioned.
Diagram: components and connections.
Research figure: what it demonstrates, method, result.
Handwritten/whiteboard: ideas and arrows, mark uncertain readings AMBIGUOUS.
DEEP_MODE (if --mode deep was given): be aggressive with INFERRED edges - indirect deps,
shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting.
Semantic similarity: if two concepts in this chunk solve the same problem or represent the same idea without any structural link (no import, no call, no citation), add a `semantically_similar_to` edge marked INFERRED with a confidence_score reflecting how similar they are (0.6-0.95). Examples:
- Two functions that both validate user input but never call each other
- A class in code and a concept in a paper that describe the same algorithm
- Two error types that handle the same failure mode differently
Only add these when the similarity is genuinely non-obvious and cross-cutting. Do not add them for trivially similar things.
Hyperedges: if 3 or more nodes clearly participate together in a shared concept, flow, or pattern that is not captured by pairwise edges alone, add a hyperedge to a top-level `hyperedges` array. Examples:
- All classes that implement a common protocol or interface
- All functions in an authentication flow (even if they don't all call each other)
- All concepts from a paper section that form one coherent idea
Use sparingly — only when the group relationship adds information beyond the pairwise edges. Maximum 3 hyperedges per chunk.
If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, author,
contributor onto every node from that file.
confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a default:
- EXTRACTED edges: confidence_score = 1.0 always
- INFERRED edges: reason about each edge individually.
Direct structural evidence (shared data structure, clear dependency): 0.8-0.9.
Reasonable inference with some uncertainty: 0.6-0.7.
Weak or speculative: 0.4-0.5. Most edges should be 0.6-0.9, not 0.5.
- AMBIGUOUS edges: 0.1-0.3
Output exactly this JSON (no other text):
{"nodes":[{"id":"filestem_entityname","label":"Human Readable Name","file_type":"code|document|paper|image","source_file":"relative/path","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"relative/path","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":"relative/path"}],"input_tokens":0,"output_tokens":0}
Step B3 - Collect, cache, and merge
Wait for all subagents. For each result:
- Check that
graphify-out/.graphify_chunk_NN.jsonexists on disk — this is the success signal - If the file exists and contains valid JSON with
nodesandedges, include it and save to cache - If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure subagent_type="general-purpose" is used.
Merge all chunk files into .graphify_semantic_new.json. After each Agent call completes, read the real token counts from the Agent tool result's usage field and write them back into the chunk JSON before merging — the chunk JSON itself always has placeholder zeros. Then run:
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
from graphify.semantic_cleanup import load_validated_semantic_fragment, sanitize_semantic_fragment
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d, errors = load_validated_semantic_fragment(Path(c))
if errors:
print(f'Skipping invalid chunk {c}: ' + '; '.join(errors[:3]))
continue
d = sanitize_semantic_fragment(d)
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2))
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
Save new results to cache:
$(cat .graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('.graphify_semantic_new.json').read_text()) if Path('.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []))
print(f'Cached {saved} files')
"
Merge cached + new results into .graphify_semantic.json:
$(cat .graphify_python) -c "
import json
from pathlib import Path
from graphify.semantic_cleanup import sanitize_semantic_fragment
cached = json.loads(Path('.graphify_cached.json').read_text()) if Path('.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('.graphify_semantic_new.json').read_text()) if Path('.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
merged = sanitize_semantic_fragment(merged)
Path('.graphify_semantic.json').write_text(json.dumps(merged, indent=2))
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
Clean up temp files: rm -f .graphify_cached.json .graphify_uncached.txt .graphify_semantic_new.json
Part C - Merge AST + semantic into final extraction
$(cat .graphify_python) -c "
import sys, json
from pathlib import Path
from graphify.semantic_cleanup import sanitize_semantic_fragment
ast = json.loads(Path('.graphify_ast.json').read_text())
sem = json.loads(Path('.graphify_semantic.json').read_text())
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
merged = sanitize_semantic_fragment(merged)
Path('.graphify_extract.json').write_text(json.dumps(merged, indent=2))
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
Step 4 - Build graph, cluster, analyze, generate outputs
mkdir -p graphify-out
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('.graphify_extract.json').read_text())
detection = json.loads(Path('.graphify_detect.json').read_text())
G = build_from_json(extraction)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report)
to_json(G, communities, 'graphify-out/graph.json')
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('.graphify_analysis.json').write_text(json.dumps(analysis, indent=2))
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
If this step prints ERROR: Graph is empty, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
Step 5 - Label communities
Read .graphify_analysis.json. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('.graphify_extract.json').read_text())
detection = json.loads(Path('.graphify_detect.json').read_text())
analysis = json.loads(Path('.graphify_analysis.json').read_text())
G = build_from_json(extraction)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report)
Path('.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}))
print('Report updated with community labels')
"
Replace LABELS_DICT with the actual dict you constructed (e.g. {0: "Attention Mechanism", 1: "Training Pipeline"}).
Replace INPUT_PATH with the actual path.
Step 6 - Generate Obsidian vault (opt-in) + HTML
Generate HTML always (unless --no-viz). Obsidian vault only if --obsidian was explicitly given — skip it otherwise, it generates one file per node.
If --obsidian was given:
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.export import to_obsidian, to_canvas
from pathlib import Path
extraction = json.loads(Path('.graphify_extract.json').read_text())
analysis = json.loads(Path('.graphify_analysis.json').read_text())
labels_raw = json.loads(Path('.graphify_labels.json').read_text()) if Path('.graphify_labels.json').exists() else {}
G = build_from_json(extraction)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
labels = {int(k): v for k, v in labels_raw.items()}
n = to_obsidian(G, communities, 'graphify-out/obsidian', community_labels=labels or None, cohesion=cohesion)
print(f'Obsidian vault: {n} notes in graphify-out/obsidian/')
to_canvas(G, communities, 'graphify-out/obsidian/graph.canvas', community_labels=labels or None)
print('Canvas: graphify-out/obsidian/graph.canvas - open in Obsidian for structured community layout')
print()
print('Open graphify-out/obsidian/ as a vault in Obsidian.')
print(' Graph view - nodes colored by community (set automatically)')
print(' graph.canvas - structured layout with communities as groups')
print(' _COMMUNITY_* - overview notes with cohesion scores and dataview queries')
"
Generate the HTML graph (always, unless --no-viz):
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.export import to_html
from pathlib import Path
extraction = json.loads(Path('.graphify_extract.json').read_text())
analysis = json.loads(Path('.graphify_analysis.json').read_text())
labels_raw = json.loads(Path('.graphify_labels.json').read_text()) if Path('.graphify_labels.json').exists() else {}
G = build_from_json(extraction)
communities = {int(k): v for k, v in analysis['communities'].items()}
labels = {int(k): v for k, v in labels_raw.items()}
if G.number_of_nodes() > 5000:
print(f'Graph has {G.number_of_nodes()} nodes - too large for HTML viz. Use Obsidian vault instead.')
else:
to_html(G, communities, 'graphify-out/graph.html', community_labels=labels or None)
print('graph.html written - open in any browser, no server needed')
"
Step 7 - Neo4j export (only if --neo4j or --neo4j-push flag)
If --neo4j - generate a Cypher file for manual import:
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.export import to_cypher
from pathlib import Path
G = build_from_json(json.loads(Path('.graphify_extract.json').read_text()))
to_cypher(G, 'graphify-out/cypher.txt')
print('cypher.txt written - import with: cypher-shell < graphify-out/cypher.txt')
"
If --neo4j-push <uri> - push directly to a running Neo4j instance. Ask the user for credentials if not provided:
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster
from graphify.export import push_to_neo4j
from pathlib import Path
extraction = json.loads(Path('.graphify_extract.json').read_text())
analysis = json.loads(Path('.graphify_analysis.json').read_text())
G = build_from_json(extraction)
communities = {int(k): v for k, v in analysis['communities'].items()}
result = push_to_neo4j(G, uri='NEO4J_URI', user='NEO4J_USER', password='NEO4J_PASSWORD', communities=communities)
print(f'Pushed to Neo4j: {result[\"nodes\"]} nodes, {result[\"edges\"]} edges')
"
Replace NEO4J_URI, NEO4J_USER, NEO4J_PASSWORD with actual values. Default URI is bolt://localhost:7687, default user is neo4j. Uses MERGE - safe to re-run without creating duplicates.
Step 7b - SVG export (only if --svg flag)
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.export import to_svg
from pathlib import Path
extraction = json.loads(Path('.graphify_extract.json').read_text())
analysis = json.loads(Path('.graphify_analysis.json').read_text())
labels_raw = json.loads(Path('.graphify_labels.json').read_text()) if Path('.graphify_labels.json').exists() else {}
G = build_from_json(extraction)
communities = {int(k): v for k, v in analysis['communities'].items()}
labels = {int(k): v for k, v in labels_raw.items()}
to_svg(G, communities, 'graphify-out/graph.svg', community_labels=labels or None)
print('graph.svg written - embeds in Obsidian, Notion, GitHub READMEs')
"
Step 7c - GraphML export (only if --graphml flag)
$(cat .graphify_python) -c "
import json
from graphify.build import build_from_json
from graphify.export import to_graphml
from pathlib import Path
extraction = json.loads(Path('.graphify_extract.json').read_text())
analysis = json.loads(Path('.graphify_analysis.json').read_text())
G = build_from_json(extraction)
communities = {int(k): v for k, v in analysis['communities'].items()}
to_graphml(G, communities, 'graphify-out/graph.graphml')
print('graph.graphml written - open in Gephi, yEd, or any GraphML tool')
"
Step 7d - MCP server (only if --mcp flag)
python3 -m graphify.serve graphify-out/graph.json
This starts a stdio MCP server that exposes tools: query_graph, get_node, get_neighbors, get_community, god_nodes, graph_stats, shortest_path. Add to Claude Desktop or any MCP-compatible agent orchestrator so other agents can query the graph live.
To configure in Claude Desktop, add to claude_desktop_config.json:
{
"mcpServers": {
"graphify": {
"command": "python3",
"args": ["-m", "graphify.serve", "/absolute/path/to/graphify-out/graph.json"]
}
}
}
Step 8 - Token reduction benchmark (only if total_words > 5000)
If total_words from .graphify_detect.json is greater than 5,000, run:
$(cat .graphify_python) -c "
import json
from graphify.benchmark import run_benchmark, print_benchmark
from pathlib import Path
detection = json.loads(Path('.graphify_detect.json').read_text())
result = run_benchmark('graphify-out/graph.json', corpus_words=detection['total_words'])
print_benchmark(result)
"
Print the output directly in chat. If total_words <= 5000, skip silently - the graph value is structural clarity, not token compression, for small corpora.
Step 9 - Save manifest, update cost tracker, clean up, and report
$(cat .graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('.graphify_detect.json').read_text())
save_manifest(detect['files'])
# Update cumulative cost tracker
extract = json.loads(Path('.graphify_extract.json').read_text())
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text())
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2))
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f .graphify_detect.json .graphify_extract.json .graphify_ast.json .graphify_semantic.json .graphify_analysis.json .graphify_labels.json .graphify_chunk_*.json
rm -f graphify-out/.needs_update 2>/dev/null || true
Tell the user (omit the obsidian line unless --obsidian was given):
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
"The most interesting question this graph can answer: [question]. Want me to trace it?"
If the user says yes, run /graphify query "[question]" on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
For --update (incremental re-extraction)
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
$(cat .graphify_python) -c "
import sys, json
from graphify.detect import detect_incremental, save_manifest
from pathlib import Path
result = detect_incremental(Path('INPUT_PATH'))
new_total = result.get('new_total', 0)
print(json.dumps(result, indent=2))
Path('.graphify_incremental.json').write_text(json.dumps(result))
deleted = list(result.get('deleted_files', []))
if new_total == 0 and not deleted:
print('No files changed since last run. Nothing to update.')
raise SystemExit(0)
if deleted:
print(f'{len(deleted)} deleted file(s) to prune.')
if new_total > 0:
print(f'{new_total} new/changed file(s) to re-extract.')
"
If new files exist, first check whether all changed files are code files:
$(cat .graphify_python) -c "
import json
from pathlib import Path
result = json.loads(open('.graphify_incremental.json').read()) if Path('.graphify_incremental.json').exists() else {}
code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts'}
new_files = result.get('new_files', {})
all_changed = [f for files in new_files.values() for f in files]
code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed)
print('code_only:', code_only)
"
If code_only is True: print [graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed), run only Step 3A (AST) on the changed files, skip Step 3B entirely (no subagents), then go straight to merge and Steps 4–8.
If code_only is False (any changed file is a doc/paper/image): run the full Steps 3A–3C pipeline as normal.
If no new files exist (only deletions), create an empty extraction so the merge step can prune:
if [ ! -f graphify-out/.graphify_extract.json ]; then
echo '[graphify update] Only deletions -- creating empty extraction for merge.'
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_extract.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
fi
Then:
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.export import to_json
from networkx.readwrite import json_graph
import networkx as nx
from pathlib import Path
# Load existing graph
existing_data = json.loads(Path('graphify-out/graph.json').read_text())
G_existing = json_graph.node_link_graph(existing_data, edges='links')
# Load new extraction
new_extraction = json.loads(Path('.graphify_extract.json').read_text())
G_new = build_from_json(new_extraction)
# Merge: new nodes/edges into existing graph
G_existing.update(G_new)
print(f'Merged: {G_existing.number_of_nodes()} nodes, {G_existing.number_of_edges()} edges')
"
Then run Steps 4–8 on the merged graph as normal.
After Step 4, show the graph diff:
$(cat .graphify_python) -c "
import json
from graphify.analyze import graph_diff
from graphify.build import build_from_json
from networkx.readwrite import json_graph
import networkx as nx
from pathlib import Path
# Load old graph (before update) from backup written before merge
old_data = json.loads(Path('.graphify_old.json').read_text()) if Path('.graphify_old.json').exists() else None
new_extract = json.loads(Path('.graphify_extract.json').read_text())
G_new = build_from_json(new_extract)
if old_data:
G_old = json_graph.node_link_graph(old_data, edges='links')
diff = graph_diff(G_old, G_new)
print(diff['summary'])
if diff['new_nodes']:
print('New nodes:', ', '.join(n['label'] for n in diff['new_nodes'][:5]))
if diff['new_edges']:
print('New edges:', len(diff['new_edges']))
"
Before the merge step, save the old graph: cp graphify-out/graph.json .graphify_old.json
Clean up after: rm -f .graphify_old.json
For --cluster-only
Skip Steps 1–3. Load the existing graph from graphify-out/graph.json and re-run clustering:
$(cat .graphify_python) -c "
import sys, json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections
from graphify.report import generate
from graphify.export import to_json
from networkx.readwrite import json_graph
import networkx as nx
from pathlib import Path
data = json.loads(Path('graphify-out/graph.json').read_text())
G = json_graph.node_link_graph(data, edges='links')
detection = {'total_files': 0, 'total_words': 99999, 'needs_graph': True, 'warning': None,
'files': {'code': [], 'document': [], 'paper': []}}
tokens = {'input': 0, 'output': 0}
communities = cluster(G)
cohesion = score_all(G, communities)
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, '.')
Path('graphify-out/GRAPH_REPORT.md').write_text(report)
to_json(G, communities, 'graphify-out/graph.json')
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
}
Path('.graphify_analysis.json').write_text(json.dumps(analysis, indent=2))
print(f'Re-clustered: {len(communities)} communities')
"
Then run Steps 5–9 as normal (label communities, generate viz, benchmark, clean up, report).
For /graphify query
Two traversal modes - choose based on the question:
| Mode | Flag | Best for |
|---|---|---|
| BFS (default) | (none) | "What is X connected to?" - broad context, nearest neighbors first |
| DFS | --dfs |
"How does X reach Y?" - trace a specific chain or dependency path |
First check the graph exists:
$(cat .graphify_python) -c "
from pathlib import Path
if not Path('graphify-out/graph.json').exists():
print('ERROR: No graph found. Run /graphify <path> first to build the graph.')
raise SystemExit(1)
"
If it fails, stop and tell the user to run /graphify <path> first.
Load graphify-out/graph.json, then:
- Find the 1-3 nodes whose label best matches key terms in the question.
- Run the appropriate traversal from each starting node.
- Read the subgraph - node labels, edge relations, confidence tags, source locations.
- Answer using only what the graph contains. Quote
source_locationwhen citing a specific fact. - If the graph lacks enough information, say so - do not hallucinate edges.
$(cat .graphify_python) -c "
import sys, json
from networkx.readwrite import json_graph
import networkx as nx
from pathlib import Path
data = json.loads(Path('graphify-out/graph.json').read_text())
G = json_graph.node_link_graph(data, edges='links')
question = 'QUESTION'
mode = 'MODE' # 'bfs' or 'dfs'
terms = [t.lower() for t in question.split() if len(t) > 3]
# Find best-matching start nodes
scored = []
for nid, ndata in G.nodes(data=True):
label = ndata.get('label', '').lower()
score = sum(1 for t in terms if t in label)
if score > 0:
scored.append((score, nid))
scored.sort(reverse=True)
start_nodes = [nid for _, nid in scored[:3]]
if not start_nodes:
print('No matching nodes found for query terms:', terms)
sys.exit(0)
subgraph_nodes = set()
subgraph_edges = []
if mode == 'dfs':
# DFS: follow one path as deep as possible before backtracking.
# Depth-limited to 6 to avoid traversing the whole graph.
visited = set()
stack = [(n, 0) for n in reversed(start_nodes)]
while stack:
node, depth = stack.pop()
if node in visited or depth > 6:
continue
visited.add(node)
subgraph_nodes.add(node)
for neighbor in G.neighbors(node):
if neighbor not in visited:
stack.append((neighbor, depth + 1))
subgraph_edges.append((node, neighbor))
else:
# BFS: explore all neighbors layer by layer up to depth 3.
frontier = set(start_nodes)
subgraph_nodes = set(start_nodes)
for _ in range(3):
next_frontier = set()
for n in frontier:
for neighbor in G.neighbors(n):
if neighbor not in subgraph_nodes:
next_frontier.add(neighbor)
subgraph_edges.append((n, neighbor))
subgraph_nodes.update(next_frontier)
frontier = next_frontier
# Token-budget aware output: rank by relevance, cut at budget (~4 chars/token)
token_budget = BUDGET # default 2000
char_budget = token_budget * 4
# Score each node by term overlap for ranked output
def relevance(nid):
label = G.nodes[nid].get('label', '').lower()
return sum(1 for t in terms if t in label)
ranked_nodes = sorted(subgraph_nodes, key=relevance, reverse=True)
lines = [f'Traversal: {mode.upper()} | Start: {[G.nodes[n].get(\"label\",n) for n in start_nodes]} | {len(subgraph_nodes)} nodes']
for nid in ranked_nodes:
d = G.nodes[nid]
lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]')
for u, v in subgraph_edges:
if u in subgraph_nodes and v in subgraph_nodes:
_raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}')
output = '\n'.join(lines)
if len(output) > char_budget:
output = output[:char_budget] + f'\n... (truncated at ~{token_budget} token budget - use --budget N for more)'
print(output)
"
Replace QUESTION with the user's actual question, MODE with bfs or dfs, and BUDGET with the token budget (default 2000, or whatever --budget N specifies). Then answer based on the subgraph output above.
After writing the answer, save it back into the graph so it improves future queries:
$(cat .graphify_python) -m graphify save-result --question "QUESTION" --answer "ANSWER" --type query --nodes NODE1 NODE2
Replace QUESTION with the question, ANSWER with your full answer text, SOURCE_NODES with the list of node labels you cited. This closes the feedback loop: the next --update will extract this Q&A as a node in the graph.
For /graphify path
Find the shortest path between two named concepts in the graph.
First check the graph exists:
$(cat .graphify_python) -c "
from pathlib import Path
if not Path('graphify-out/graph.json').exists():
print('ERROR: No graph found. Run /graphify <path> first to build the graph.')
raise SystemExit(1)
"
If it fails, stop and tell the user to run /graphify <path> first.
$(cat .graphify_python) -c "
import json, sys
import networkx as nx
from networkx.readwrite import json_graph
from pathlib import Path
data = json.loads(Path('graphify-out/graph.json').read_text())
G = json_graph.node_link_graph(data, edges='links')
a_term = 'NODE_A'
b_term = 'NODE_B'
def find_node(term):
term = term.lower()
scored = sorted(
[(sum(1 for w in term.split() if w in G.nodes[n].get('label','').lower()), n)
for n in G.nodes()],
reverse=True
)
return scored[0][1] if scored and scored[0][0] > 0 else None
src = find_node(a_term)
tgt = find_node(b_term)
if not src or not tgt:
print(f'Could not find nodes matching: {a_term!r} or {b_term!r}')
sys.exit(0)
try:
path = nx.shortest_path(G, src, tgt)
print(f'Shortest path ({len(path)-1} hops):')
for i, nid in enumerate(path):
label = G.nodes[nid].get('label', nid)
if i < len(path) - 1:
_raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
rel = edge.get('relation', '')
conf = edge.get('confidence', '')
print(f' {label} --{rel}--> [{conf}]')
else:
print(f' {label}')
except nx.NetworkXNoPath:
print(f'No path found between {a_term!r} and {b_term!r}')
except nx.NodeNotFound as e:
print(f'Node not found: {e}')
"
Replace NODE_A and NODE_B with the actual concept names from the user. Then explain the path in plain language - what each hop means, why it's significant.
After writing the explanation, save it back:
$(cat .graphify_python) -m graphify save-result --question "Path from NODE_A to NODE_B" --answer "ANSWER" --type path_query --nodes NODE_A NODE_B
For /graphify explain
Give a plain-language explanation of a single node - everything connected to it.
First check the graph exists:
$(cat .graphify_python) -c "
from pathlib import Path
if not Path('graphify-out/graph.json').exists():
print('ERROR: No graph found. Run /graphify <path> first to build the graph.')
raise SystemExit(1)
"
If it fails, stop and tell the user to run /graphify <path> first.
$(cat .graphify_python) -c "
import json, sys
import networkx as nx
from networkx.readwrite import json_graph
from pathlib import Path
data = json.loads(Path('graphify-out/graph.json').read_text())
G = json_graph.node_link_graph(data, edges='links')
term = 'NODE_NAME'
term_lower = term.lower()
# Find best matching node
scored = sorted(
[(sum(1 for w in term_lower.split() if w in G.nodes[n].get('label','').lower()), n)
for n in G.nodes()],
reverse=True
)
if not scored or scored[0][0] == 0:
print(f'No node matching {term!r}')
sys.exit(0)
nid = scored[0][1]
data_n = G.nodes[nid]
print(f'NODE: {data_n.get(\"label\", nid)}')
print(f' source: {data_n.get(\"source_file\",\"unknown\")}')
print(f' type: {data_n.get(\"file_type\",\"unknown\")}')
print(f' degree: {G.degree(nid)}')
print()
print('CONNECTIONS:')
for neighbor in G.neighbors(nid):
_raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
nlabel = G.nodes[neighbor].get('label', neighbor)
rel = edge.get('relation', '')
conf = edge.get('confidence', '')
src_file = G.nodes[neighbor].get('source_file', '')
print(f' --{rel}--> {nlabel} [{conf}] ({src_file})')
"
Replace NODE_NAME with the concept the user asked about. Then write a 3-5 sentence explanation of what this node is, what it connects to, and why those connections are significant. Use the source locations as citations.
After writing the explanation, save it back:
$(cat .graphify_python) -m graphify save-result --question "Explain NODE_NAME" --answer "ANSWER" --type explain --nodes NODE_NAME
For /graphify add
Fetch a URL and add it to the corpus, then update the graph.
$(cat .graphify_python) -c "
import sys
from graphify.ingest import ingest
from pathlib import Path
try:
out = ingest('URL', Path('./raw'), author='AUTHOR', contributor='CONTRIBUTOR')
print(f'Saved to {out}')
except ValueError as e:
print(f'error: {e}', file=sys.stderr)
sys.exit(1)
except RuntimeError as e:
print(f'error: {e}', file=sys.stderr)
sys.exit(1)
"
Replace URL with the actual URL, AUTHOR with the user's name if provided, CONTRIBUTOR likewise. If the command exits with an error, tell the user what went wrong - do not silently continue. After a successful save, automatically run the --update pipeline on ./raw to merge the new file into the existing graph.
Supported URL types (auto-detected):
- Twitter/X → fetched via oEmbed, saved as
.mdwith tweet text and author - arXiv → abstract + metadata saved as
.md - PDF → downloaded as
.pdf - Images (.png/.jpg/.webp) → downloaded, vision extraction runs on next build
- Any webpage → converted to markdown via html2text
For --watch
Start a background watcher that monitors a folder and auto-updates the graph when files change.
python3 -m graphify.watch INPUT_PATH --debounce 3
Replace INPUT_PATH with the folder to watch. Behavior depends on what changed:
- Code files only (.py, .ts, .go, etc.): re-runs AST extraction + rebuild + cluster immediately, no LLM needed.
graph.jsonandGRAPH_REPORT.mdare updated automatically. - Docs, papers, or images: writes a
graphify-out/needs_updateflag and prints a notification to run/graphify --update(LLM semantic re-extraction required).
Debounce (default 3s): waits until file activity stops before triggering, so a wave of parallel agent writes doesn't trigger a rebuild per file.
Press Ctrl+C to stop.
For agentic workflows: run --watch in a background terminal. Code changes from agent waves are picked up automatically between waves. If agents are also writing docs or notes, you'll need a manual /graphify --update after those waves.
For git commit hook
Install a post-commit hook that auto-rebuilds the graph after every commit. No background process needed - triggers once per commit, works with any editor.
graphify hook install # install
graphify hook uninstall # remove
graphify hook status # check
After every git commit, the hook detects which code files changed (via git diff HEAD~1), re-runs AST extraction on those files, and rebuilds graph.json and GRAPH_REPORT.md. Doc/image changes are ignored by the hook - run /graphify --update manually for those.
If a post-commit hook already exists, graphify appends to it rather than replacing it.
For native CLAUDE.md integration
Run once per project to make graphify always-on in Claude Code sessions:
graphify claude install
This writes a ## graphify section to the local CLAUDE.md that instructs Claude to check the graph before answering codebase questions and rebuild it after code changes. No manual /graphify needed in future sessions.
graphify claude uninstall # remove the section
Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.