@nanonets/graft 0.7.0 → 0.8.0

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.
Files changed (51) hide show
  1. package/README.md +183 -92
  2. package/dist/ai/llm/types.d.ts +2 -2
  3. package/dist/ai/llm/types.js +1 -1
  4. package/dist/ask/ask.d.ts +14 -0
  5. package/dist/ask/ask.d.ts.map +1 -1
  6. package/dist/ask/ask.js +27 -7
  7. package/dist/ask/ask.js.map +1 -1
  8. package/dist/claude/format.d.ts +1 -2
  9. package/dist/claude/format.d.ts.map +1 -1
  10. package/dist/claude/format.js +23 -23
  11. package/dist/claude/format.js.map +1 -1
  12. package/dist/claude/hooks.d.ts.map +1 -1
  13. package/dist/claude/hooks.js +25 -1
  14. package/dist/claude/hooks.js.map +1 -1
  15. package/dist/claude/settings-merge.d.ts.map +1 -1
  16. package/dist/claude/settings-merge.js +13 -6
  17. package/dist/claude/settings-merge.js.map +1 -1
  18. package/dist/claude/skill-template.d.ts.map +1 -1
  19. package/dist/claude/skill-template.js +132 -80
  20. package/dist/claude/skill-template.js.map +1 -1
  21. package/dist/cli-epilogue.js +1 -1
  22. package/dist/cli-epilogue.js.map +1 -1
  23. package/dist/cli.js +6 -4
  24. package/dist/cli.js.map +1 -1
  25. package/dist/context/check.d.ts +21 -0
  26. package/dist/context/check.d.ts.map +1 -1
  27. package/dist/context/check.js +32 -1
  28. package/dist/context/check.js.map +1 -1
  29. package/dist/context/node-file.d.ts +10 -0
  30. package/dist/context/node-file.d.ts.map +1 -1
  31. package/dist/context/node-file.js +34 -1
  32. package/dist/context/node-file.js.map +1 -1
  33. package/dist/context/savings.d.ts +6 -0
  34. package/dist/context/savings.d.ts.map +1 -1
  35. package/dist/context/savings.js +9 -1
  36. package/dist/context/savings.js.map +1 -1
  37. package/dist/graph/build.d.ts.map +1 -1
  38. package/dist/graph/build.js +4 -1
  39. package/dist/graph/build.js.map +1 -1
  40. package/dist/graph/traverse-cli.d.ts.map +1 -1
  41. package/dist/graph/traverse-cli.js +16 -6
  42. package/dist/graph/traverse-cli.js.map +1 -1
  43. package/dist/graph/workspace-cli.d.ts.map +1 -1
  44. package/dist/graph/workspace-cli.js +5 -1
  45. package/dist/graph/workspace-cli.js.map +1 -1
  46. package/dist/mcp/tools.d.ts.map +1 -1
  47. package/dist/mcp/tools.js +9 -5
  48. package/dist/mcp/tools.js.map +1 -1
  49. package/dist/search/grep-cli.js +1 -1
  50. package/dist/search/grep-cli.js.map +1 -1
  51. package/package.json +1 -2
package/README.md CHANGED
@@ -1,24 +1,30 @@
1
1
  <div align="center">
2
2
 
3
- <!-- placeholder: swap for the Graft logo asset when ready -->
4
- <!-- <img src="assets/graft.png" alt="Graft" width="220"/> -->
3
+ <img src="assets/graft-hero.png" alt="Graft — open-source context layer for large codebases" width="100%"/>
5
4
 
6
- # Graft
7
-
8
- **Your coding agent reads 30 files to change 3.**
9
- **Graft gives it the map it should have read first.**
5
+ ### Turbocharge Claude Code, Cursor, Codex, Gemini & every coding agent: faster, cheaper, with contextual understanding specific to your codebase.
10
6
 
11
7
  <p>
8
+ <a href="https://graft.nanonets.ai"><img src="https://img.shields.io/badge/website-graft.nanonets.ai-546FFF?style=for-the-badge" /></a>
12
9
  <a href="https://www.npmjs.com/package/@nanonets/graft"><img src="https://img.shields.io/npm/v/%40nanonets%2Fgraft?style=for-the-badge&logo=npm&logoColor=white&label=npm" /></a>
13
10
  <a href="https://www.npmjs.com/package/@nanonets/graft"><img src="https://img.shields.io/npm/dm/%40nanonets%2Fgraft?style=for-the-badge&logo=npm&logoColor=white&label=downloads" /></a>
14
11
  <a href="https://nodejs.org"><img src="https://img.shields.io/node/v/%40nanonets%2Fgraft?style=for-the-badge&logo=nodedotjs&logoColor=white" /></a>
15
12
  <img src="https://img.shields.io/badge/TypeScript-strict-3178C6?style=for-the-badge&logo=typescript&logoColor=white" />
16
13
  <img src="https://img.shields.io/badge/License-MIT-20C997?style=for-the-badge" />
17
14
  <img src="https://img.shields.io/badge/telemetry-none-546FFF?style=for-the-badge" />
15
+ <a href="https://scorecard.dev/viewer/?uri=github.com/NanoNets/Graft"><img src="https://img.shields.io/ossf-scorecard/github.com/NanoNets/Graft?style=for-the-badge&label=openssf%20scorecard" /></a>
18
16
  </p>
19
17
 
