graphsnip 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphsnip/__init__.py +3 -0
- graphsnip/__main__.py +4 -0
- graphsnip/baseline.py +89 -0
- graphsnip/bench.py +208 -0
- graphsnip/cli.py +368 -0
- graphsnip/dashboard.py +322 -0
- graphsnip/engine.py +453 -0
- graphsnip/hooks.py +178 -0
- graphsnip/installer.py +291 -0
- graphsnip/ledger.py +88 -0
- graphsnip/mcp_server.py +146 -0
- graphsnip/project.py +308 -0
- graphsnip/py.typed +0 -0
- graphsnip/shellfilter.py +116 -0
- graphsnip/slicer.py +217 -0
- graphsnip/tokens.py +35 -0
- graphsnip-0.1.0.dist-info/METADATA +186 -0
- graphsnip-0.1.0.dist-info/RECORD +23 -0
- graphsnip-0.1.0.dist-info/WHEEL +5 -0
- graphsnip-0.1.0.dist-info/entry_points.txt +2 -0
- graphsnip-0.1.0.dist-info/licenses/LICENSE +202 -0
- graphsnip-0.1.0.dist-info/licenses/NOTICE +7 -0
- graphsnip-0.1.0.dist-info/top_level.txt +1 -0
graphsnip/__init__.py
ADDED
graphsnip/__main__.py
ADDED
graphsnip/baseline.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""The honest baseline.
|
|
2
|
+
|
|
3
|
+
Graphify's own benchmark compares a query against reading the *entire corpus*,
|
|
4
|
+
which no agent actually does. graphsnip instead prices what an agent without
|
|
5
|
+
the graph would realistically do for the same question: grep for the terms,
|
|
6
|
+
then read the best-matching files in full (capped at `max_files`).
|
|
7
|
+
|
|
8
|
+
Both numbers are reported; the headline uses this one because it is the
|
|
9
|
+
defensible one.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from graphsnip.project import MAX_FILE_BYTES, Project
|
|
16
|
+
from graphsnip.tokens import count_tokens
|
|
17
|
+
|
|
18
|
+
_STOP = set("""
|
|
19
|
+
a an and are as at be by can do does for from how i in is it its of on or that the this to was what when where which who why with
|
|
20
|
+
use used using work works get gets make makes find show tell explain code file files function functions class classes method
|
|
21
|
+
methods please about into over under all any not our your their there then than also
|
|
22
|
+
""".split())
|
|
23
|
+
_WORD = re.compile(r"[A-Za-z][A-Za-z0-9_]{2,}")
|
|
24
|
+
_CAMEL = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def query_terms(text: str, limit: int = 8) -> list[str]:
|
|
28
|
+
terms: list[str] = []
|
|
29
|
+
for w in _WORD.findall(text or ""):
|
|
30
|
+
parts = [w.lower()]
|
|
31
|
+
if "_" in w or re.search(r"[a-z][A-Z]", w):
|
|
32
|
+
parts.append(w.lower())
|
|
33
|
+
parts += [p.lower() for p in _CAMEL.findall(w.replace("_", " ")) if len(p) >= 3]
|
|
34
|
+
for p in parts:
|
|
35
|
+
if p not in _STOP and len(p) >= 3 and p not in terms:
|
|
36
|
+
terms.append(p)
|
|
37
|
+
return terms[:limit]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Baseline:
|
|
41
|
+
def __init__(self, project: Project):
|
|
42
|
+
self.p = project
|
|
43
|
+
self._text: dict[str, str] | None = None
|
|
44
|
+
self._tok: dict[str, int] = {}
|
|
45
|
+
|
|
46
|
+
def _index(self) -> dict[str, str]:
|
|
47
|
+
if self._text is None:
|
|
48
|
+
idx: dict[str, str] = {}
|
|
49
|
+
for f in self.p.files()[:20000]:
|
|
50
|
+
try:
|
|
51
|
+
if f.stat().st_size > MAX_FILE_BYTES:
|
|
52
|
+
continue
|
|
53
|
+
idx[self.p.rel(f)] = f.read_text(encoding="utf-8", errors="replace")
|
|
54
|
+
except Exception:
|
|
55
|
+
continue
|
|
56
|
+
self._text = idx
|
|
57
|
+
return self._text
|
|
58
|
+
|
|
59
|
+
def file_tokens(self, rel: str) -> int:
|
|
60
|
+
if rel not in self._tok:
|
|
61
|
+
t = self._index().get(rel)
|
|
62
|
+
if t is None:
|
|
63
|
+
t = self.p.read(rel) or ""
|
|
64
|
+
self._tok[rel] = count_tokens(t)
|
|
65
|
+
return self._tok[rel]
|
|
66
|
+
|
|
67
|
+
def grep_read(self, terms: list[str], max_files: int = 8) -> tuple[int, list[str]]:
|
|
68
|
+
"""Tokens for: grep each term, read the top `max_files` matching files in full."""
|
|
69
|
+
if not terms:
|
|
70
|
+
return 0, []
|
|
71
|
+
scored: list[tuple[int, str]] = []
|
|
72
|
+
for rel, text in self._index().items():
|
|
73
|
+
low = text.lower()
|
|
74
|
+
s = 0
|
|
75
|
+
for t in terms:
|
|
76
|
+
c = low.count(t)
|
|
77
|
+
if c:
|
|
78
|
+
s += min(c, 10)
|
|
79
|
+
if s:
|
|
80
|
+
scored.append((s, rel))
|
|
81
|
+
scored.sort(key=lambda x: (-x[0], x[1]))
|
|
82
|
+
chosen = [rel for _s, rel in scored[:max_files]]
|
|
83
|
+
tokens = sum(self.file_tokens(r) for r in chosen) + 150 # + the grep listing itself
|
|
84
|
+
return tokens, chosen
|
|
85
|
+
|
|
86
|
+
def symbol_name_files(self, name: str, max_files: int = 10) -> tuple[int, list[str]]:
|
|
87
|
+
"""Tokens for: grep a symbol name, read every matching file (capped)."""
|
|
88
|
+
n = re.sub(r"\(.*\)\s*$", "", name).lstrip(".").split(".")[-1].lower()
|
|
89
|
+
return self.grep_read([n], max_files=max_files) if len(n) >= 2 else (0, [])
|
graphsnip/bench.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Offline, reproducible benchmark.
|
|
2
|
+
|
|
3
|
+
For each task it prices three approaches and checks the answer is actually there:
|
|
4
|
+
|
|
5
|
+
naive grep the terms, read the best-matching files in full (capped) <- realistic baseline
|
|
6
|
+
graphify Graphify's own `query` (budgeted) <- graph map, no source code
|
|
7
|
+
graphsnip pack / find+symbol / impact+symbol <- map + exact code
|
|
8
|
+
|
|
9
|
+
Quality is checked, not assumed: a task counts as answered only when the
|
|
10
|
+
expected symbol shows up in the output (and, for graphsnip, with its source).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from graphsnip.baseline import Baseline, query_terms
|
|
20
|
+
from graphsnip.engine import Engine
|
|
21
|
+
from graphsnip.project import Project
|
|
22
|
+
from graphsnip.tokens import count_tokens, method
|
|
23
|
+
|
|
24
|
+
TYPES = ("understand", "impact", "locate")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _short(label: str) -> str:
|
|
28
|
+
return re.sub(r"\([^()]*\)\s*$", "", label).strip()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolvable(G, project: Project, name: str) -> bool:
|
|
32
|
+
"""Auto-generated tasks must be answerable by the tools; user-written tasks are never filtered."""
|
|
33
|
+
try:
|
|
34
|
+
from graphify.affected import resolve_seed_outcome
|
|
35
|
+
from graphify.serve import _resolve_single_node
|
|
36
|
+
|
|
37
|
+
nid, err = _resolve_single_node(G, name.split(".")[-1] if "." not in name else name)
|
|
38
|
+
seed, _ = resolve_seed_outcome(G, name.split(".")[-1], project.root)
|
|
39
|
+
return nid is not None and not err and seed is not None
|
|
40
|
+
except Exception:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def default_tasks(project: Project, n: int = 8) -> list[dict]:
|
|
45
|
+
eng = Engine(project.root, source="bench", auto_refresh=False)
|
|
46
|
+
G = project.graph()
|
|
47
|
+
seen_files: dict[str, int] = {}
|
|
48
|
+
picks = []
|
|
49
|
+
idx = project.label_index()
|
|
50
|
+
from graphsnip.project import bare
|
|
51
|
+
|
|
52
|
+
for row in project.top_symbols(80):
|
|
53
|
+
lab = _short(str(row["label"]))
|
|
54
|
+
if len(lab) < 4 or lab.startswith("_") or row["file"] in seen_files and seen_files[row["file"]] >= 1:
|
|
55
|
+
continue
|
|
56
|
+
if len(idx.get(bare(lab), [])) != 1: # ambiguous names make impact/symbol lookups refuse; skip them
|
|
57
|
+
continue
|
|
58
|
+
if not _resolvable(G, project, eng._display(G, row["id"]).replace("()", "")):
|
|
59
|
+
continue
|
|
60
|
+
seen_files[row["file"]] = seen_files.get(row["file"], 0) + 1
|
|
61
|
+
picks.append(row)
|
|
62
|
+
if len(picks) >= n:
|
|
63
|
+
break
|
|
64
|
+
tasks = []
|
|
65
|
+
for i, row in enumerate(picks):
|
|
66
|
+
name = _short(eng._display(G, row["id"]))
|
|
67
|
+
t = TYPES[i % 3]
|
|
68
|
+
q = {"understand": f"how does {name} work and what does it depend on",
|
|
69
|
+
"impact": f"what breaks if I change {name}",
|
|
70
|
+
"locate": f"where is {name} defined and where is it used"}[t]
|
|
71
|
+
tasks.append({"id": f"t{i + 1}", "type": t, "question": q, "expect": [name.split(".")[-1]],
|
|
72
|
+
"target_file": row["file"]})
|
|
73
|
+
return tasks
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_tasks(project: Project, path: str | None) -> list[dict]:
|
|
77
|
+
p = Path(path) if path else project.gs_dir / "tasks.json"
|
|
78
|
+
if p.exists():
|
|
79
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
80
|
+
tasks = data["tasks"] if isinstance(data, dict) else data
|
|
81
|
+
for i, t in enumerate(tasks):
|
|
82
|
+
t.setdefault("id", f"t{i + 1}")
|
|
83
|
+
t.setdefault("type", "understand")
|
|
84
|
+
t.setdefault("expect", [])
|
|
85
|
+
return tasks
|
|
86
|
+
return default_tasks(project)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def save_default_tasks(project: Project) -> Path:
|
|
90
|
+
project.gs_dir.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
p = project.gs_dir / "tasks.json"
|
|
92
|
+
p.write_text(json.dumps({"_note": "Edit freely. type: understand | impact | locate. expect: symbol names that must appear in a correct answer.",
|
|
93
|
+
"tasks": default_tasks(project)}, indent=2) + "\n", encoding="utf-8")
|
|
94
|
+
return p
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def run_bench(root: str = ".", tasks_path: str | None = None, quiet: bool = False) -> dict:
|
|
98
|
+
eng = Engine(root, source="bench")
|
|
99
|
+
p = eng.p
|
|
100
|
+
p.ensure_graph(True)
|
|
101
|
+
G = p.graph()
|
|
102
|
+
tasks = load_tasks(p, tasks_path)
|
|
103
|
+
base = Baseline(p)
|
|
104
|
+
eng.ledger.clear(source="bench")
|
|
105
|
+
from graphify.serve import _query_graph_text
|
|
106
|
+
|
|
107
|
+
corpus_tokens = sum(base.file_tokens(p.rel(f)) for f in p.files()[:20000])
|
|
108
|
+
rows = []
|
|
109
|
+
t0 = time.time()
|
|
110
|
+
for t in tasks:
|
|
111
|
+
q = t["question"]
|
|
112
|
+
expect = [e.lower() for e in t.get("expect", [])]
|
|
113
|
+
e2 = Engine(root, source="bench", auto_refresh=False) # fresh session per task
|
|
114
|
+
naive_tokens, naive_files = base.grep_read(query_terms(q))
|
|
115
|
+
naive_text = " ".join((p.read(f) or "") for f in naive_files).lower()
|
|
116
|
+
naive_hit = all(x in naive_text for x in expect) if expect else bool(naive_files)
|
|
117
|
+
|
|
118
|
+
gtext = _query_graph_text(G, q, mode="bfs", depth=3, token_budget=2000, graph_path=str(p.graph_json))
|
|
119
|
+
gtokens = 0 if gtext.startswith("No matching") else count_tokens(gtext)
|
|
120
|
+
ghit = all(x in gtext.lower() for x in expect) if expect else bool(gtokens)
|
|
121
|
+
# what an agent using Graphify alone then does: read the files its top hits point to
|
|
122
|
+
g_files: list[str] = []
|
|
123
|
+
for m in re.finditer(r"src=([^\s\]]+)", gtext):
|
|
124
|
+
f = m.group(1)
|
|
125
|
+
if f and f not in g_files and (p.root / f).is_file():
|
|
126
|
+
g_files.append(f)
|
|
127
|
+
if len(g_files) >= 3:
|
|
128
|
+
break
|
|
129
|
+
gread_tokens = (gtokens + sum(base.file_tokens(f) for f in g_files)) if gtokens else 0
|
|
130
|
+
|
|
131
|
+
typ = t.get("type", "understand")
|
|
132
|
+
name = (t.get("expect") or [q])[0]
|
|
133
|
+
parts = []
|
|
134
|
+
if typ == "understand":
|
|
135
|
+
parts.append(e2.pack(q, budget=3500))
|
|
136
|
+
elif typ == "impact":
|
|
137
|
+
parts.append(e2.impact(name))
|
|
138
|
+
parts.append(e2.symbol(name))
|
|
139
|
+
else:
|
|
140
|
+
parts.append(e2.find(q, budget=1200))
|
|
141
|
+
parts.append(e2.symbol(name))
|
|
142
|
+
gs_text = "\n".join(r.text for r in parts if r.ok)
|
|
143
|
+
gs_tokens = sum(r.returned for r in parts if r.ok)
|
|
144
|
+
has_code = "```" in gs_text
|
|
145
|
+
gs_hit = (all(x in gs_text.lower() for x in expect) if expect else bool(gs_tokens)) and has_code
|
|
146
|
+
# second read of the same symbol in the same session
|
|
147
|
+
again = e2.symbol(name)
|
|
148
|
+
rows.append({
|
|
149
|
+
"id": t["id"], "type": typ, "question": q, "expect": t.get("expect", []),
|
|
150
|
+
"naive_tokens": naive_tokens, "naive_files": len(naive_files), "naive_hit": naive_hit,
|
|
151
|
+
"graphify_tokens": gtokens, "graphify_hit": ghit, "graphify_read_tokens": gread_tokens,
|
|
152
|
+
"graphsnip_tokens": gs_tokens, "graphsnip_hit": gs_hit, "graphsnip_has_code": has_code,
|
|
153
|
+
"reread_tokens": again.returned if again.ok else None,
|
|
154
|
+
"reduction_vs_naive": round(naive_tokens / gs_tokens, 1) if gs_tokens else None,
|
|
155
|
+
})
|
|
156
|
+
ok = [r for r in rows if r["graphsnip_tokens"]]
|
|
157
|
+
tot_n = sum(r["naive_tokens"] for r in ok)
|
|
158
|
+
tot_c = sum(r["graphsnip_tokens"] for r in ok)
|
|
159
|
+
tot_g = sum(r["graphify_tokens"] for r in rows)
|
|
160
|
+
tot_gr = sum(r["graphify_read_tokens"] for r in rows)
|
|
161
|
+
result = {
|
|
162
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
163
|
+
"root": str(p.root),
|
|
164
|
+
"token_method": method(),
|
|
165
|
+
"baseline_definition": "grep the question's terms, read the top 8 matching files in full",
|
|
166
|
+
"graph": p.graph_stats(),
|
|
167
|
+
"corpus_tokens": corpus_tokens,
|
|
168
|
+
"tasks": rows,
|
|
169
|
+
"totals": {
|
|
170
|
+
"naive_tokens": tot_n, "graphsnip_tokens": tot_c, "graphify_tokens": tot_g, "graphify_read_tokens": tot_gr,
|
|
171
|
+
"saved_vs_graphify_read_pct": round(100 * (tot_gr - tot_c) / tot_gr, 1) if tot_gr else 0.0,
|
|
172
|
+
"saved_tokens": tot_n - tot_c,
|
|
173
|
+
"saved_pct": round(100 * (tot_n - tot_c) / tot_n, 1) if tot_n else 0.0,
|
|
174
|
+
"ratio": round(tot_n / tot_c, 1) if tot_c else 0.0,
|
|
175
|
+
"vs_corpus_ratio": round(corpus_tokens / (tot_c / len(ok)), 1) if ok and tot_c else 0.0,
|
|
176
|
+
"answered": sum(1 for r in rows if r["graphsnip_hit"]),
|
|
177
|
+
"naive_answered": sum(1 for r in rows if r["naive_hit"]),
|
|
178
|
+
"graphify_answered": sum(1 for r in rows if r["graphify_hit"]),
|
|
179
|
+
"tasks": len(rows),
|
|
180
|
+
"seconds": round(time.time() - t0, 1),
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
p.gs_dir.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
(p.gs_dir / "bench.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def format_table(res: dict) -> str:
|
|
189
|
+
rows = res["tasks"]
|
|
190
|
+
t = res["totals"]
|
|
191
|
+
w = max(8, min(44, max(len(r["question"]) for r in rows)))
|
|
192
|
+
hdr = f"{'task':<{w}} {'grep+read':>9} {'graphify':>8} {'gfy+read':>8} {'graphsnip':>9} {'saved':>6} ok"
|
|
193
|
+
out = [hdr, "-" * len(hdr)]
|
|
194
|
+
for r in rows:
|
|
195
|
+
saved = f"{100 * (1 - r['graphsnip_tokens'] / r['naive_tokens']):.0f}%" if r["naive_tokens"] and r["graphsnip_tokens"] else "-"
|
|
196
|
+
out.append(f"{r['question'][:w]:<{w}} {r['naive_tokens']:>9,} {r['graphify_tokens']:>8,} {r['graphify_read_tokens']:>8,} "
|
|
197
|
+
f"{r['graphsnip_tokens']:>9,} {saved:>6} {'✓' if r['graphsnip_hit'] else '✗'}")
|
|
198
|
+
out.append("-" * len(hdr))
|
|
199
|
+
out.append(f"{'TOTAL':<{w}} {t['naive_tokens']:>9,} {t['graphify_tokens']:>8,} {t['graphify_read_tokens']:>8,} "
|
|
200
|
+
f"{t['graphsnip_tokens']:>9,} {t['saved_pct']:>5}% {t['answered']}/{t['tasks']}")
|
|
201
|
+
out.append(f"\ngrep+read = {res['baseline_definition']} (what an agent does with no graph)")
|
|
202
|
+
out.append("graphify = Graphify `query` alone: a map with no source code")
|
|
203
|
+
out.append("gfy+read = that map, then reading the whole files its top hits point to (3 files max)")
|
|
204
|
+
out.append("graphsnip = exact code, ranked and budgeted (pack / find+symbol / impact+symbol)")
|
|
205
|
+
out.append(f"Saved column is vs grep+read; vs graphify+read it is {t['saved_vs_graphify_read_pct']}%. Tokens: {res['token_method']}.")
|
|
206
|
+
out.append(f"Answered (expected symbol + source present): graphsnip {t['answered']}/{t['tasks']}, "
|
|
207
|
+
f"grep+read {t['naive_answered']}/{t['tasks']}.")
|
|
208
|
+
return "\n".join(out)
|
graphsnip/cli.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""graphsnip command line."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import importlib.metadata as md
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import webbrowser
|
|
10
|
+
|
|
11
|
+
from graphsnip import __version__
|
|
12
|
+
from graphsnip import ledger as _ledger
|
|
13
|
+
from graphsnip.engine import Engine, Result
|
|
14
|
+
from graphsnip.project import GraphError, Project
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _common() -> argparse.ArgumentParser:
|
|
18
|
+
c = argparse.ArgumentParser(add_help=False)
|
|
19
|
+
c.add_argument("--root", default=".", help="project root (default: current directory)")
|
|
20
|
+
return c
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _emit(r: Result, quiet: bool = False) -> int:
|
|
24
|
+
print(r.text)
|
|
25
|
+
if r.ok and not quiet:
|
|
26
|
+
pct = f"{100 * (1 - r.returned / r.baseline):.0f}%" if r.baseline else "n/a"
|
|
27
|
+
note = " (duplicate, suppressed)" if r.deduped else ""
|
|
28
|
+
sys.stderr.write(f"\n[graphsnip] ~{r.returned:,} tokens returned vs ~{r.baseline:,} for grep+read → saved {pct}{note}\n")
|
|
29
|
+
return 0 if r.ok else 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cmd_build(a) -> int:
|
|
33
|
+
p = Project(a.root)
|
|
34
|
+
s = p.build(force=a.force)
|
|
35
|
+
print(f"Graph ready ({s['mode']}, {s['seconds']}s): {s['nodes']:,} nodes, {s['edges']:,} edges, "
|
|
36
|
+
f"{s['communities']:,} communities, {s['symbols']:,} code symbols.")
|
|
37
|
+
print(f" map: {p.graph_html}")
|
|
38
|
+
print(f" report: {p.report_md}")
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cmd_status(a) -> int:
|
|
43
|
+
p = Project(a.root)
|
|
44
|
+
if not p.has_graph():
|
|
45
|
+
print("No graph yet. Run `graphsnip build` or `graphsnip init`.")
|
|
46
|
+
return 1
|
|
47
|
+
s = p.graph_stats()
|
|
48
|
+
print(f"Project: {p.root}")
|
|
49
|
+
print(f"Graph: {s['nodes']:,} nodes · {s['edges']:,} edges · {s['communities']:,} communities · {s['symbols']:,} code symbols")
|
|
50
|
+
print(f"Stale: {'yes, run `graphsnip build` (the server refreshes automatically)' if p.is_stale(0) else 'no'}")
|
|
51
|
+
print(f"Mode: {p.config()['mode']}")
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_find(a) -> int:
|
|
56
|
+
return _emit(Engine(a.root, source="cli").find(a.question, budget=a.budget, depth=a.depth), a.quiet)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cmd_symbol(a) -> int:
|
|
60
|
+
return _emit(Engine(a.root, source="cli").symbol(a.name, max_lines=a.max_lines, docs="full" if a.full_docs else "short"), a.quiet)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_pack(a) -> int:
|
|
64
|
+
return _emit(Engine(a.root, source="cli").pack(a.task, budget=a.budget), a.quiet)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_impact(a) -> int:
|
|
68
|
+
return _emit(Engine(a.root, source="cli").impact(a.name, depth=a.depth), a.quiet)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_outline(a) -> int:
|
|
72
|
+
return _emit(Engine(a.root, source="cli").outline(a.path), a.quiet)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cmd_stats(a) -> int:
|
|
76
|
+
p = Project(a.root)
|
|
77
|
+
recs = _ledger.Ledger(p.gs_dir).read()
|
|
78
|
+
price = float(p.config().get("price_per_mtok", 3.0))
|
|
79
|
+
if a.json:
|
|
80
|
+
print(json.dumps({"live": _ledger.summarize(recs, price, {"agent", "cli", "shell"}),
|
|
81
|
+
"bench": _ledger.summarize(recs, price, {"bench"})}, indent=2, default=str))
|
|
82
|
+
return 0
|
|
83
|
+
for title, src in (("Live usage (agents + CLI + shell)", {"agent", "cli", "shell"}), ("Benchmark", {"bench"})):
|
|
84
|
+
s = _ledger.summarize(recs, price, src)
|
|
85
|
+
print(f"\n{title}")
|
|
86
|
+
if not s["calls"]:
|
|
87
|
+
print(" nothing recorded yet")
|
|
88
|
+
continue
|
|
89
|
+
print(f" {s['calls']} calls · {s['returned']:,} tokens returned vs {s['baseline']:,} baseline")
|
|
90
|
+
print(f" saved {s['saved']:,} tokens ({s['pct']}%, {s['ratio']}x) ≈ ${s['usd_saved']} at ${price:g}/M input tokens")
|
|
91
|
+
for tool, t in sorted(s["by_tool"].items(), key=lambda kv: -(kv[1]["baseline"] - kv[1]["returned"])):
|
|
92
|
+
print(f" {tool:<8} {t['calls']:>4} calls {t['returned']:>9,} vs {t['baseline']:>10,}")
|
|
93
|
+
if s["hooks"]:
|
|
94
|
+
print(" hooks: " + ", ".join(f"{k} ×{v}" for k, v in s["hooks"].items()))
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def cmd_report(a) -> int:
|
|
99
|
+
from graphsnip.dashboard import write_dashboard
|
|
100
|
+
|
|
101
|
+
p = Project(a.root)
|
|
102
|
+
if not p.has_graph():
|
|
103
|
+
print("No graph yet. Run `graphsnip build` first.")
|
|
104
|
+
return 1
|
|
105
|
+
out = write_dashboard(p, bundle=a.bundle)
|
|
106
|
+
print(f"Dashboard written: {out}")
|
|
107
|
+
if a.bundle:
|
|
108
|
+
print("The folder is self-contained (dashboard.html + graph.html): zip or share it as is.")
|
|
109
|
+
if a.open:
|
|
110
|
+
webbrowser.open(out.as_uri())
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_bench(a) -> int:
|
|
115
|
+
from graphsnip.bench import format_table, run_bench, save_default_tasks
|
|
116
|
+
|
|
117
|
+
p = Project(a.root)
|
|
118
|
+
p.ensure_graph(True)
|
|
119
|
+
if a.init_tasks or (not (p.gs_dir / "tasks.json").exists() and not a.tasks):
|
|
120
|
+
path = save_default_tasks(p)
|
|
121
|
+
print(f"Wrote starter tasks to {path} (edit them to use your own questions).\n")
|
|
122
|
+
res = run_bench(a.root, a.tasks)
|
|
123
|
+
if a.json:
|
|
124
|
+
print(json.dumps(res, indent=2))
|
|
125
|
+
else:
|
|
126
|
+
print(format_table(res))
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_demo(a) -> int:
|
|
131
|
+
from graphsnip.bench import format_table, run_bench, save_default_tasks
|
|
132
|
+
from graphsnip.dashboard import write_dashboard
|
|
133
|
+
|
|
134
|
+
p = Project(a.root)
|
|
135
|
+
if not p.has_graph():
|
|
136
|
+
print("Building the Graphify graph (code only, local, no LLM)…")
|
|
137
|
+
s = p.build()
|
|
138
|
+
print(f" {s['nodes']:,} nodes, {s['edges']:,} edges in {s['seconds']}s\n")
|
|
139
|
+
if not (p.gs_dir / "tasks.json").exists():
|
|
140
|
+
save_default_tasks(p)
|
|
141
|
+
print("Running benchmark…\n")
|
|
142
|
+
res = run_bench(a.root)
|
|
143
|
+
print(format_table(res))
|
|
144
|
+
out = write_dashboard(p, bundle=a.bundle)
|
|
145
|
+
print(f"\nDashboard: {out}")
|
|
146
|
+
if a.open:
|
|
147
|
+
webbrowser.open(out.as_uri())
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cmd_serve(a) -> int:
|
|
152
|
+
from graphsnip.mcp_server import serve
|
|
153
|
+
|
|
154
|
+
serve(a.root)
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_hook(a) -> int:
|
|
159
|
+
from graphsnip.hooks import main_stdin
|
|
160
|
+
|
|
161
|
+
return main_stdin(a.root)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cmd_run(a) -> int:
|
|
165
|
+
from graphsnip.shellfilter import run
|
|
166
|
+
|
|
167
|
+
argv = list(a.cmd)
|
|
168
|
+
if argv and argv[0] == "--":
|
|
169
|
+
argv = argv[1:]
|
|
170
|
+
return run(argv, a.root)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _clients(s: str) -> list[str]:
|
|
174
|
+
return [x.strip().lower() for x in s.split(",") if x.strip()]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def cmd_install(a) -> int:
|
|
178
|
+
from graphsnip.installer import install
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
for ln in install(a.root, _clients(a.clients), dry_run=a.dry_run, allow_tools=a.allow_tools,
|
|
182
|
+
codex_scope=a.codex_scope, strict=a.strict, portable=a.portable):
|
|
183
|
+
print(ln)
|
|
184
|
+
except (ValueError, RuntimeError) as e:
|
|
185
|
+
print(f"error: {e}", file=sys.stderr)
|
|
186
|
+
return 2
|
|
187
|
+
if not a.dry_run:
|
|
188
|
+
print("\nRestart your agent/IDE so it picks up the MCP server. `graphsnip doctor` checks the setup.")
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def cmd_uninstall(a) -> int:
|
|
193
|
+
from graphsnip.installer import uninstall
|
|
194
|
+
|
|
195
|
+
for ln in uninstall(a.root, _clients(a.clients), a.codex_scope):
|
|
196
|
+
print(ln)
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def cmd_init(a) -> int:
|
|
201
|
+
from graphsnip.bench import format_table, run_bench, save_default_tasks
|
|
202
|
+
from graphsnip.dashboard import write_dashboard
|
|
203
|
+
from graphsnip.installer import install
|
|
204
|
+
|
|
205
|
+
p = Project(a.root)
|
|
206
|
+
if a.dry_run:
|
|
207
|
+
print("1/4 [dry-run] skipping graph build")
|
|
208
|
+
elif not a.no_build:
|
|
209
|
+
print("1/4 Building the Graphify graph (code only, local, no LLM)…")
|
|
210
|
+
s = p.build()
|
|
211
|
+
print(f" {s['nodes']:,} nodes, {s['edges']:,} edges, {s['communities']:,} communities in {s['seconds']}s")
|
|
212
|
+
print("2/4 Installing agent integrations…")
|
|
213
|
+
try:
|
|
214
|
+
for ln in install(a.root, _clients(a.clients), dry_run=a.dry_run, allow_tools=a.allow_tools,
|
|
215
|
+
codex_scope=a.codex_scope, strict=a.strict, portable=a.portable):
|
|
216
|
+
print(" " + ln)
|
|
217
|
+
except (ValueError, RuntimeError) as e:
|
|
218
|
+
print(f"error: {e}", file=sys.stderr)
|
|
219
|
+
return 2
|
|
220
|
+
if a.dry_run or not p.has_graph():
|
|
221
|
+
return 0
|
|
222
|
+
if not a.no_bench:
|
|
223
|
+
print("3/4 Benchmarking on this repo…")
|
|
224
|
+
save_default_tasks(p) if not (p.gs_dir / "tasks.json").exists() else None
|
|
225
|
+
res = run_bench(a.root)
|
|
226
|
+
print("\n" + format_table(res) + "\n")
|
|
227
|
+
print("4/4 Writing the dashboard…")
|
|
228
|
+
out = write_dashboard(p)
|
|
229
|
+
print(f" {out}")
|
|
230
|
+
print("\nDone. Restart your agent/IDE, then ask it a code question. `graphsnip report --open` shows the savings.")
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def cmd_doctor(a) -> int:
|
|
235
|
+
from graphsnip.installer import CLIENTS, HOOK_MARK
|
|
236
|
+
|
|
237
|
+
p = Project(a.root)
|
|
238
|
+
ok = True
|
|
239
|
+
|
|
240
|
+
def line(good: bool, msg: str, fix: str = ""):
|
|
241
|
+
nonlocal ok
|
|
242
|
+
ok &= good
|
|
243
|
+
print(f"{'✓' if good else '✗'} {msg}" + (f" → {fix}" if (fix and not good) else ""))
|
|
244
|
+
|
|
245
|
+
line(sys.version_info >= (3, 10), f"Python {sys.version.split()[0]}", "needs Python 3.10+")
|
|
246
|
+
try:
|
|
247
|
+
gv = md.version("graphifyy")
|
|
248
|
+
line(True, f"Graphify {gv}")
|
|
249
|
+
except Exception:
|
|
250
|
+
line(False, "Graphify not installed", "pip install graphifyy")
|
|
251
|
+
try:
|
|
252
|
+
import tree_sitter # noqa: F401
|
|
253
|
+
line(True, "tree-sitter available (exact symbol slicing)")
|
|
254
|
+
except Exception:
|
|
255
|
+
line(False, "tree-sitter missing; slicing falls back to heuristics", "pip install graphifyy")
|
|
256
|
+
exe = shutil.which("graphsnip")
|
|
257
|
+
line(bool(exe), f"`graphsnip` on PATH ({exe})" if exe else "`graphsnip` not on PATH (configs will use `python -m graphsnip`)", "pipx install graphsnip")
|
|
258
|
+
line(p.has_graph(), f"graph present ({p.graph_json})" if p.has_graph() else "no graph yet", "graphsnip build")
|
|
259
|
+
if p.has_graph():
|
|
260
|
+
line(not p.is_stale(0), "graph is up to date" if not p.is_stale(0) else "graph is older than some source files", "graphsnip build (the server also refreshes itself)")
|
|
261
|
+
s = p.graph_stats()
|
|
262
|
+
line(s["symbols"] > 0, f"{s['symbols']:,} code symbols indexed")
|
|
263
|
+
mcp_claude = (p.root / ".mcp.json")
|
|
264
|
+
st = p.root / ".claude" / "settings.json"
|
|
265
|
+
found = {
|
|
266
|
+
"claude": mcp_claude.exists() and "graphsnip" in mcp_claude.read_text(encoding="utf-8", errors="ignore"),
|
|
267
|
+
"copilot": (p.root / ".vscode" / "mcp.json").exists() and "graphsnip" in (p.root / ".vscode" / "mcp.json").read_text(encoding="utf-8", errors="ignore"),
|
|
268
|
+
"codex": (p.root / ".codex" / "config.toml").exists() and "graphsnip" in (p.root / ".codex" / "config.toml").read_text(encoding="utf-8", errors="ignore"),
|
|
269
|
+
}
|
|
270
|
+
for c in CLIENTS:
|
|
271
|
+
print(f"{'✓' if found[c] else '·'} {c}: {'MCP server registered' if found[c] else 'not installed here (graphsnip install --clients ' + c + ')'}")
|
|
272
|
+
hook_on = st.exists() and HOOK_MARK in st.read_text(encoding="utf-8", errors="ignore")
|
|
273
|
+
print(f"{'✓' if hook_on else '·'} claude hook: {'installed' if hook_on else 'not installed'} (mode: {p.config()['mode']})")
|
|
274
|
+
print("\nAll good." if ok else "\nSome checks failed; see arrows above.")
|
|
275
|
+
return 0 if ok else 1
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def main(argv=None) -> int:
|
|
279
|
+
common = _common()
|
|
280
|
+
ap = argparse.ArgumentParser(prog="graphsnip", description="Token-saving layer on top of Graphify.")
|
|
281
|
+
ap.add_argument("--version", action="version", version=f"graphsnip {__version__}")
|
|
282
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
283
|
+
|
|
284
|
+
def add(name, fn, help_, **kw):
|
|
285
|
+
sp = sub.add_parser(name, parents=[common], help=help_, **kw)
|
|
286
|
+
sp.set_defaults(fn=fn)
|
|
287
|
+
return sp
|
|
288
|
+
|
|
289
|
+
sp = add("init", cmd_init, "build graph + install integrations + benchmark + dashboard")
|
|
290
|
+
sp.add_argument("--clients", default="all", help="claude,copilot,codex or all (default: all)")
|
|
291
|
+
sp.add_argument("--dry-run", action="store_true")
|
|
292
|
+
sp.add_argument("--allow-tools", action="store_true", help="Claude Code: pre-approve the read-only graphsnip tools")
|
|
293
|
+
sp.add_argument("--strict", action="store_true", help="hooks deny redirectable calls instead of nudging")
|
|
294
|
+
sp.add_argument("--portable", action="store_true", help="write bare `graphsnip` into configs (safe to commit) instead of this machine's path")
|
|
295
|
+
sp.add_argument("--codex-scope", choices=["project", "user"], default="project")
|
|
296
|
+
sp.add_argument("--no-build", action="store_true")
|
|
297
|
+
sp.add_argument("--no-bench", action="store_true")
|
|
298
|
+
|
|
299
|
+
sp = add("build", cmd_build, "build or refresh the Graphify graph (code only, no LLM)")
|
|
300
|
+
sp.add_argument("--force", action="store_true")
|
|
301
|
+
add("status", cmd_status, "show graph status")
|
|
302
|
+
sp = add("find", cmd_find, "locate symbols and connections (budgeted map)")
|
|
303
|
+
sp.add_argument("question")
|
|
304
|
+
sp.add_argument("--budget", type=int, default=1500)
|
|
305
|
+
sp.add_argument("--depth", type=int, default=2)
|
|
306
|
+
sp.add_argument("--quiet", action="store_true")
|
|
307
|
+
sp = add("symbol", cmd_symbol, "read one function/class with callers and callees")
|
|
308
|
+
sp.add_argument("name")
|
|
309
|
+
sp.add_argument("--max-lines", type=int, default=120)
|
|
310
|
+
sp.add_argument("--full-docs", action="store_true")
|
|
311
|
+
sp.add_argument("--quiet", action="store_true")
|
|
312
|
+
sp = add("pack", cmd_pack, "token-budgeted context pack for a task")
|
|
313
|
+
sp.add_argument("task")
|
|
314
|
+
sp.add_argument("--budget", type=int, default=4000)
|
|
315
|
+
sp.add_argument("--quiet", action="store_true")
|
|
316
|
+
sp = add("impact", cmd_impact, "what depends on a symbol")
|
|
317
|
+
sp.add_argument("name")
|
|
318
|
+
sp.add_argument("--depth", type=int, default=2)
|
|
319
|
+
sp.add_argument("--quiet", action="store_true")
|
|
320
|
+
sp = add("outline", cmd_outline, "symbols in a file with line numbers")
|
|
321
|
+
sp.add_argument("path")
|
|
322
|
+
sp.add_argument("--quiet", action="store_true")
|
|
323
|
+
sp = add("stats", cmd_stats, "token savings so far")
|
|
324
|
+
sp.add_argument("--json", action="store_true")
|
|
325
|
+
sp = add("report", cmd_report, "write the HTML dashboard")
|
|
326
|
+
sp.add_argument("--open", action="store_true")
|
|
327
|
+
sp.add_argument("--bundle", metavar="DIR", help="write a self-contained folder (dashboard + graph.html) for sharing")
|
|
328
|
+
sp = add("bench", cmd_bench, "offline benchmark: grep+read vs Graphify vs graphsnip")
|
|
329
|
+
sp.add_argument("--tasks", help="tasks JSON (default .graphsnip/tasks.json, auto-generated)")
|
|
330
|
+
sp.add_argument("--init-tasks", action="store_true", help="(re)write starter tasks")
|
|
331
|
+
sp.add_argument("--json", action="store_true")
|
|
332
|
+
sp = add("demo", cmd_demo, "one command for a live demo: graph + benchmark + dashboard")
|
|
333
|
+
sp.add_argument("--open", action="store_true")
|
|
334
|
+
sp.add_argument("--bundle", metavar="DIR")
|
|
335
|
+
add("serve", cmd_serve, "run the MCP server over stdio")
|
|
336
|
+
sp = add("hook", cmd_hook, "agent hook entry point (reads JSON on stdin)")
|
|
337
|
+
sp.add_argument("event", choices=["pretooluse"])
|
|
338
|
+
sp = add("run", cmd_run, "run a command and compress its output (exact token savings)")
|
|
339
|
+
sp.add_argument("cmd", nargs=argparse.REMAINDER)
|
|
340
|
+
sp = add("install", cmd_install, "write MCP + hooks + guidance for Claude Code / Copilot / Codex")
|
|
341
|
+
sp.add_argument("--clients", default="all")
|
|
342
|
+
sp.add_argument("--dry-run", action="store_true")
|
|
343
|
+
sp.add_argument("--allow-tools", action="store_true")
|
|
344
|
+
sp.add_argument("--strict", action="store_true")
|
|
345
|
+
sp.add_argument("--portable", action="store_true")
|
|
346
|
+
sp.add_argument("--codex-scope", choices=["project", "user"], default="project")
|
|
347
|
+
sp = add("uninstall", cmd_uninstall, "remove everything install added")
|
|
348
|
+
sp.add_argument("--clients", default="all")
|
|
349
|
+
sp.add_argument("--codex-scope", choices=["project", "user"], default="project")
|
|
350
|
+
add("doctor", cmd_doctor, "check the setup")
|
|
351
|
+
|
|
352
|
+
args = ap.parse_args(argv)
|
|
353
|
+
if not getattr(args, "fn", None):
|
|
354
|
+
ap.print_help()
|
|
355
|
+
return 0
|
|
356
|
+
try:
|
|
357
|
+
return int(args.fn(args) or 0)
|
|
358
|
+
except GraphError as e:
|
|
359
|
+
print(f"error: {e}", file=sys.stderr)
|
|
360
|
+
return 1
|
|
361
|
+
except BrokenPipeError:
|
|
362
|
+
return 0
|
|
363
|
+
except KeyboardInterrupt:
|
|
364
|
+
return 130
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
if __name__ == "__main__":
|
|
368
|
+
raise SystemExit(main())
|