Skip to content
Closed
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
2 changes: 1 addition & 1 deletion graphify/always_on/agents-md.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ This project has a knowledge graph at graphify-out/ with god nodes, community st
When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else.

Rules:
- 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.
- When graphify-out/graph.json exists, run `graphify query "<question>"` before your first grep, search or source-file read for a codebase question, and put this rule in every subagent prompt that explores code. Read raw files only for lines the graph or an earlier read already located. 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.
Expand Down
2 changes: 1 addition & 1 deletion graphify/always_on/claude-md.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.

Rules:
- 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.
- When graphify-out/graph.json exists, run `graphify query "<question>"` before your first grep, search or source-file read for a codebase question, and put this rule in every subagent prompt that explores code. Read raw files only for lines the graph or an earlier read already located. 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.
- 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).
Expand Down
2 changes: 1 addition & 1 deletion graphify/always_on/gemini-md.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.

Rules:
- 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.
- When graphify-out/graph.json exists, run `graphify query "<question>"` before your first grep, search or source-file read for a codebase question, and put this rule in every subagent prompt that explores code. Read raw files only for lines the graph or an earlier read already located. 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.
- 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).
Expand Down
167 changes: 158 additions & 9 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@
"hookEventName": "PreToolUse",
"additionalContext": (
'MANDATORY: graphify-out/graph.json exists. You MUST run '
'`graphify query "<question>"` before grepping raw files. Only grep '
'after graphify has oriented you, or to modify/debug specific lines.'
'`graphify query "<question>"` before grepping raw files. Grep raw '
'files only once you know the exact file and lines you need, from the '
'graph or an earlier read.'
),
}
}, ensure_ascii=False, separators=(",", ":")) + "\n"
Expand All @@ -32,8 +33,8 @@
'MANDATORY: graphify-out/graph.json exists. You MUST run graphify '
'before reading source files. Use: `graphify query "<question>"` '
'(scoped subgraph), `graphify explain "<concept>"`, or '
'`graphify path "<A>" "<B>"`. Only read raw files after graphify has '
'oriented you, or to modify/debug specific lines. This rule applies to '
'`graphify path "<A>" "<B>"`. Read raw files only once graphify has '
'shown you the exact file and lines you need. This rule applies to '
'subagents too — include it in every subagent prompt involving code '
'exploration.'
),
Expand Down Expand Up @@ -62,8 +63,8 @@
'graphify strict mode: this project has a fresh knowledge graph that covers '
'this file. Run `graphify query "<your question>"` (or `graphify explain` / '
'`graphify path`) FIRST to orient yourself, then re-issue this Read — it '
'will be allowed. This block fires at most once per session; reading raw '
'files to modify or debug specific lines is fine after one query. Apply the '
'will be allowed. This block fires at most once per session; after one '
'query, read raw files for the exact lines the graph pointed you to. Apply the '
'same rule in any subagent prompt that explores code.'
),
}
Expand Down Expand Up @@ -153,6 +154,154 @@ def _default_graph_path() -> str:
return str(Path(_GRAPHIFY_OUT) / "graph.json")


# Hint for a firing nudge: the graph nodes behind this very call and a command
# to run, so the nudge shows what the graph knows instead of only asking for it.
# A direct label scan of graph.json, not `graphify query`: loading the full query
# stack costs ~1.5 s on a 15k-node graph, too slow for a per-call hook. The scan
# costs ~0.5 s on an 18 MiB graph; bigger graphs than the cap get the plain nudge.
_HINT_MAX_NODES = 5
_HINT_GRAPH_MAX_BYTES = 24 * 1024 * 1024
_HINT_TERM_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]{2,}")
_HINT_SAFE_ARG_RE = re.compile(r"[\w.\-() ]+")
# Declaration keywords a pattern like `def load_config` carries; never the target.
_HINT_KEYWORDS = frozenset({
"def", "class", "function", "func", "const", "let", "var", "import", "export",
"async", "return", "struct", "interface", "type", "enum", "impl", "pub",
})
# grep/rg/ag/ack options whose value is the next argument, never the pattern.
_HINT_VALUE_FLAGS = frozenset({
"-t", "-T", "-g", "--type", "--type-not", "--glob", "--iglob", "-A", "-B", "-C",
"-m", "--max-count", "-j", "--threads", "-f", "--file", "--include", "--exclude",
"--exclude-dir", "--max-depth", "-d", "-D", "--color", "--colour",
})


def _pattern_terms(pattern: str) -> list[str]:
"""Identifier-like words in a search pattern (regex escapes and declaration
keywords dropped), at most three."""
words = _HINT_TERM_RE.findall(re.sub(r"\\.", " ", pattern))
return [w for w in words if w.lower() not in _HINT_KEYWORDS][:3]


def _rel_to_root(file_path: str, root: "Path") -> "str | None":
"""The read target as a root-relative POSIX path, the form graph.json stores."""
if not file_path:
return None
try:
p = Path(file_path.replace("\\", "/"))
return (p if p.is_absolute() else root / p).resolve().relative_to(root).as_posix()
except (ValueError, OSError, RuntimeError):
return None


