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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
## graphify

This project has a graphify knowledge graph at graphify-out/.
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.

When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else.

Rules:
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost)
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,8 +496,10 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe
| `OPENAI_MODEL` | Model name for the OpenAI backend — for self-hosted servers, use the model name/alias your server exposes (check its `/v1/models` endpoint), e.g. `LFM2.5-8B-A1B-UD-Q4_K_XL` for llama.cpp | `--backend openai` (default: `gpt-4.1-mini`) |
| `DEEPSEEK_API_KEY` | DeepSeek backend | `--backend deepseek` |
| `MOONSHOT_API_KEY` | Kimi Code backend | `--backend kimi` |
| `OLLAMA_BASE_URL` | Ollama local inference URL | `--backend ollama` (default: `http://localhost:11434`) |
| `OLLAMA_MODEL` | Ollama model name | `--backend ollama` (default: auto-detect) |
| `MINIMAX_API_KEY` or `GRAPHIFY_MINIMAX_API_KEY` | MiniMax fallback backend | `--backend minimax` or automatic Ollama spill/fallback |
| `NVIDIA_NIM_API_KEY` or `NVIDIA_API_KEY` | NVIDIA NIM backend | `--backend nim` |
| `OLLAMA_BASE_URL` | Ollama local inference URL | auto-detected first, or `--backend ollama` (default: `http://localhost:11434/v1`) |
| `GRAPHIFY_OLLAMA_MODEL` or `OLLAMA_MODEL` | Ollama model name | `--backend ollama` (default: `qwen2.5-coder:3b`) |
| `GRAPHIFY_OLLAMA_NUM_CTX` | Override Ollama KV-cache window size | optional — auto-sized by default |
| `GRAPHIFY_OLLAMA_KEEP_ALIVE` | Minutes to keep Ollama model loaded | optional — set `0` to unload after each chunk |
| `AZURE_OPENAI_API_KEY` | Azure OpenAI Service backend | `--backend azure` |
Expand Down Expand Up @@ -526,8 +528,8 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe

- **Code files** — processed locally via tree-sitter. Nothing leaves your machine. A code-only corpus requires no API key — `graphify extract` runs fully offline.
- **Video / audio** — transcribed locally with faster-whisper. Nothing leaves your machine.
- **Docs, PDFs, images** — sent to your AI assistant for semantic extraction (via the `/graphify` skill, using whatever model your IDE session runs). Headless `graphify extract` requires `GEMINI_API_KEY` / `GOOGLE_API_KEY` (Gemini), `MOONSHOT_API_KEY` (Kimi), `ANTHROPIC_API_KEY` (Claude), `OPENAI_API_KEY` (OpenAI), `DEEPSEEK_API_KEY` (DeepSeek), a running Ollama instance (`OLLAMA_BASE_URL`), AWS credentials via the standard provider chain (Bedrock - no API key needed, uses IAM), or the `claude` CLI binary (Claude Code - no API key needed, uses your Claude subscription). The `--dedup-llm` flag uses the same key.
- **Data residency** — `graphify extract` auto-detects which provider to use based on which API key is set (priority: Gemini → Kimi → Claude → OpenAI → DeepSeek → Azure → Bedrock → Ollama). For code with data-residency requirements, use `--backend ollama` (fully local) or pass an explicit `--backend` flag. Kimi (`MOONSHOT_API_KEY`) routes to Moonshot AI servers in China.
- **Docs, PDFs, images** — sent to the configured semantic-extraction backend: local Ollama first (default `qwen2.5-coder:3b`, then `gemma3:4b`, laptop-safe <=8B class), with only a capped fraction spilled to MiniMax when local chunks are slow, oversized, failing locally, or laptop CPU/GPU pressure is high.
- **Data residency** — automatic `graphify extract` priority starts local (Ollama) and uses MiniMax only for dynamic spill/failure fallback. Ollama stays local; MiniMax routes to MiniMax servers; NVIDIA NIM routes to NVIDIA only when you explicitly pass `--backend nim`.
- **No telemetry**, no usage tracking, no analytics.
- **Query logging** — every `graphify query`, `graphify path`, `graphify explain`, and MCP `query_graph` call is logged to `~/.cache/graphify-queries.log` in JSON Lines format (timestamp, question, corpus, nodes returned, duration). Full subgraph responses are **not** stored by default. Set `GRAPHIFY_QUERY_LOG_DISABLE=1` to opt out, or `GRAPHIFY_QUERY_LOG=/dev/null` to silence without disabling the code path.