20
- <!-- numbers from the committed run in bench/results/ (2026-07-22, 162 agent runs, 2 repos) — see Benchmark below -->
21
- **32% less cost · 46% fewer tool calls · 60% lower latency** with the graph bundle pushed up front, at equal correctness — or **16% less cost with correctness up 5 points** when the agent pulls from the graph on demand. Measured against the same agent going in cold.
18
+ ### Up to **4× cheaper** and **3× faster**, with better or no loss of correctness.
19
+
20
+ | vs. a standard session, no Graft | With Graft |
21
+ |---|---|
22
+ | Tool calls | **46% fewer** |
23
+ | Tokens | **42% fewer** |
24
+ | Time | **60% less** |
25
+ | Correctness | **equal** |
26
+
27
+ <sub>The table is a 162-run controlled benchmark (same agent, same file tools, only the context differs). The "up to 4× cheaper / 3× faster" figures are the biggest single-task wins from a separate real-repo sweep (PocketBase, ollama, Excalidraw). [Full method & per-repo numbers ↓](#tested-on-your-popular-repos)</sub>
22
28
 
23
29
  </div>
24
30
 
@@ -28,6 +34,26 @@
28
34
 
29
35
  ---
30
36
 
37
+ ## Contents
38
+
39
+ - [Quick start](#quick-start)
40
+ - [The problem](#the-problem)
41
+ - [What Graft does](#what-graft-does)
42
+ - [How the graph gets built](#how-the-graph-gets-built)
43
+ - [What's in a node](#whats-in-a-node)
44
+ - [What runs where](#what-runs-where)
45
+ - [Agent integration](#agent-integration) — [MCP server](#mcp-server) · [Claude Code (deep integration)](#claude-code-deep-integration)
46
+ - [CLI](#cli)
47
+ - [Search & orient](#search--orient-graft-grep--graft-map) (`graft grep` / `graft map`)
48
+ - [Monorepos & multi-repo folders](#monorepos--multi-repo-folders)
49
+ - [Visualize it](#visualize-it-graft-viz) (`graft viz`)
50
+ - [Benchmark](#benchmark)
51
+ - [Tested on your popular repos](#tested-on-your-popular-repos)
52
+ - [Development](#development)
53
+ - [License](#license)
54
+
55
+ ---
56
+
31
57
  ## Quick start
32
58
 
33
59
  ```bash
@@ -37,64 +63,16 @@ graft init # build the graph + wire it into Claude Code
37
63
 
38
64
  That is the whole setup. `graft init` builds `graft/` from your code and drops a statusline and hooks into `.claude/`, so from the next session on Graft rides along in Claude Code: it pulls the matching nodes into each prompt and rebuilds the graph in the background after every turn. No daemon, no re-indexing to remember, nothing to run or maintain by default — the graph is just files.
39
65
 
40
- Commit `graft/` so everyone who clones the repo (and their agents) gets the map:
66
+ `graft build` adds `graft/` to your `.gitignore` automatically — the graph is a local, regenerable cache (like `node_modules`), not something you commit. What you share is the wiring `init` dropped into `.claude/`; each teammate runs `graft build` to generate their own graph:
41
67
 
42
68
  ```bash
43
- git add graft && git commit -m "add context graph"
69
+ git add .claude && git commit -m "wire in graft"
44
70
  ```
45
71
 
46
72
  Prefer not to install globally? `npx @nanonets/graft init` works the same way.
47
73
 
48
74
  ---
49
75
 
50
- ## Agent integration
51
-
52
- One command wires Graft into the coding agents you use:
53
-
54
- ```bash
55
- npx @nanonets/graft init
56
- # detects your agents and writes each one's native instruction file;
57
- # Claude Code additionally gets the live statusline + hooks below
58
- ```
59
-
60
- `init` auto-detects which agents are present (via their config directories) and writes a marker-fenced Graft section into each one's shared instruction file — `AGENTS.md`, `GEMINI.md`, `.github/copilot-instructions.md` — or a wholly-owned rule file for the agents that use one — `.cursor/rules/graft.mdc`, `.kiro/steering/graft.md`, `.windsurf/rules/graft.md`. Re-running only updates Graft's own section (or replaces the owned file) and never touches the rest of your content.
61
-
62
- | Flag | Effect |
63
- |---|---|
64
- | `--agents <ids...>` | wire only these — ids: `agents`, `cursor`, `gemini`, `copilot`, `kiro`, `windsurf`, `claude` |
65
- | `--all-agents` | write instruction files for every known agent, detected or not |
66
- | `--no-agents` | Claude Code wiring only; skip other agents |
67
- | `--list-agents` | print the known agent ids and exit |
68
- | `--no-mcp` | skip MCP server registration |
69
- | `--no-hooks` | skip hook installation |
70
-
71
- ### MCP server
72
-
73
- `graft init` also registers Graft's MCP server with agents that support it, so
74
- `graft_ask`, `graft_callers`, `graft_grep`, `graft_skeleton`, `graft_map`, and
75
- `graft_check` appear as native tools — no shell required. Claude Code now gets
76
- this too: `graft init` writes the server into the project's `.mcp.json` (restart
77
- Claude Code to load it). Skip with `--no-mcp`. Run it manually with
78
- `graft mcp [dir]`, or register it by hand:
79
-
80
- ```json
81
- { "mcpServers": { "graft": { "command": "npx", "args": ["-y", "@nanonets/graft", "mcp"] } } }
82
- ```
83
-
84
- Where a CLI agent supports user-level `hooks.json`, `init` also installs Graft's post-edit hook — blast-radius warnings and automatic `$0` graph re-sync after edits (skip with `--no-hooks`).
85
-
86
- ### Claude Code (deep integration)
87
-
88
- `graft init` always wires up Claude Code, and Claude Code gets more than an instruction file. From then on, any Claude Code session opened in the repo gets:
89
-
90
- - **a live statusline** — graph size, % enriched, and a `⚠ N stale` warning when the code has moved ahead of the graph
91
- - **auto-sync** — after you edit code, Graft rebuilds the graph in the background at the end of the turn (structural, `$0` — it never calls the LLM on its own)
92
- - **context on tap** — each prompt pulls the matching nodes into the session; editing a file surfaces what depends on it ("blast radius"); new sessions start with the repo map
93
-
94
- `graft init` is idempotent and never clobbers your existing `.claude/settings.json` — it merges its blocks and leaves the rest alone. Want the LLM summaries too? Run `graft build --deep` (with a key) whenever you like; auto-sync will never do it for you.
95
-
96
- ---
97
-
98
76
  ## The problem
99
77
 
100
78
  Every task, your coding agent starts blind. Before it changes anything, it re-explores the repo: grep a term, open a file, follow an import, back out, try again. It is rebuilding a picture of a codebase it mapped an hour ago and threw away. That rediscovery burns most of a run's tool calls, tokens, and latency, and it is pure overhead:
@@ -105,6 +83,10 @@ Every task, your coding agent starts blind. Before it changes anything, it re-ex
105
83
 
106
84
  Humans onboard to a codebase once. Agents onboard every single time.
107
85
 
86
+ <p align="center">
87
+ <img src="assets/graft-site-act-demo.gif" alt="A no-map agent's exploration trail wandering file to file before it finds what it needs" width="820"/>
88
+ </p>
89
+
108
90
  ---
109
91
 
110
92
  ## What Graft does
@@ -126,7 +108,16 @@ Graft builds the graph in two passes, both powered by a language model:
126
108
  1. **Read each file.** Every source file is summarized once into a short description of what it does.
127
109
  2. **Group into nodes.** Those summaries are grouped into a curated set of nodes (subsystems, key files, and concepts) with typed links between them. Graft chooses the right level of detail for you instead of making one node per file, so a big repo becomes a few dozen readable nodes.
128
110
 
129
- Both passes are cached by content hash. Re-running only touches the files that changed, so the second build is fast and cheap.
111
+ ```mermaid
112
+ flowchart LR
113
+ S[Source files] --> T["Tier 1 — tree-sitter<br/>no model, no key"]
114
+ S --> P1["Pass 1 — LLM summarizes<br/>each file (--deep)"]
115
+ T --> W["graft/.graph/wiring.json<br/>per-symbol code graph"]
116
+ P1 --> P2["Pass 2 — group into nodes<br/>+ typed links"]
117
+ P2 --> N["graft/*.md<br/>markdown node graph"]
118
+ ```
119
+
120
+ Both LLM passes are cached by content hash. Re-running only touches the files that changed, so the second build is fast and cheap.
130
121
 
131
122
  Alongside the markdown graph, `graft build` builds `graft/.graph/wiring.json` — a per-symbol code graph — plus a per-file wiring card mirroring your source tree. Tier 1 is pure tree-sitter (every function, class, and call edge; deterministic, no model, no network), which is why plain `graft build` needs no key. The `--deep` pass adds a one-line summary and a crux excerpt per symbol, cached by body hash.
132
123
 
@@ -154,29 +145,72 @@ _Summary, sources, links, and notes ship today in markdown nodes. The crux ships
154
145
 
155
146
  ---
156
147
 
157
- ## Keeping it honest (CI)
148
+ ## What runs where
149
+
150
+ - **On your machine, no key, no network:** the structural code graph. `graft build` (wiring graph + per-file cards), `graft check`, and `graft ask` are deterministic tree-sitter — they never call a model.
151
+ - **Through your provider key:** the LLM-written parts — `graft build --deep` adds the concept nodes (file summaries + node synthesis) and the per-symbol summaries and cruxes. graft is vendor-neutral: set `GRAFT_PROVIDER` (`openai` for any OpenAI-compatible endpoint, or `anthropic` for the native API), your `GRAFT_API_KEY`, `GRAFT_MODEL`, and — for the `openai` wire format — `GRAFT_BASE_URL` to point at OpenRouter, Fireworks, Groq, a LiteLLM proxy, a local server, or OpenAI itself. Or pass `--provider/--model/--api-key/--base-url` on the command line. (`OPENROUTER_API_KEY` still works as a deprecated fallback.)
152
+ - **No telemetry** and no analytics — the only network calls are the LLM requests you configured.
153
+
154
+ See [`.env.example`](.env.example) for the full list of settings (model, base URL, graph directory).
155
+
156
+ ---
158
157
 
159
- A graph that has drifted from the code is worse than no graph. `graft check` compares each node's tracked source hashes against the current files and fails if the graph is stale. Drop it in CI so a pull request cannot merge with an out-of-date map.
158
+ ## Agent integration
159
+
160
+ One command wires Graft into the coding agents you use:
160
161
 
161
162
  ```bash
162
- graft check # exits non-zero if graft/ has drifted
163
- graft check --json # machine-readable drift report
163
+ npx @nanonets/graft init
164
+ # detects your agents and writes each one's native instruction file;
165
+ # Claude Code additionally gets the live statusline + hooks below
164
166
  ```
165
167
 
166
- When the wiring graph exists, `graft check` validates it too — structural drift (symbols added, removed, or changed since the last `graft build`) and stale summaries both fail the check. A repo without a wiring graph is not penalized; the markdown graph stands alone.
168
+ `init` auto-detects which agents are present (via their config directories) and writes a marker-fenced Graft section into each one's shared instruction file — `AGENTS.md`, `GEMINI.md`, `.github/copilot-instructions.md` — or a wholly-owned rule file for the agents that use one — `.cursor/rules/graft.mdc`, `.kiro/steering/graft.md`, `.windsurf/rules/graft.md`. Re-running only updates Graft's own section (or replaces the owned file) and never touches the rest of your content.
167
169
 
168
- <!-- placeholder: planned, not yet in the CLI -->
169
- > **Planned: auto-regen on PR.** A single CI bot will regenerate changed nodes on a pull request and push the update, so the graph stays current without anyone remembering to run `build`. Until then, regenerate with `graft build --deep` and commit it alongside your code.
170
+ | Flag | Effect |
171
+ |---|---|
172
+ | `--agents <ids...>` | wire only these — ids: `agents`, `cursor`, `gemini`, `copilot`, `kiro`, `windsurf`, `claude` |
173
+ | `--all-agents` | write instruction files for every known agent, detected or not |
174
+ | `--no-agents` | Claude Code wiring only; skip other agents |
175
+ | `--list-agents` | print the known agent ids and exit |
176
+ | `--no-mcp` | skip MCP server registration |
177
+ | `--no-hooks` | skip hook installation |
170
178
 
171
- ---
179
+ ### MCP server
172
180
 
173
- ## What runs where
181
+ `graft init` also registers Graft's MCP server with agents that support it, so these six tools appear natively, no shell required. Claude Code gets this too: `graft init` writes the server into the project's `.mcp.json` (restart Claude Code to load it). Skip with `--no-mcp`; run it manually with `graft mcp [dir]`.
174
182
 
175
- - **On your machine, no key, no network:** the structural code graph. `graft build` (wiring graph + per-file cards), `graft check`, and `graft ask` are deterministic tree-sitter — they never call a model.
176
- - **Through your provider key:** the LLM-written parts — `graft build --deep` adds the concept nodes (file summaries + node synthesis) and the per-symbol summaries and cruxes. graft is vendor-neutral: set `GRAFT_PROVIDER` (`openai` for any OpenAI-compatible endpoint, or `anthropic` for the native API), your `GRAFT_API_KEY`, `GRAFT_MODEL`, and — for the `openai` wire format — `GRAFT_BASE_URL` to point at OpenRouter, Fireworks, Groq, a LiteLLM proxy, a local server, or OpenAI itself. Or pass `--provider/--model/--api-key/--base-url` on the command line. (`OPENROUTER_API_KEY` still works as a deprecated fallback.)
177
- - **No telemetry** and no analytics — the only network calls are the LLM requests you configured.
183
+ | Tool | Takes | What it's for |
184
+ |---|---|---|
185
+ | `graft_ask` | a question | Ranked nodes with file:line, source inlined — usually the full answer, no follow-up read needed. |
186
+ | `graft_skeleton` | a file path | Every signature in that file, no bodies — the API surface for a tenth of the tokens. |
187
+ | `graft_callers` | a symbol | Who depends on it, or what it depends on with `direction: out`, N levels deep for blast radius. |
188
+ | `graft_grep` | a regex | Every hit, grouped by enclosing symbol, ranked by how coupled that symbol is. |
189
+ | `graft_map` | nothing | A first look at an unfamiliar repo: directory clusters, hubs, hotspots. |
190
+ | `graft_check` | nothing | Whether the local graph has drifted from the code. |
178
191
 
179
- See [`.env.example`](.env.example) for the full list of settings (model, base URL, graph directory).
192
+ Register it by hand if your agent needs it explicit:
193
+
194
+ ```json
195
+ { "mcpServers": { "graft": { "command": "npx", "args": ["-y", "@nanonets/graft", "mcp"] } } }
196
+ ```
197
+
198
+ Where a CLI agent supports user-level `hooks.json`, `init` also installs Graft's post-edit hook — blast-radius warnings and automatic `$0` graph re-sync after edits (skip with `--no-hooks`).
199
+
200
+ ### Claude Code (deep integration)
201
+
202
+ `graft init` always wires up Claude Code, and Claude Code gets more than an instruction file. From then on, any Claude Code session opened in the repo gets:
203
+
204
+ - **a live statusline** — graph size, % enriched, and a `⚠ N stale` warning when the code has moved ahead of the graph
205
+ - **auto-sync** — after you edit code, Graft rebuilds the graph in the background at the end of the turn (structural, `$0` — it never calls the LLM on its own)
206
+ - **context on tap** — each prompt pulls the matching nodes into the session; editing a file surfaces what depends on it ("blast radius"); new sessions start with the repo map
207
+
208
+ <p align="center">
209
+ <img src="assets/graft-hook-blast-radius-demo.gif" alt="graft's post-edit hook: editing node-file.ts prints its blast radius (who depends on it) inline, the statusline flips stale → syncing → synced on its own, and the same dependents light up in graft viz" width="820"/>
210
+ <br/><sub>edit a file → blast radius appears inline → graph auto-resyncs → confirmed in <code>graft viz</code></sub>
211
+ </p>
212
+
213
+ `graft init` is idempotent and never clobbers your existing `.claude/settings.json` — it merges its blocks and leaves the rest alone. Want the LLM summaries too? Run `graft build --deep` (with a key) whenever you like; auto-sync will never do it for you.
180
214
 
181
215
  ---
182
216
 
@@ -191,6 +225,8 @@ graft ask "<task>" [dir] # query the graph — ranked nodes + exact
191
225
  graft ask "<task>" --json # machine-readable result
192
226
  graft ask "<task>" --in <scope> # narrow to one sub-project of a monorepo/multi-repo folder (see below)
193
227
 
228
+ graft skeleton <file> [dir] # every signature in one file, no bodies — the API surface for ~1/10th the tokens (no LLM, no key)
229
+
194
230
  graft callers <symbol> [dir] # who calls/references/imports/implements/extends a symbol (no LLM, no key)
195
231
  graft callers <symbol> --direction out # the reverse: what the symbol itself calls/references (was `graft callees`)
196
232
  graft callers <symbol> -d N # walk transitively out to depth N — full blast radius (was `graft impact`)
@@ -249,15 +285,14 @@ with file/symbol counts, each dir's local hubs, and the global hotspots —
249
285
  all ranked by in-degree, no LLM, no key:
250
286
 
251
287
  ```
252
- repo map — 105 files · 582 symbols · 1815 edges · typescript
288
+ repo map — 113 files · 687 symbols · 2186 edges · typescript
253
289
 
254
- src/ 54 files · 414 symbols hubs: set (bindings.ts, 38←), contextDirFor (node-file.ts, 11←), wiringPath (write.ts, 10←)
255
- test/ 36 files · 68 symbols hubs: edge (graph-traverse.test.ts, 4←), graphOf (graph-traverse.test.ts, 4←), nodeStub (graph-traverse.test.ts, 3←)
290
+ src/ 63 files · 527 symbols hubs: contextDirFor (node-file.ts, 21←), wiringPath (write.ts, 14←), buildGraph (build.ts, 11←)
291
+ test/ 43 files · 102 symbols hubs: edge (graph-traverse.test.ts, 4←), graphOf (graph-traverse.test.ts, 4←), fileNode (graph-map.test.ts, 3←)
256
292
  viewer/ 5 files · 58 symbols hubs: $ (main.ts, 9←), activeGraph (main.ts, 5←), cvar (data.ts, 5←)
257
- bench/ 8 files · 42 symbols hubs: makeClient (llm.ts, 3←), buildMarkdown (report.ts, 2←), judge (judge.ts, 2←)
258
293
  scripts/ 2 files · 0 symbols
259
294
 
260
- hotspots: set · method · src/graph/bindings.ts:L21-L23 · 38← contextDirFor · function · src/context/node-file.ts:L100-L103 · 11← wiringPath · function · src/graph/write.ts:L20-L22 · 10← ...
295
+ hotspots: contextDirFor · function · src/context/node-file.ts:L100-L103 · 21← wiringPath · function · src/graph/write.ts:L20-L22 · 14← buildGraph · function · src/graph/build.ts:L104-L218 · 11← ...
261
296
  ```
262
297
 
263
298
  ## Monorepos & multi-repo folders
@@ -271,7 +306,7 @@ Graft handles two shapes without any config:
271
306
  sub-project can't drown a small one; hits carry `[scope/]` labels, and
272
307
  `graft map` groups its directory clusters by scope first.
273
308
  - **A folder of separate git repos** (no `.git` at the top) — `graft build`
274
- auto-splits: each child keeps its own committable `graft/`, and the parent
309
+ auto-splits: each child gets its own (git-ignored) `graft/`, and the parent
275
310
  gets a `graft/workspace.json` index. Queries from the parent federate across
276
311
  every child, always labeled `<child>/`. Run `graft build` inside a child to
277
312
  work on just that repo.
@@ -284,6 +319,11 @@ once you know where you're working.
284
319
  `graft viz` opens a local, interactive view of both graphs — no install, no dev
285
320
  server; the viewer ships prebuilt inside the package.
286
321
 
322
+ <p align="center">
323
+ <img src="assets/graft-viz-demo.gif" alt="graft viz — searching a symbol and jumping to it lights up its dependency graph: amber edges are what it depends on, teal is what depends on it" width="820"/>
324
+ <br/><sub>search → jump to a node → dependency graph lights up</sub>
325
+ </p>
326
+
287
327
  - **Context** tab — the architecture graph from `graft/*.md`. Nodes colored by
288
328
  type, sized by connectedness.
289
329
  - **Code** tab — the per-symbol graph from `graft/.graph/wiring.json` (run `graft build` first).
@@ -312,26 +352,77 @@ normalized on load — no regeneration needed.
312
352
 
313
353
  ## Benchmark
314
354
 
315
- The claim Graft has to earn is simple: an agent that gets its context from the graph is cheaper and faster without getting more answers wrong. The harness runs every task through three arms of the same Claude Sonnet 5 agent with the same file tools: **cold** (explores from zero), **graph (push)** (a `graft ask --source` bundle injected up front), and **graft (pull)** (graft_ask/graft_skeleton tools, nothing injected — context paid for only when asked). An Opus 4.8 judge scores correctness with a required-keyword floor so a fast-but-wrong answer cannot win. Cost is cache-aware (reads ≈0.1×, writes 1.25×) — the billing model agents actually run under.
355
+ An agent that reads the graph should be cheaper and faster without getting more answers wrong. That's the whole claim, so we measured it instead of asserting it.
316
356
 
317
- Committed run: `bench/results/2026-07-22T13-19-14-246Z` — 162 runs across two repos (graft itself and a real Node/Express auth service), 3 trials each, tasks split between localized (single-file) and multi-file questions.
357
+ The harness ran three variants of the same Claude Sonnet 5 agent with the same file tools: **cold** (explores from zero), **Graft** (a `graft ask --source` bundle pushed up front), and **pull** (graft_ask/graft_skeleton tools, nothing injected — context paid for only when asked). An Opus 4.8 judge scored correctness with a required-keyword floor, so a fast-but-wrong answer couldn't win by being fast. Cost is cache-aware: reads ≈0.1×, writes 1.25×, the billing model agents actually run under.
318
358
 
319
- | Metric (mean/task) | Cold | Graph (push) | Graft (pull) |
359
+ 162 runs, two repos (graft itself and a real Node/Express auth service), 3 trials each, tasks split between single-file and multi-file questions.
360
+
361
+ | Metric (mean/task) | Cold | Graft |
362
+ |---|---|---|
363
+ | Cost ($) | 0.0429 | **0.0292 (−32%)** |
364
+ | Uncached input tokens | 8,070 | **4,650 (−42%)** |
365
+ | Tool calls | 4.2 | **2.3 (−46%)** |
366
+ | Latency (s) | 39.8 | **15.8 (−60%)** |
367
+ | Correctness | 93% | 93% (equal) |
368
+
369
+ Graft never answered worse than cold, on any corpus. The pull variant gave up most of that speed for something bigger: correctness jumped to 98%, +5 points over cold, the strongest single result in the sweep. Push when speed is what you need; pull when being right matters more.
370
+
371
+ ---
372
+
373
+ ## Tested on your popular repos
374
+
375
+ The sweep above measures the mechanism. The real test is whether graft helps an agent **ship real changes** on code people actually run, not just answer questions. So we benchmark it on popular open-source repos: **15 tasks each**, 10 real developer questions plus **5 actual implementation tasks** (real merged pull requests, each re-implemented from its base commit and scored against the files the maintainers actually changed). Same agent (Claude Opus), same file tools; the only difference is whether graft is wired in.
376
+
377
+ Across these repos graft runs **up to 4× cheaper and 3× faster**, with better or no loss of correctness: it reproduces the real merged PRs by touching the same files the maintainers did. Per-repo detail below.
378
+
379
+ ### PocketBase (Go, ~350 files)
380
+
381
+ | Aggregate over 15 tasks | Standard Claude Code | With graft |
382
+ |---|---|---|
383
+ | Cost | $13.91 | **$11.02 (−21%)** |
384
+ | Wall-clock | 2,044s | **1,762s (−14%)** |
385
+ | PRs reproduced | 5 / 5 | **5 / 5 (same files as the maintainers)** |
386
+
387
+ Cheaper and faster with no loss of correctness: graft reproduced all five merged PRs, touching the same files the maintainers did. The gap is widest on cross-file understanding — "how does auth work across OAuth2 providers" dropped from $2.19 to $0.84.
388
+
389
+ <details>
390
+ <summary><b>The 10 questions we asked</b></summary>
391
+
392
+ 1. **Orientation** — Give me a map of PocketBase's architecture: the main subsystems and how an HTTP request flows through to the database.
393
+ 2. **Entry-point trace** — Trace end-to-end what happens when a client creates a record via the REST API, from route handler to database write.
394
+ 3. **Feature location** — I want to add a brand-new collection field type. Where do I hook it in, and which pieces must change?
395
+ 4. **Bug localization** — Realtime subscriptions silently stop delivering events after a while. Where would you start looking, and why?
396
+ 5. **Blast radius** — If I change the signature of the record-validation logic, what depends on it and what could break?
397
+ 6. **Cross-file synthesis** — How does auth work across OAuth2 providers: where are tokens issued, validated, stored, and refreshed?
398
+ 7. **Extensibility** — How do I use PocketBase as a Go framework to register a custom route plus an on-record-create hook?
399
+ 8. **Security discovery** — Where is user input validated, and where are collection API access rules enforced before a query runs?
400
+ 9. **Public API** — As an external app, how do I authenticate and then list and filter records over the REST API?
401
+ 10. **Test verification** — Where are the tests for the record CRUD API, and what do they assert about access rules?
402
+
403
+ </details>
404
+
405
+ <details>
406
+ <summary><b>The 5 merged PRs we re-implemented</b></summary>
407
+
408
+ Each PR was reset to its base commit; graft's diff was scored against the files the merged PR changed.
409
+
410
+ | PR | Type | What it does | Files the maintainers touched |
320
411
  |---|---|---|---|
321
- | Cost ($) | 0.0429 | **0.0292 (−32%)** | 0.0362 (−16%) |
322
- | Uncached input tokens | 8,070 | **4,650 (−42%)** | 5,965 (−26%) |
323
- | Tool calls | 4.2 | **2.3 (−46%)** | 4.2 (−1%) |
324
- | Latency (s) | 39.8 | **15.8 (−60%)** | 39.5 (−1%) |
325
- | Correctness | 93% | 93% | **98% (+5 pts)** |
412
+ | [#6744](https://github.com/pocketbase/pocketbase/pull/6744) | feat | Generate & serve WebP thumbnails | `apis/file.go`, `tools/filesystem/filesystem.go` |
413
+ | [#6947](https://github.com/pocketbase/pocketbase/pull/6947) | fix | Uniform char distribution in regex random strings | `tools/security/random_by_regex.go` |
414
+ | [#6690](https://github.com/pocketbase/pocketbase/pull/6690) | refactor | Patreon OAuth2 to use `x/oauth2/endpoints` | `tools/auth/patreon.go` |
415
+ | [#2726](https://github.com/pocketbase/pocketbase/pull/2726) | perf | Drop a redundant admin-count query on a hot middleware path | `apis/middlewares.go` |
416
+ | [#3192](https://github.com/pocketbase/pocketbase/pull/3192) | fix | Restore prior API rules on automigration rollback | `plugins/migratecmd/templates.go` |
326
417
 
327
- Push is the cost/latency win; pull is the correctness win (and never lost to cold anywhere, including single-file tasks). On one corpus push traded 3 correctness points for its savings — the per-corpus and per-locality splits are in the committed results, not averaged away. Reproduce it yourself:
418
+ </details>
328
419
 
329
- ```bash
330
- npm run bench -- --smoke # 1 corpus, 1 task, all arms — a quick plumbing check
331
- npm run bench # full: all corpora, all tasks, 3 arms × 3 trials
332
- ```
420
+ <details>
421
+ <summary><b>Method</b></summary>
422
+
423
+ Two clones of PocketBase at the same commit: one wired with `graft init`, one untouched and verified graft-free. Each task run headless (`claude -p`, Claude Opus) with an empty MCP config. Understanding questions were graded by whether the answer pointed to the right files and functions; PR tasks were scored on whether the agent's diff touched the same files as the merged PR. Every transcript was audited to confirm graft was actually used in the graft arm and absent from the standard arm.
333
424
 
334
- See [`bench/README.md`](bench/README.md) for the method and how to add your own repo.
425
+ </details>
335
426
 
336
427
  ---
337
428
 
@@ -2,7 +2,7 @@
2
2
  * Provider-neutral chat transport.
3
3
  *
4
4
  * Every LLM call in graft — the engine's single-shot summarize/synthesize/crux
5
- * ops AND the benchmark's multi-turn ReAct loop — goes through one interface,
5
+ * ops, and any multi-turn tool-use loop built on top — goes through one interface,
6
6
  * {@link ChatModel}. Adapters translate this neutral shape to a concrete SDK
7
7
  * (OpenAI-compatible or native Anthropic). Nothing above this layer knows which
8
8
  * provider is in play, so adding a provider is a new adapter, never a change to
@@ -37,7 +37,7 @@ export interface Message {
37
37
  /**
38
38
  * Request a provider cache marker (`cache_control: ephemeral`) on THIS message.
39
39
  * The transport only understands this per-message flag; any sliding/rotation
40
- * policy lives in the caller (see the bench loop).
40
+ * policy lives in the caller.
41
41
  */
42
42
  cacheBreakpoint?: boolean;
43
43
  /**
@@ -2,7 +2,7 @@
2
2
  * Provider-neutral chat transport.
3
3
  *
4
4
  * Every LLM call in graft — the engine's single-shot summarize/synthesize/crux
5
- * ops AND the benchmark's multi-turn ReAct loop — goes through one interface,
5
+ * ops, and any multi-turn tool-use loop built on top — goes through one interface,
6
6
  * {@link ChatModel}. Adapters translate this neutral shape to a concrete SDK
7
7
  * (OpenAI-compatible or native Anthropic). Nothing above this layer knows which
8
8
  * provider is in play, so adding a provider is a new adapter, never a change to
package/dist/ask/ask.d.ts CHANGED
@@ -58,6 +58,20 @@ export interface AskResult {
58
58
  }[];
59
59
  };
60
60
  }
61
+ /** Score a document's token counts against the query counts (name field
62
+ * pre-weighted by caller). Each shared term is weighted by its inverse document
63
+ * frequency (`idf`), so a word that appears in many nodes — "overlay" across a
64
+ * whole widget subsystem, or a repeated word in a pasted issue body — counts for
65
+ * far less than a rare, discriminating identifier ("scrolling"). Without idf,
66
+ * pure term-frequency lets an incidental common word dominate the ranking; this
67
+ * is the lexical half of the keyword-collision fix (graph-rank is the other). */
68
+ /** Test files rarely answer "how does X work" / "where is Y" — they mirror the
69
+ * real symbol's tokens, so a test can out-score the definition on a lexical tie
70
+ * and land as the top hit (observed: an `ask` returning a `Test…` function first,
71
+ * sending the agent to the wrong file). A multiplicative de-rank keeps tests in
72
+ * the results (they still matter for "where are the tests") but below the real
73
+ * definition. Covers Go (`_test.go`), JS/TS (`.test.` / `.spec.`), and test dirs. */
74
+ export declare function isTestPath(path: string): boolean;
61
75
  /** Idf-weighted share (0..1) of the query a document matches: each query term
62
76
  * counts by its rarity, so this separates task prompts from chatter in a way a
63
77
  * raw term count can't. A conversational prompt's words are either off-corpus
@@ -1 +1 @@
1
- {"version":3,"file":"ask.d.ts","sourceRoot":"","sources":["../../src/ask/ask.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ask.d.ts","sourceRoot":"","sources":["../../src/ask/ask.ts"],"names":[],"mappings":"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"}
package/dist/ask/ask.js CHANGED
@@ -17,7 +17,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
17
17
  import { join, resolve } from "node:path";
18
18
  import matter from "gray-matter";
19
19
  import { contextDirFor } from "../context/node-file.js";
20
- import { savingsFooter, savingsFor } from "../context/savings.js";
20
+ import { savingsFooter, savingsFor, SAVINGS_TURN_NUDGE } from "../context/savings.js";
21
21
  import { loadGraphCached, loadAskIndexCached } from "../graph/load.js";
22
22
  import { pathUnderPrefix, scopeLabel, scopeOf, scopesHereClause, scopesOfGraph } from "../graph/scopes.js";
23
23
  import { resolveSymbol } from "../graph/traverse.js";
@@ -71,6 +71,16 @@ function loadCorpus(outDir) {
71
71
  * far less than a rare, discriminating identifier ("scrolling"). Without idf,
72
72
  * pure term-frequency lets an incidental common word dominate the ranking; this
73
73
  * is the lexical half of the keyword-collision fix (graph-rank is the other). */
74
+ /** Test files rarely answer "how does X work" / "where is Y" — they mirror the
75
+ * real symbol's tokens, so a test can out-score the definition on a lexical tie
76
+ * and land as the top hit (observed: an `ask` returning a `Test…` function first,
77
+ * sending the agent to the wrong file). A multiplicative de-rank keeps tests in
78
+ * the results (they still matter for "where are the tests") but below the real
79
+ * definition. Covers Go (`_test.go`), JS/TS (`.test.` / `.spec.`), and test dirs. */
80
+ export function isTestPath(path) {
81
+ return /(^|\/)(tests?|__tests__|spec)\/|(_test|\.test|\.spec)\.[a-z]+$/i.test(path || "");
82
+ }
83
+ const TEST_RANK_PENALTY = 0.35;
74
84
  function score(query, doc, idf) {
75
85
  let s = 0;
76
86
  for (const [t, qn] of query) {
@@ -169,7 +179,7 @@ function findSubjectNodes(query, graph, inPrefix) {
169
179
  }
170
180
  function fallthroughNoteFor(subject) {
171
181
  return (`structural index: no entries for '${subject}' — showing lexical matches; ` +
172
- `for exhaustive callers use grep -rn '${subject}'`);
182
+ `for precise edges try graft callers '${subject}', or graft grep '${subject}' for every reference (loosen the pattern if it returns nothing)`);
173
183
  }
174
184
  function structural(query, graph, limit, inPrefix) {
175
185
  const wantsIn = INCOMING.test(query);
@@ -275,6 +285,13 @@ function lexical(query, corpus, limit, graphRank, inPrefix) {
275
285
  // ranty description can't linearly amplify an incidental word.
276
286
  const q = new Map([...counts(tokenize(query)).keys()].map((t) => [t, 1]));
277
287
  const graph = corpus.graph;
288
+ // Test-file de-ranking is query-aware: a query that ASKS about tests wants test
289
+ // files on top, so it gets no penalty; every other query wants the real
290
+ // definition first. This stops a test that merely contains the query's literals
291
+ // (e.g. `TestDownloadStall` matching "download stall") from outranking the
292
+ // source it exercises — the "tests ranked above the actual code" trap.
293
+ const wantsTests = /\b(tests?|specs?|coverage|assert(?:ion)?s?|fixtures?|mocks?)\b/i.test(query);
294
+ const testFactor = (path) => (!wantsTests && isTestPath(path) ? TEST_RANK_PENALTY : 1);
278
295
  // ── Pass 1: tokenize every scored field once, and collect per-document token
279
296
  // bags so IDF can down-weight words that occur across the whole corpus. `--in`
280
297
  // drops a concept unless at least one of its sources falls under the prefix —
@@ -429,9 +446,10 @@ function lexical(query, corpus, limit, graphRank, inPrefix) {
429
446
  const avg = docs.reduce((a, d) => a + bodyLen(d.body), 0) / docs.length;
430
447
  const out = new Map();
431
448
  for (const { n, name, path, body } of docs) {
432
- const total = score(q, name, sIdf) * 3 +
449
+ const total = (score(q, name, sIdf) * 3 +
433
450
  score(q, path, sIdf) * 2 +
434
- bm25(q, body, sIdf, bodyLen(body), avg);
451
+ bm25(q, body, sIdf, bodyLen(body), avg)) *
452
+ testFactor(n.path);
435
453
  if (total > 0)
436
454
  out.set(n.id, total);
437
455
  }
@@ -501,9 +519,10 @@ function lexical(query, corpus, limit, graphRank, inPrefix) {
501
519
  for (const { n, name, path, body } of symbolDocs) {
502
520
  // Name and path are short identifiers → plain idf-weighted overlap; the body
503
521
  // is length-normalized via BM25 so long definitions don't win on bulk.
504
- const total = score(q, name, idf) * 3 +
522
+ const total = (score(q, name, idf) * 3 +
505
523
  score(q, path, idf) * 2 +
506
- bm25(q, body, idf, bodyLen(body), avgBodyLen);
524
+ bm25(q, body, idf, bodyLen(body), avgBodyLen)) *
525
+ testFactor(n.path);
507
526
  if (total > 0) {
508
527
  lex.set(n.id, total);
509
528
  maxLex = Math.max(maxLex, total);
@@ -813,7 +832,8 @@ function askSavingsFooter(r, body) {
813
832
  const pct = Math.round((saved / base) * 100);
814
833
  return (`\n\n[graft] tokens saved ≈ ${saved.toLocaleString()} (${pct}%) — this pack ≈ ` +
815
834
  `${pack.toLocaleString()} tok vs reading the ${r.saved.files} source file(s) whole ≈ ` +
816
- `${base.toLocaleString()} tok. Estimate (baseline = those files read in full).`);
835
+ `${base.toLocaleString()} tok. Estimate (baseline = those files read in full).` +
836
+ SAVINGS_TURN_NUDGE);
817
837
  }
818
838
  /** Multi-scope footer: answers a reviewer's two questions — "which
819
839
  * sub-projects did this pack come from?" (`matched in:`, displayed-hit counts,