def _search_hint_terms(t: dict) -> list[str]:
"""Words the agent searched for: the Grep `pattern`, or the pattern argument of
a shell content search. Empty when there is nothing to go on."""
cmd = str(t.get("command") or "")
if not cmd:
return _pattern_terms(str(t.get("pattern") or ""))
import shlex
try:
tokens = shlex.split(cmd)
except ValueError:
return []
for i, tok in enumerate(tokens):
name = tok.replace("\\", "/").rsplit("/", 1)[-1].lower().removesuffix(".exe")
# Content searches only: find/fd match file names, which labels do not, and
# a search fed by a pipe filters that output, not the code.
if name not in _SEARCH_COMMANDS or name in ("find", "fd") or (i and tokens[i - 1] == "|"):
continue
rest = iter(tokens[i + 1:])
for arg in rest:
if arg in ("-e", "--regexp"):
return _pattern_terms(next(rest, ""))
if arg in _HINT_VALUE_FLAGS:
next(rest, None)
continue
if arg.startswith("-"):
continue
return _pattern_terms(arg) # the first positional argument is the pattern
return []
return []


def _hint_text(value: object, cap: int) -> str:
"""A graph field as one inert line: graph.json is repository content, so no
newline, control character or backtick may reach the model-facing hint."""
text = re.sub(r"[\x00-\x1f\x7f`]", " ", str(value))
text = " ".join(text.split())
return text if len(text) <= cap else text[: cap - 1] + "…"


def _graph_hint(terms: "list[str]", rel_file: "str | None") -> tuple[str, list[str]]:
"""(command, node lines) for a nudge: nodes whose label matches the searched
terms, or the symbols in the file being read. ("", []) for an oversized graph
or no match."""
from graphify.paths import out_path
p = out_path("graph.json")
if p.stat().st_size > _HINT_GRAPH_MAX_BYTES:
return "", []
nodes = json.loads(p.read_text(encoding="utf-8")).get("nodes")
nodes = [n for n in nodes if isinstance(n, dict)] if isinstance(nodes, list) else []

def key(n: dict) -> str:
return str(n.get("label") or "").lower().removesuffix("()").lstrip(".")

def line_of(n: dict) -> int:
m = re.match(r"L(\d+)", str(n.get("source_location") or ""))
return int(m.group(1)) if m else 0

command = ""
if rel_file:
base = rel_file.rsplit("/", 1)[-1]
picked = sorted((n for n in nodes if n.get("source_file") == rel_file and n.get("label") != base),
key=line_of)[:_HINT_MAX_NODES]
if picked and _HINT_SAFE_ARG_RE.fullmatch(base):
command = f'graphify explain "{base}"'
else:
lowered = [term.lower() for term in terms]
# Exact label matches first, then labels containing a term; shorter labels first.
# Short terms (`src`, `app`) only match exactly: as substrings they hit every path.
ranked = sorted(
((0 if key(n) in lowered else 1, len(key(n)), n) for n in nodes
if key(n) in lowered or any(len(term) >= 5 and term in key(n) for term in lowered)),
key=lambda r: (r[0], r[1]),
)
picked = [n for _, _, n in ranked[:_HINT_MAX_NODES]]
# An exact match's label is a search term ([A-Za-z0-9_]) plus "." or "()": safe to quote.
if picked and ranked[0][0] == 0:
command = f'graphify explain "{picked[0].get("label")}"'
elif picked:
command = f'graphify query "{" ".join(terms)}"'
lines = []
for n in picked:
loc = _hint_text(n.get("source_location") or "", 20)
src = _hint_text(n.get("source_file") or "?", 120)
lines.append(f"- {_hint_text(n.get('label') or '', 80)} ({src}{' ' + loc if loc else ''})")
return command, lines


def _with_hint(nudge: str, terms: "list[str] | None" = None, rel_file: "str | None" = None) -> str:
"""Append the call-specific command and matching graph nodes to a nudge or deny.
Any failure returns the nudge unchanged: a hint must never cost the nudge."""
if not terms and not rel_file:
return nudge
try:
command, lines = _graph_hint(terms or [], rel_file)
if not command and not lines:
return nudge
d = json.loads(nudge)
out = d["hookSpecificOutput"]
key = "permissionDecisionReason" if "permissionDecisionReason" in out else "additionalContext"
extra = f"\nStart with: `{command}`" if command else ""
if lines:
extra += "\nWhat the graph already has for this call:\n" + "\n".join(lines)
out[key] += extra
return json.dumps(d, ensure_ascii=False, separators=(",", ":")) + "\n"
except Exception:
return nudge


def _stamped_manifest_files(
files_by_type: dict[str, list[str]],
sem_result: dict,
Expand Down Expand Up @@ -994,7 +1143,7 @@ def _run_hook_guard(kind: str, strict: bool = False) -> None:
is_bash_search = bool(cmd_str) and _bash_invokes_search(cmd_str)
if (is_grep_tool or is_bash_search) and out_path("graph.json").is_file() \
and _nudge_allowed(d):
sys.stdout.write(_nudge_for_out(_SEARCH_NUDGE))
sys.stdout.write(_with_hint(_nudge_for_out(_SEARCH_NUDGE), terms=_search_hint_terms(t)))
elif kind == "read":
vals = [str(t.get("file_path") or ""), str(t.get("pattern") or ""), str(t.get("path") or "")]
tails = [
Expand Down Expand Up @@ -1114,10 +1263,10 @@ def _is_under_out(target: str) -> bool:
and not _query_stamp_fresh() \
and _target_is_indexed(fp, root) \
and _mark_session_denied(str(d.get("session_id") or "")):
sys.stdout.write(_READ_DENY)
sys.stdout.write(_with_hint(_READ_DENY, rel_file=_rel_to_root(fp, root)))
return
if _nudge_allowed(d):
sys.stdout.write(_nudge_for_out(_READ_NUDGE))
sys.stdout.write(_with_hint(_nudge_for_out(_READ_NUDGE), rel_file=_rel_to_root(fp, root)))
except Exception:
pass

Expand Down
Loading
Loading