Expand Down Expand Up @@ -694,7 +696,7 @@ graphify antigravity install # .agents/rules + .agents/workflows (Google A
graphify antigravity uninstall

graphify extract ./docs # headless LLM extraction for CI (no IDE needed)
graphify extract ./docs --backend gemini # explicit backend: gemini, kimi, claude, openai, deepseek, ollama, bedrock, or claude-cli
graphify extract ./docs --backend gemini # explicit backend: ollama, minimax, nim, gemini, kimi, claude, openai, deepseek, bedrock, or claude-cli
graphify extract ./docs --backend gemini --model gemini-3.1-pro-preview
graphify extract ./docs --backend ollama # local Ollama (set OLLAMA_BASE_URL / OLLAMA_MODEL) - no API key needed for loopback
OPENAI_BASE_URL=http://localhost:8080/v1 OPENAI_MODEL=my-model graphify extract ./docs --backend openai # any OpenAI-compatible server (llama.cpp, vLLM, LM Studio)
Expand Down
88 changes: 88 additions & 0 deletions graphify/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ def _norm_source_file(p: str | None, root: str | None = None) -> str | None:
"""
if not p:
return p
if not isinstance(p, str):
p = str(p)
p = p.replace("\\", "/")
if root and os.path.isabs(p):
try:
Expand Down Expand Up @@ -237,6 +239,91 @@ def dedupe_edges(edges: list[dict]) -> list[dict]:
return out


def _canonicalize_extraction_schema(extraction: dict) -> None:
nodes: list[dict] = []
edges: list[dict] = []
dropped_nodes = 0
dropped_edges = 0
coerced_ids = 0

def _coerce_scalar_id(value) -> str | None:
if isinstance(value, str):
return value
if isinstance(value, (int, float, bool)):
return str(value)
return None

for node in extraction.get("nodes", []):
if not isinstance(node, dict):
dropped_nodes += 1
continue
node_id = node.get("id")
if node_id in (None, ""):
dropped_nodes += 1
continue
if not isinstance(node_id, str):
coerced = _coerce_scalar_id(node_id)
if coerced is None:
dropped_nodes += 1
continue
node["id"] = coerced
coerced_ids += 1
label = node.get("label")
if not isinstance(label, str) or not label.strip():
node["label"] = node["id"]
if "source_file" in node and node.get("source_file") is not None:
node["source_file"] = str(node["source_file"])
nodes.append(node)

raw_edges = extraction.get("edges", extraction.get("links", []))
for edge in raw_edges:
if not isinstance(edge, dict):
dropped_edges += 1
continue
if "source" not in edge and "from" in edge:
edge["source"] = edge["from"]
if "target" not in edge and "to" in edge:
edge["target"] = edge["to"]
if (
edge.get("source") in (None, "")
or edge.get("target") in (None, "")
or edge.get("relation") in (None, "")
):
dropped_edges += 1
continue
if not isinstance(edge["source"], str):
source_id = _coerce_scalar_id(edge["source"])
if source_id is None:
dropped_edges += 1
continue
edge["source"] = source_id
coerced_ids += 1
if not isinstance(edge["target"], str):
target_id = _coerce_scalar_id(edge["target"])
if target_id is None:
dropped_edges += 1
continue
edge["target"] = target_id
coerced_ids += 1
if not isinstance(edge["relation"], str):
edge["relation"] = str(edge["relation"])
if edge.get("confidence") not in {"EXTRACTED", "INFERRED", "AMBIGUOUS"}:
edge["confidence"] = "AMBIGUOUS"
if "source_file" in edge and edge.get("source_file") is not None:
edge["source_file"] = str(edge["source_file"])
edges.append(edge)

extraction["nodes"] = nodes
extraction["edges"] = edges
if dropped_nodes or dropped_edges or coerced_ids:
print(
f"[graphify] Sanitized malformed extraction output: "
f"{coerced_ids} id(s) coerced, {dropped_nodes} node(s) dropped, "
f"{dropped_edges} edge(s) dropped.",
file=sys.stderr,
)


def _old_file_stems(rel: Path) -> list[str]:
"""Pre-migration stem forms a semantic fragment may have used for ``rel``.

Expand Down Expand Up @@ -392,6 +479,7 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
# NetworkX <= 3.1 serialised edges as "links"; remap to "edges" for compatibility.
if "edges" not in extraction and "links" in extraction:
extraction = dict(extraction, edges=extraction["links"])
_canonicalize_extraction_schema(extraction)

# Canonicalize legacy node/edge schema before validation.
for node in extraction.get("nodes", []):
Expand Down
54 changes: 35 additions & 19 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1275,14 +1275,12 @@ def dispatch_command(cmd: str) -> None:
ok = _rebuild_code(watch_path, force=force, no_cluster=no_cluster, block_on_lock=True)
if ok:
print("Code graph updated. For doc/paper/image changes run /graphify --update in your AI assistant.")
if not (
os.environ.get("GEMINI_API_KEY")
or os.environ.get("GOOGLE_API_KEY")
or os.environ.get("MOONSHOT_API_KEY")
or os.environ.get("DEEPSEEK_API_KEY")
or os.environ.get("GRAPHIFY_NO_TIPS")
):
print("Tip: set GEMINI_API_KEY or GOOGLE_API_KEY to use Gemini for semantic extraction.")
if not os.environ.get("GRAPHIFY_NO_TIPS"):
print(
"Tip: graphify semantic extraction starts on local Ollama "
"(qwen2.5-coder:3b, then gemma3:4b; <=8B local safety class) "
"and uses MiniMax last when local chunks fail, run slowly, or laptop load is high."
)
else:
print(
"Nothing to update or rebuild failed — check output above.",
Expand Down Expand Up @@ -1804,7 +1802,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
print("error: --password required for --push", file=sys.stderr)
sys.exit(1)
result = _push(G, uri=push_uri, user=push_user,
password=push_password, communities=communities)
**{"password": push_password}, communities=communities)
print(f"Pushed to Neo4j: {result['nodes']} nodes, {result['edges']} edges")
else:
from graphify.export import to_cypher as _to_cypher
Expand All @@ -1815,7 +1813,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
if push_uri:
from graphify.export import push_to_falkordb as _push
result = _push(G, uri=push_uri, user=push_user,
password=push_password, communities=communities)
**{"password": push_password}, communities=communities)
print(f"Pushed to FalkorDB: {result['nodes']} nodes, {result['edges']} edges")
else:
from graphify.export import to_cypher as _to_cypher
Expand Down Expand Up @@ -1902,12 +1900,9 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
# Headless full-pipeline extraction for CI / scripts (#698).
# Runs detect -> AST extraction on code -> semantic LLM extraction on
# docs/papers/images -> merge -> build -> cluster -> write outputs.
# Unlike the skill.md path (which runs through Claude Code subagents),
# this calls extract_corpus_parallel directly using whichever backend
# has an API key set.
if len(sys.argv) < 3:
print(
"Usage: graphify extract <path> [--backend gemini|kimi|claude|openai|deepseek|ollama] "
"Usage: graphify extract <path> [--backend ollama|minimax|nim|gemini|kimi|claude|openai|deepseek] "
"[--model M] [--mode deep] [--out DIR] [--google-workspace] [--no-cluster] "
"[--max-workers N] [--token-budget N] [--max-concurrency N] "
"[--api-timeout S] [--postgres DSN] [--cargo] [--timing]",
Expand Down Expand Up @@ -2170,6 +2165,7 @@ def _parse_float(name: str, raw: str) -> float:
_get_backend_api_key,
)
needs_llm = bool(semantic_files) or dedup_llm
auto_backend = backend is None and needs_llm
if backend is None and needs_llm:
backend = _detect_backend()
if backend is not None and backend not in _BACKENDS:
Expand All @@ -2193,11 +2189,11 @@ def _parse_float(name: str, raw: str) -> float:
hint = (" Or pass --code-only to index just the code "
"(local AST, no key) and skip the non-code files.")
print(
"error: no LLM API key found (" + "; ".join(reasons) + "). "
"Set GEMINI_API_KEY or GOOGLE_API_KEY (gemini), MOONSHOT_API_KEY "
"(kimi), ANTHROPIC_API_KEY (claude), OPENAI_API_KEY (openai), "
"DEEPSEEK_API_KEY (deepseek), or pass --backend. A code-only "
"corpus needs no key." + hint,
"error: no LLM backend found (" + "; ".join(reasons) + "). "
"Graphify auto-detects local Ollama first (default model "
"qwen2.5-coder:3b, <=8B local safety class) and MiniMax as token-plan fallback. "
"Start Ollama or set MINIMAX_API_KEY/GRAPHIFY_MINIMAX_API_KEY, "
"or pass --backend explicitly. A code-only corpus needs no key." + hint,
file=sys.stderr,
)
sys.exit(1)
Expand Down Expand Up @@ -2299,6 +2295,7 @@ def _parse_float(name: str, raw: str) -> float:
"backend": backend,
"model": model,
"root": target,
"allow_minimax_fallback": auto_backend or backend == "ollama",
}
if deep_mode:
corpus_kwargs["deep_mode"] = True
Expand Down Expand Up @@ -2336,6 +2333,25 @@ def _progress(idx: int, total: int, _result: dict) -> None:
)
fresh = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0}

if fresh.get("deferred_semantic"):
queue = graphify_out / "semantic-rebuild-queue.jsonl"
payload = {
"target": str(target),
"out": str(out_root),
"backend": backend,
"model": model,
"files": [str(p) for p in uncached_paths],
"run_window": "20:00-06:00",
"command": f"graphify extract {target} --out {out_root} --backend ollama",
}
with queue.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(payload, sort_keys=True) + "\n")
print(
f"[graphify extract] semantic rebuild deferred; queued night job hint in {queue}",
file=sys.stderr,
)
_chunk_stats["succeeded"] = 1

# on_chunk_done only fires after a chunk succeeds. If fresh
# semantic extraction was requested and no chunks completed,
# fail instead of writing an AST-only graph with exit 0.
Expand Down
18 changes: 12 additions & 6 deletions graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,8 @@ def count_words(path: Path) -> int:
".next", ".nuxt", ".turbo", ".angular",
".idea", ".cache", ".parcel-cache", ".svelte-kit", ".terraform", ".serverless",
".graphify", # graphify's own extraction cache — never index self-generated data
".cursor", ".claude", ".opencode", ".codex", ".codex-research", ".hermes",
".repowise", ".researchclaw_cache", ".serena", ".clawteam", ".aider", ".memu",
".worktrees", # git worktree convention (#947) — sibling checkouts, always redundant
}

Expand Down Expand Up @@ -1134,7 +1136,7 @@ def _resolves_under_root(path: Path, root: Path) -> bool:
return True


def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace: bool | None = None, extra_excludes: list[str] | None = None, cache_root: Path | None = None) -> dict:
def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace: bool | None = None, extra_excludes: list[str] | None = None, cache_root: Path | None = None, count_content: bool = True) -> dict:
root = root.resolve()
if follow_symlinks is None:
follow_symlinks = False
Expand Down Expand Up @@ -1296,7 +1298,8 @@ def _on_walk_error(err: OSError) -> None:
if _is_ignored(md_path, root, ignore_patterns, _cache=ignore_cache):
continue
files[ftype].append(str(md_path))
total_words += _wc(md_path)
if count_content:
total_words += _wc(md_path)
else:
skipped_sensitive.append(str(p) + " [Google Workspace export produced no readable text]")
continue
Expand All @@ -1307,24 +1310,27 @@ def _on_walk_error(err: OSError) -> None:
if _is_ignored(md_path, root, ignore_patterns, _cache=ignore_cache):
continue
files[ftype].append(str(md_path))
total_words += _wc(md_path)
if count_content:
total_words += _wc(md_path)
else:
# Conversion failed (library not installed) - skip with note
skipped_sensitive.append(str(p) + " [office conversion failed - pip install graphifyy[office]]")
continue
files[ftype].append(str(p))
if ftype != FileType.VIDEO:
if count_content and ftype != FileType.VIDEO:
total_words += _wc(p)

for ftype in files:
files[ftype].sort()

total_files = sum(len(v) for v in files.values())
needs_graph = total_words >= CORPUS_WARN_THRESHOLD
needs_graph = total_files > 0 if not count_content else total_words >= CORPUS_WARN_THRESHOLD

# Determine warning - lower bound, upper bound, or sensitive files skipped
warning: str | None = None
if not needs_graph:
if not count_content:
warning = None
elif not needs_graph:
warning = (
f"Corpus is ~{total_words:,} words - fits in a single context window. "
f"You may not need a graph."
Expand Down
Loading