pi-graft 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KSonny4
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # pi-graft — Graft support for the pi harness, at Claude-Code parity
2
+
3
+ Graft (https://github.com/trailhq/Graft) builds a linked-markdown context graph
4
+ of your repo so coding agents skip re-exploration: fewer tool calls, fewer
5
+ tokens, faster runs. This repo answers *"is pi support a graft PR, or pi
6
+ extensions?"* with: **both, but each side is small** — and ships the pi side
7
+ with the **same integration depth Claude Code gets**.
8
+
9
+ ## TL;DR
10
+
11
+ | Layer | Claude Code | pi (this package) |
12
+ |---|---|---|
13
+ | Instructions | `AGENTS.md` section / skill file | same — pi auto-loads `AGENTS.md` (`graft init --agents agents`); skill bundled here + upstream `pi` host proposed |
14
+ | Per-prompt retrieval | UserPromptSubmit hook: gated `ask --json -n 3`, strength + novelty gates, scope hint | same, via `before_agent_start` → this turn's system prompt |
15
+ | Session orientation | SessionStart hook: directive + INDEX.md + stale banner | same, once per session, as a persistent message |
16
+ | Post-edit | dirty mark + stale count + blast radius inline | same, via `tool_result` on edit/write |
17
+ | Savings tally | graft-vs-source counts, `~N tok saved` accumulator, reply-tally check | same, in the shared session file |
18
+ | Background sync | Stop hook: detached structural rebuild | same, via `agent_settled` (plain `build` only — never `--deep`) |
19
+ | Statusline | nodes/edges, freshness, tok saved, last file | same, via `ctx.ui.setStatus` (+ `/graft-status`) |
20
+ | Tools | MCP server (6 tools) | same 6 names, CLI-backed (pi ships no MCP client) |
21
+
22
+ **Shared state, not a lookalike:** stats live in `graft/.cache/stats.json` and
23
+ per-session counters in `graft/.cache/session/<id>.json` using graft's own
24
+ schema, so `graft stats`, a Claude session, and a pi session in the same repo
25
+ read and update the same numbers. Every hook is fail-soft — a graft failure
26
+ never fails the turn.
27
+
28
+ ## Prerequisites
29
+
30
+ ```bash
31
+ npm install -g @nanonets/graft # the graft CLI
32
+ graft build # build graft/ in your repo (deterministic, no key, $0)
33
+ # optional, for LLM concept nodes:
34
+ graft build --deep # needs GRAFT_API_KEY (or --provider/--model/--api-key)
35
+ ```
36
+
37
+ ## Install this package
38
+
39
+ ```bash
40
+ pi install npm:pi-graft
41
+ # or project-local (shared with the team via .pi/settings.json):
42
+ pi install -l npm:pi-graft
43
+ # or try without installing:
44
+ pi -e npm:pi-graft
45
+ ```
46
+
47
+ Then wire the instruction layer in each repo you work in:
48
+
49
+ ```bash
50
+ graft init --agents agents # writes the AGENTS.md section pi already loads
51
+ graft build
52
+ ```
53
+
54
+ Restart pi (or `/reload`). The footer shows `graft <ver>: …ready/synced/stale`,
55
+ new sessions get the orientation message, and every prompt is retrieval-gated
56
+ like Claude's.
57
+
58
+ ## What you get
59
+
60
+ **Hooks (automatic — the Claude behaviors):**
61
+
62
+ - *Orientation.* First turn of each session injects the always-on directive +
63
+ `graft/INDEX.md` slice + stale banner (when the graph drifted).
64
+ - *Per-prompt retrieval.* Every prompt ≥12 chars runs `graft ask --json -n 3`
65
+ (6s budget, pointers-only — never `--source`, so per-prompt tokens stay tiny)
66
+ through the strength gate (symbol-name or broad match required, else a capped
67
+ weak-match nudge) and the novelty gate (already-injected pointers are never
68
+ re-injected). Multi-scope repos narrow via the last-edited file's scope.
69
+ - *Post-edit.* Editing a file appends its blast radius (who depends on it, top
70
+ 8, read straight from the wiring graph — no subprocess) to the tool result,
71
+ marks the graph dirty, and refreshes the stale count from `graft check`.
72
+ - *Savings tally.* Every tool call is classified (graft vs source reads;
73
+ a `[graft] tokens saved ≈ N` footer promotes even a bare `bash graft …` to
74
+ graft) and folded into the session counters; turn ends check whether the
75
+ reply told the user what was saved (`graftTurns`/`reportedTurns`, same as
76
+ `graft stats` consumes).
77
+ - *Background sync.* At settle, a dirty graph triggers a detached structural
78
+ `graft build` (MONEY GUARD: never `--deep`), with completion recorded in the
79
+ shared stats cache. Every query also refreshes structurally before answering,
80
+ so answers are never stale regardless.
81
+
82
+ **Tools** (same six operations as graft's MCP server, CLI-backed):
83
+
84
+ | Tool | Does | CLI equivalent |
85
+ |---|---|---|
86
+ | `graft_find_code` | ranked concept search, source inlined | `graft ask --source` |
87
+ | `graft_find_all` | exhaustive pattern search, grouped by symbol | `graft grep` |
88
+ | `graft_trace_calls` | exact caller/callee edges + blast radius | `graft callers` |
89
+ | `graft_file_api` | signatures-only file view (~10x cheaper) | `graft skeleton` |
90
+ | `graft_repo_map` | cold-start orientation | `graft map` |
91
+ | `graft_check_freshness` | drift report | `graft check` |
92
+
93
+ **Skill** (`/skill:graft`): the full usage playbook — which tool per task shape,
94
+ one-call discipline, savings tally. Mirrors upstream's skill text with pi tool
95
+ names.
96
+
97
+ **Commands**: `/graft-build`, `/graft-check`, `/graft-map`, `/graft-status`
98
+ (graph stats + this session's graft/source reads + tally ratio).
99
+
100
+ **Status**: footer shows `◤ graft · N nodes / M edges · ✓ synced / ⚠ N stale /
101
+ syncing… · ~N tok saved · last: file`. (pi's own footer already shows context
102
+ usage, and per-turn billing isn't exposed to pi extensions, so no ctx% or
103
+ dollar figure — tokens alone, never priced by hand.)
104
+
105
+ ## Configuration
106
+
107
+ | Env | Default | Meaning |
108
+ |---|---|---|
109
+ | `GRAFT_BIN` | `graft` | graft binary (absolute path if not on PATH) |
110
+ | `GRAFT_TIMEOUT_MS` | `30000` | per-command timeout for tools/commands |
111
+ | `GRAFT_DIR` | — | custom graph dir (same override the CLI honors) |
112
+
113
+ ## Upstream PR
114
+
115
+ Graft upstream tracks the `pi` host in [trailhq/Graft#341](https://github.com/trailhq/Graft/pull/341)
116
+ (`graft init --agents pi` → `.pi/skills/graft/SKILL.md`). Until it merges, this
117
+ package's bundled skill covers the same ground. No upstream change is needed
118
+ for the hooks — those live entirely in this extension.
119
+
120
+ ## Repo layout
121
+
122
+ ```
123
+ package.json pi package manifest (extensions + skills)
124
+ extensions/graft.ts the extension — hooks, tools, status, commands
125
+ skills/graft/SKILL.md the skill — usage playbook for the model
126
+ ```
127
+
128
+ ## License
129
+
130
+ MIT.
@@ -0,0 +1,940 @@
1
+ /**
2
+ * pi-graft — Graft context graph for the pi harness, at Claude-Code parity.
3
+ *
4
+ * Claude Code's deep integration (see graft's src/claude/) is five hooks plus
5
+ * a statusline, a skill, and an MCP server. This extension mirrors each one
6
+ * with the pi primitive that matches it closest:
7
+ *
8
+ * | Claude Code | pi equivalent here |
9
+ * |--------------------------------------|------------------------------------------------------|
10
+ * | SessionStart hook (orientation: | `before_agent_start`, once per session: injects the |
11
+ * | directive + INDEX.md slice + stale | same orientation as a persistent message |
12
+ * | banner) | |
13
+ * | UserPromptSubmit hook (gated | `before_agent_start`, every user prompt: runs |
14
+ * | `graft ask --json -n 3`, strength + | `graft ask --json -n 3` through the same strength |
15
+ * | novelty gates, scope hint; pointers- | + novelty gates (+ scope hint) and appends the pack |
16
+ * | only pack as additionalContext) | to that turn's system prompt |
17
+ * | PostToolUse edit hook (dirty mark + | `tool_result` on edit/write: marks the shared |
18
+ * | stale count + blast radius inline) | stats cache dirty, refreshes the stale count, and |
19
+ * | | appends the blast radius to the tool result |
20
+ * | PostToolUse savings hook (graft vs | `tool_result` on every tool: classifies graft vs |
21
+ * | source tally, saved-tokens footer | source use, sums `[graft] tokens saved ≈ N` footers |
22
+ * | accumulator) | into the shared session file |
23
+ * | Stop hook (tally check + detached | `turn_end` (tally check on the reply) + |
24
+ * | structural rebuild) | `agent_settled` (detached structural rebuild) |
25
+ * | statusline (nodes/edges, freshness, | `ctx.ui.setStatus("graft", …)` from the same cache |
26
+ * | tok saved, last file) | |
27
+ * | MCP server (6 tools) | 6 CLI-backed tools with the same names (pi ships no |
28
+ * | | built-in MCP client) |
29
+ * | skill file | bundled skill (same guidance, pi tool names) |
30
+ *
31
+ * Shared-state discipline (this is what makes it the SAME integration, not a
32
+ * lookalike): stats live in `graft/.cache/stats.json` and per-session counters
33
+ * in `graft/.cache/session/<id>.json`, using graft's own schema, so `graft
34
+ * stats`, a Claude session, and a pi session in the same repo all read and
35
+ * update the same numbers. Writes are atomic (scratch file + rename) and every
36
+ * hook is fail-soft: a graft failure must never fail the turn.
37
+ *
38
+ * MONEY GUARD: the background sync runs plain `graft build` only —
39
+ * structural, $0, offline. Never `--deep` (that calls the LLM on your key).
40
+ *
41
+ * Requires: `graft` on PATH (`npm install -g @nanonets/graft`) and a built
42
+ * graph (`graft build` in the repo; `graft build --deep` for concept nodes).
43
+ */
44
+
45
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
46
+ import { Type } from "typebox";
47
+ import { execFile, execFileSync, spawn } from "node:child_process";
48
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
49
+ import { basename, dirname, isAbsolute, join } from "node:path";
50
+
51
+ // ── constants (mirror graft's own floors/caps) ─────────────────────────────
52
+
53
+ const GRAFT_BIN = process.env.GRAFT_BIN ?? "graft";
54
+ const TOOL_TIMEOUT_MS = Number(process.env.GRAFT_TIMEOUT_MS ?? 30_000);
55
+ /** Prompt-hook ask budget: the 8s hook budget minus headroom for our own work. */
56
+ const ASK_TIMEOUT_MS = 6_000;
57
+ const CHECK_TIMEOUT_MS = 8_000;
58
+ /** Background structural rebuild budget (mirrors graft's sync-run). */
59
+ const BUILD_TIMEOUT_MS = 120_000;
60
+ /** Prompts shorter than this never trigger retrieval (conversational noise). */
61
+ const MIN_PROMPT_CHARS = 12;
62
+ /** Strength gate, same two-clause rule as graft's fuse ranking. */
63
+ const STRONG_FLOOR = 0.1;
64
+ const HIGH_FLOOR = 0.5;
65
+ /** Novelty gate memory + weak-nudge budget, same as the Claude hooks. */
66
+ const INJECTED_POINTERS_CAP = 40;
67
+ const NUDGE_CAP = 2;
68
+
69
+ const SAVINGS_RE = /\[graft\] tokens saved ≈ ([\d,]+)/g;
70
+ /** Anchored on "graft saved" + number + tokens so prose about graft can't trip it. */
71
+ const TALLY_RE = /graft\s+saved\s*[~≈]?\s*[\d,.]+\s*[km]?\s*(?:tok|tokens)/i;
72
+
73
+ // ── subprocesses ───────────────────────────────────────────────────────────
74
+
75
+ function runGraftAsync(args: string[], cwd: string, timeout = TOOL_TIMEOUT_MS): Promise<string> {
76
+ return new Promise((resolve, reject) => {
77
+ execFile(
78
+ GRAFT_BIN,
79
+ args,
80
+ { cwd, timeout, maxBuffer: 10 * 1024 * 1024 },
81
+ (err, stdout, stderr) => {
82
+ if (err) {
83
+ const out = `${stdout ?? ""}${stderr ? `\n${stderr}` : ""}`.trim();
84
+ reject(new Error(out || err.message));
85
+ return;
86
+ }
87
+ resolve(String(stdout ?? ""));
88
+ },
89
+ );
90
+ });
91
+ }
92
+
93
+ /** Synchronous JSON call for turn-blocking hooks. Returns null on any failure
94
+ * (timeout, no graph, unparseable) — hooks stay silent rather than failing. */
95
+ function graftJson(dir: string, args: string[], timeout: number): any | null {
96
+ try {
97
+ const full = withContextDirArg(dir, args);
98
+ const out = execFileSync(GRAFT_BIN, full, {
99
+ cwd: dir, encoding: "utf8", timeout, stdio: ["ignore", "pipe", "ignore"],
100
+ });
101
+ return JSON.parse(out);
102
+ } catch (e: any) {
103
+ // `graft check` exits non-zero on drift by design but still prints valid
104
+ // JSON — recover it before giving up.
105
+ const stdout = typeof e?.stdout === "string" ? e.stdout.trim() : "";
106
+ if (stdout) {
107
+ try { return JSON.parse(stdout); } catch { /* fall through */ }
108
+ }
109
+ return null;
110
+ }
111
+ }
112
+
113
+ // ── graft paths (mirror util/state resolveContextDir/cacheDir) ──────────────
114
+
115
+ function contextDir(projectDir: string): string {
116
+ const override = process.env.GRAFT_DIR;
117
+ if (!override) return join(projectDir, "graft");
118
+ return isAbsolute(override) ? override : join(projectDir, override);
119
+ }
120
+
121
+ function withContextDirArg(dir: string, args: string[]): string[] {
122
+ return process.env.GRAFT_DIR ? [...args, "--dir", contextDir(dir)] : args;
123
+ }
124
+
125
+ const cacheDir = (d: string) => join(contextDir(d), ".cache");
126
+ const statsPath = (d: string) => join(cacheDir(d), "stats.json");
127
+ const wiringPath = (d: string) => join(contextDir(d), ".graph", "wiring.json");
128
+ const indexPath = (d: string) => join(contextDir(d), "INDEX.md");
129
+ const sessionPath = (d: string, id: string) =>
130
+ join(cacheDir(d), "session", `${id.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
131
+
132
+ const hasGraph = (d: string) => existsSync(contextDir(d));
133
+
134
+ // ── shared state (graft's own schema, atomic writes) ────────────────────────
135
+
136
+ interface Stats {
137
+ nodeCount: number; edgeCount: number; languages: string[];
138
+ totalCount: number; readyCount: number;
139
+ staleCount: number; dirty: boolean; syncing: boolean;
140
+ syncedAt: string | null; lastFile: string | null;
141
+ }
142
+
143
+ interface SessionState {
144
+ lastQuery: string | null;
145
+ perAgentQuery: Record<string, string>;
146
+ graftReads: number; sourceReads: number;
147
+ savedTokens: number;
148
+ injectedPointers?: string[];
149
+ nudges?: number;
150
+ graftTurns?: number;
151
+ reportedTurns?: number;
152
+ turnUsedGraft?: boolean;
153
+ lastTallyUuid?: string;
154
+ }
155
+
156
+ function emptyStats(): Stats {
157
+ return {
158
+ nodeCount: 0, edgeCount: 0, languages: [], totalCount: 0, readyCount: 0,
159
+ staleCount: 0, dirty: false, syncing: false, syncedAt: null, lastFile: null,
160
+ };
161
+ }
162
+
163
+ function emptySession(): SessionState {
164
+ return {
165
+ lastQuery: null, perAgentQuery: {}, graftReads: 0, sourceReads: 0,
166
+ savedTokens: 0, injectedPointers: [], nudges: 0,
167
+ };
168
+ }
169
+
170
+ function readJson<T>(p: string): T | null {
171
+ try { return JSON.parse(readFileSync(p, "utf8")) as T; } catch { return null; }
172
+ }
173
+
174
+ function writeJsonAtomic(p: string, value: unknown): void {
175
+ mkdirSync(dirname(p), { recursive: true });
176
+ const tmp = `${p}.${process.pid}.tmp`;
177
+ try {
178
+ writeFileSync(tmp, JSON.stringify(value));
179
+ renameSync(tmp, p);
180
+ } catch {
181
+ try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
182
+ throw new Error(`write ${p} failed`);
183
+ }
184
+ }
185
+
186
+ function readStats(d: string): Stats | null {
187
+ return readJson<Stats>(statsPath(d));
188
+ }
189
+
190
+ function patchStats(d: string, patch: Partial<Stats>): Stats {
191
+ const next: Stats = { ...(readStats(d) ?? emptyStats()), ...patch };
192
+ writeJsonAtomic(statsPath(d), next);
193
+ return next;
194
+ }
195
+
196
+ function readSession(d: string, id: string): SessionState {
197
+ return readJson<SessionState>(sessionPath(d, id)) ?? emptySession();
198
+ }
199
+
200
+ function writeSession(d: string, id: string, s: SessionState): void {
201
+ writeJsonAtomic(sessionPath(d, id), s);
202
+ }
203
+
204
+ // ── wiring graph (pure reads — the fast path behind blast radius/status) ────
205
+
206
+ interface WiringNode { id: string; name: string; kind: string; path?: string; summary_state?: string }
207
+ interface WiringEdge { source: string; target: string; relation: string }
208
+ interface Wiring { meta?: { nodeCount?: number; edgeCount?: number; languages?: string[]; scopes?: { prefix: string }[] }; nodes?: WiringNode[]; edges?: WiringEdge[] }
209
+
210
+ function readWiring(d: string): Wiring | null {
211
+ return readJson<Wiring>(wiringPath(d));
212
+ }
213
+
214
+ /** Statusline fast path: hook-maintained cache first, wiring graph as fallback
215
+ * (a fresh `graft build` doesn't write the cache), null only when unbuilt. */
216
+ function resolveStats(d: string): Stats | null {
217
+ const cached = readStats(d);
218
+ if (cached && cached.nodeCount > 0) return cached;
219
+ const w = readWiring(d);
220
+ if (!w) return null;
221
+ const nodes = w.nodes ?? [];
222
+ const edges = w.edges ?? [];
223
+ return {
224
+ ...emptyStats(),
225
+ nodeCount: w.meta?.nodeCount ?? nodes.length,
226
+ edgeCount: w.meta?.edgeCount ?? edges.length,
227
+ languages: w.meta?.languages ?? [],
228
+ totalCount: nodes.length,
229
+ readyCount: nodes.filter((n) => n.summary_state === "ready").length,
230
+ };
231
+ }
232
+
233
+ // ── formatting (mirror claude/format) ───────────────────────────────────────
234
+
235
+ function freshnessSegment(s: Stats): string {
236
+ if (s.syncing) return "syncing…";
237
+ if (s.dirty && s.staleCount > 0) return `⚠ ${s.staleCount} stale`;
238
+ if (s.dirty) return "⚠ stale";
239
+ return "✓ synced";
240
+ }
241
+
242
+ /** One footer line: `◤ graft · N nodes / M edges · ✓ synced · ~N tok saved`.
243
+ * (pi's own footer already shows context usage, so no ctx% line — unlike the
244
+ * two-line Claude bar. Dollar figures need per-turn billing only the Claude
245
+ * transcript exposes, so pi shows tokens alone rather than pricing them.) */
246
+ function renderStatusline(stats: Stats | null, session: SessionState | null): string {
247
+ if (!stats) return "◤ graft · not built · run graft build";
248
+ const parts = [`◤ graft`, `${stats.nodeCount} nodes / ${stats.edgeCount} edges`, freshnessSegment(stats)];
249
+ const saved = session?.savedTokens ?? 0;
250
+ if (saved > 0) parts.push(`~${saved.toLocaleString()} tok saved`);
251
+ if (stats.lastFile) parts.push(`last: ${basename(stats.lastFile)}`);
252
+ return parts.join(" · ");
253
+ }
254
+
255
+ /** Blast radius for an edited file: who depends on it (cap 8, like Claude). */
256
+ function formatBlastRadius(w: Wiring, filePath: string, cap = 8): string | null {
257
+ const ids = new Set(
258
+ (w.nodes ?? [])
259
+ .filter((n) => n.path && (filePath === n.path || filePath.endsWith(`/${n.path}`)))
260
+ .map((n) => n.id),
261
+ );
262
+ if (!ids.size) return null;
263
+ const incoming = (w.edges ?? []).filter((e) => ids.has(e.target) && !ids.has(e.source));
264
+ if (!incoming.length) return null;
265
+ const byId = new Map((w.nodes ?? []).map((n) => [n.id, n]));
266
+ const items = incoming.slice(0, cap).map((e) => {
267
+ const n = byId.get(e.source);
268
+ const label = n ? `${n.name} (${basename(n.path ?? e.source)})` : e.source;
269
+ return ` • ${e.relation} ← ${label}`;
270
+ });
271
+ const more = incoming.length > cap ? `\n • +${incoming.length - cap} more` : "";
272
+ return `[graft] blast radius for ${basename(filePath)}, who depends on it:\n${items.join("\n")}${more}`;
273
+ }
274
+
275
+ interface AskHit { title: string; pointer: string; snippet?: string; code?: string }
276
+ interface AskJson {
277
+ hits: AskHit[];
278
+ saved?: { files: number; baselineChars: number };
279
+ coverage?: number;
280
+ coverageStrong?: number;
281
+ }
282
+
283
+ const tokensOf = (chars: number) => Math.round(chars / 4);
284
+
285
+ function retrievalBody(hits: AskHit[]): string {
286
+ const blocks = hits.map((h, i) => {
287
+ const ptr = (h.pointer ?? "").split(",")[0].trim();
288
+ const snip = (h.snippet ?? "").replace(/\s+/g, " ").trim().slice(0, 140);
289
+ let b = ` ${i + 1}. ${h.title}: ${ptr}`;
290
+ if (snip) b += `\n ${snip}`;
291
+ if (h.code) b += `\n\`\`\`\n${h.code}\n\`\`\``;
292
+ return b;
293
+ });
294
+ // Pointers-only pack (the prompt hook never passes --source: per-prompt
295
+ // injected tokens are fresh full-price input, so the pack stays tiny and the
296
+ // agent pulls spans itself via the find-code tool when a pointer looks right).
297
+ const header = hits.some((h) => h.code)
298
+ ? "[graft] retrieved context, read these spans; do not re-open the files:"
299
+ : "[graft] starting points for this task: pull the code inline with graft_find_code (or `graft ask \"<what you need>\" --source`), trace impact with graft_trace_calls, or search with graft_find_all:";
300
+ return `${header}\n${blocks.join("\n")}`;
301
+ }
302
+
303
+ function retrievalTokensSaved(ask: AskJson, cap = 3): number {
304
+ const hits = (ask.hits ?? []).slice(0, cap);
305
+ if (!hits.length || !ask.saved || ask.saved.baselineChars <= 0) return 0;
306
+ const pack = tokensOf(retrievalBody(hits).length);
307
+ const base = tokensOf(ask.saved.baselineChars);
308
+ return base > pack ? base - pack : 0;
309
+ }
310
+
311
+ function formatRetrieval(ask: AskJson, cap = 3): string | null {
312
+ const hits = (ask.hits ?? []).slice(0, cap);
313
+ if (!hits.length) return null;
314
+ const body = retrievalBody(hits);
315
+ const saved = retrievalTokensSaved(ask, cap);
316
+ if (saved <= 0) return body;
317
+ const base = tokensOf(ask.saved!.baselineChars);
318
+ const pct = Math.round((saved / base) * 100);
319
+ return (
320
+ `${body}\n[graft] tokens saved ≈ ${saved.toLocaleString()} (${pct}%); this pack ≈ ` +
321
+ `${tokensOf(body.length).toLocaleString()} tok vs reading the ${ask.saved!.files} file(s) whole ≈ ` +
322
+ `${base.toLocaleString()} tok (estimate).`
323
+ );
324
+ }
325
+
326
+ function weakMatchNudge(s: SessionState, strong: number): string | null {
327
+ const spent = s.nudges ?? 0;
328
+ if (spent >= NUDGE_CAP) return null;
329
+ s.nudges = spent + 1;
330
+ return (
331
+ `[graft] no strong match for this prompt (name-field match ${strong.toFixed(2)}) — the graph ` +
332
+ `has more than this probe found. Run graft_find_code (or \`graft ask "<your task>" --source\`) before grepping.`
333
+ );
334
+ }
335
+
336
+ /** Per-prompt injection gate: strength first, novelty second. Mutates `s`
337
+ * (injected pointers, nudges); the caller persists it. */
338
+ function relevantRetrieval(ask: AskJson, s: SessionState, cap = 3): string | null {
339
+ if (!(ask.hits ?? []).length) return null;
340
+ const lexical = typeof ask.coverage === "number" || typeof ask.coverageStrong === "number";
341
+ if (lexical) {
342
+ const strong = ask.coverageStrong ?? 0;
343
+ const broad = ask.coverage ?? 0;
344
+ if (strong < STRONG_FLOOR && broad < HIGH_FLOOR) return weakMatchNudge(s, strong);
345
+ }
346
+ const seen = new Set(s.injectedPointers ?? []);
347
+ const fresh = ask.hits.filter((h) => !seen.has(h.pointer));
348
+ if (!fresh.length) return null;
349
+ const txt = formatRetrieval({ ...ask, hits: fresh }, cap);
350
+ if (!txt) return null;
351
+ s.injectedPointers = [...(s.injectedPointers ?? []), ...fresh.slice(0, cap).map((h) => h.pointer)]
352
+ .slice(-INJECTED_POINTERS_CAP);
353
+ return txt;
354
+ }
355
+
356
+ function staleNote(d: string): string {
357
+ try {
358
+ const s = readStats(d);
359
+ if (s?.dirty && (s.staleCount ?? 0) > 0) {
360
+ return `⚠ ${s.staleCount} file(s) changed since the graph was built — answers may miss them. \`graft build\` refreshes (structural, $0).`;
361
+ }
362
+ } catch { /* ignore */ }
363
+ return "";
364
+ }
365
+
366
+ /** Session orientation: always-on directive (the reliable steering channel —
367
+ * it fires every session, unlike the discretionary skill) + INDEX.md slice. */
368
+ function formatOrientation(indexMd: string, stale: string): string {
369
+ const directive =
370
+ `[graft] This repo is indexed by graft. To find, understand, or change code, reach for graft first; it answers from a prebuilt graph with exact file:line, faster than grep/read. Pick the ONE tool that fits and act on its answer. Most tasks need a single call. If one isn't enough, switch to the tool that fits the next need; don't call the same tool again and again or re-ask a question reworded:\n` +
371
+ ` • graft_find_code (or \`graft ask "<task>" --source\`): locate + understand. Ranked nodes with the code inlined at each file:line (the ≤8-line crux; add full:true / --full for the whole span). The default for "how does X work" / "where is Y".\n` +
372
+ ` • graft_find_all (or \`graft grep "<literal>"\`): exhaustive find. Every occurrence, grouped by enclosing symbol; use when you need them ALL (find-code is ranked top-N and misses instances).\n` +
373
+ ` • graft_file_api (or \`graft skeleton <file>\`): a file's whole API in ~200 tokens, every signature + span, ~10x cheaper than reading the file.\n` +
374
+ ` • graft_trace_calls (or \`graft callers <sym> [--direction out] [--depth N|all]\`): exact edges. Who calls it (default), what it calls (out), or the full blast radius (--depth 2, or all for every connected source). Run before you change a symbol.\n` +
375
+ ` • graft_repo_map (or \`graft map\`): orientation for an unfamiliar repo, directory clusters, hubs, hotspots. map alone is the answer; don't then skeleton every subsystem it names.\n` +
376
+ ` In a monorepo, add in:<path>/ to find-code / find-all / trace-calls to scope to one sub-project; hits are labeled [scope/].\n` +
377
+ ` Already know the file or symbol to change? Go straight to it: graft_find_all for "<symbol>", read the span, edit. Save find-code for when you don't yet know where the code lives.\n` +
378
+ ` Refactor, rename, or multi-file change? Run graft_trace_calls with depth all FIRST to map every connected file; editing the primary file and stopping is the classic miss.\n` +
379
+ `Each tool opens its output with a "[graft] tokens saved ≈ N" line; when you used graft this turn, close your reply with a one-line tally of the total saved (e.g. 🌱 graft saved ~12k tokens this turn, 3 calls). Never pipe graft output through head/tail — it is already capped, and clipping drops that line.\n`;
380
+ const banner = stale ? `${stale}\n\n` : "";
381
+ return `${banner}${directive}\nrepo map (graft/INDEX.md):\n${indexMd.slice(0, 1500)}`;
382
+ }
383
+
384
+ /** Multi-scope hint: narrow the prompt ask to the scope holding the last-edited
385
+ * file. Best-effort — single-scope graphs, unknown files, and ambiguous
386
+ * basenames all skip silently. */
387
+ function lastFileScopeHint(d: string, lastFile: string | null | undefined): string | null {
388
+ if (!lastFile) return null;
389
+ try {
390
+ const w = readWiring(d);
391
+ if (!w) return null;
392
+ const scopes = w.meta?.scopes ?? [];
393
+ if (scopes.length <= 1) return null;
394
+ const base = basename(lastFile);
395
+ const matches = (w.nodes ?? []).filter(
396
+ (n) => n.path && (n.path === base || n.path.endsWith(`/${base}`)),
397
+ );
398
+ if (!matches.length) return null;
399
+ const prefixes = new Set(
400
+ matches.map((n) => {
401
+ const sorted = [...scopes].sort((a, b) => b.prefix.length - a.prefix.length);
402
+ return sorted.find((s) => s.prefix === "" || n.path === s.prefix || n.path!.startsWith(`${s.prefix}/`))?.prefix ?? "";
403
+ }),
404
+ );
405
+ if (prefixes.size > 1) return null;
406
+ const [prefix] = prefixes;
407
+ return prefix === "" ? null : prefix;
408
+ } catch {
409
+ return null;
410
+ }
411
+ }
412
+
413
+ // ── tool-use classification + savings (mirror session-metrics) ──────────────
414
+
415
+ const SOURCE_TOOLS = new Set(["read", "grep", "glob", "find", "ls", "search"]);
416
+
417
+ function classifyToolUse(toolName: string, command?: string): "graft" | "source" | null {
418
+ const name = (toolName ?? "").toLowerCase();
419
+ if (name.startsWith("graft_")) return "graft";
420
+ if (name === "bash" || name === "powershell") {
421
+ if (command && /(^|[\s;&|])(sudo\s+)?graft[\s]/.test(` ${command} `)) return "graft";
422
+ return null;
423
+ }
424
+ if (SOURCE_TOOLS.has(name)) return "source";
425
+ return null;
426
+ }
427
+
428
+ function sumSavingsFooters(text: string): number {
429
+ let total = 0;
430
+ for (const m of text.matchAll(SAVINGS_RE)) total += Number(m[1].replace(/,/g, "")) || 0;
431
+ return total;
432
+ }
433
+
434
+ function assistantText(message: any): string {
435
+ const c = message?.content ?? message?.text ?? "";
436
+ if (typeof c === "string") return c;
437
+ if (Array.isArray(c)) {
438
+ return c.map((b) => (typeof b === "string" ? b : (b?.text ?? ""))).join("\n");
439
+ }
440
+ return "";
441
+ }
442
+
443
+ /** Edited-file path across pi edit-tool shapes; null when not an edit. */
444
+ function editedFilePath(toolName: string, input: any): string | null {
445
+ const name = (toolName ?? "").toLowerCase();
446
+ if (name !== "edit" && name !== "write") return null;
447
+ const p = input?.path ?? input?.file ?? input?.file_path ?? input?.filename;
448
+ return typeof p === "string" && p.trim() ? p : null;
449
+ }
450
+
451
+ function underGraft(d: string, file: string): boolean {
452
+ const abs = isAbsolute(file) ? file : join(d, file);
453
+ const ctx = contextDir(d);
454
+ return abs === ctx || abs.startsWith(`${ctx}/`);
455
+ }
456
+
457
+ function toolText(text: string) {
458
+ return {
459
+ content: [{ type: "text" as const, text }],
460
+ details: {},
461
+ };
462
+ }
463
+
464
+ function graftErrorText(err: unknown): string {
465
+ const msg = err instanceof Error ? err.message : String(err);
466
+ if (/no graph|graft build/i.test(msg)) {
467
+ return `${msg}\n\nNo graft/ graph here yet. Run \`graft build\` (or \`graft init --agents agents && graft build\`) in the repo root, then retry.`;
468
+ }
469
+ if (/ENOENT|not found|command not found/i.test(msg)) {
470
+ return `graft CLI not found on PATH. Install it once with \`npm install -g @nanonets/graft\`, then run \`graft build\` in this repo.\n\nUnderlying error: ${msg}`;
471
+ }
472
+ return msg;
473
+ }
474
+
475
+ // ── extension ───────────────────────────────────────────────────────────────
476
+
477
+ export default function (pi: ExtensionAPI) {
478
+ const versionCache = new Map<string, string | null>();
479
+ /** Sessions already given the orientation message (reset on session_start). */
480
+ const oriented = new Set<string>();
481
+ /** Working dirs with a background sync in flight. */
482
+ const syncing = new Set<string>();
483
+
484
+ async function graftVersion(cwd: string): Promise<string | null> {
485
+ if (!versionCache.has(cwd)) {
486
+ try {
487
+ const out = await runGraftAsync(["--version"], cwd, 5_000);
488
+ versionCache.set(cwd, out.trim().slice(0, 40) || "unknown");
489
+ } catch {
490
+ versionCache.set(cwd, null);
491
+ }
492
+ }
493
+ return versionCache.get(cwd) ?? null;
494
+ }
495
+
496
+ async function refreshStatus(ctx: ExtensionContext, cwd: string) {
497
+ try {
498
+ const version = await graftVersion(cwd);
499
+ if (!version) {
500
+ ctx.ui.setStatus("graft", "graft: not installed (npm i -g @nanonets/graft)");
501
+ return;
502
+ }
503
+ const stats = resolveStats(cwd);
504
+ if (!stats) {
505
+ ctx.ui.setStatus("graft", `graft ${version}: no graph — run graft build`);
506
+ return;
507
+ }
508
+ let session: SessionState | null = null;
509
+ try { session = readSession(cwd, ctx.sessionManager.getSessionId()); } catch { /* ignore */ }
510
+ ctx.ui.setStatus("graft", `graft ${version}: ${renderStatusline(stats, session)}`);
511
+ } catch { /* status must never fail the turn */ }
512
+ }
513
+
514
+ function sid(ctx: ExtensionContext): string {
515
+ try { return ctx.sessionManager.getSessionId() ?? "default"; }
516
+ catch { return "default"; }
517
+ }
518
+
519
+ pi.on("session_start", async (_event, ctx) => {
520
+ try { oriented.delete(`${sid(ctx)}@${ctx.cwd}`); } catch { /* ignore */ }
521
+ await refreshStatus(ctx, ctx.cwd);
522
+ });
523
+
524
+ // SessionStart + UserPromptSubmit hooks, combined: orientation once per
525
+ // session (persistent message), gated retrieval pack every prompt (this
526
+ // turn's system prompt — per-turn context, never persisted history).
527
+ pi.on("before_agent_start", async (event, ctx) => {
528
+ const cwd = ctx.cwd;
529
+ if (!hasGraph(cwd)) return;
530
+ const out: { message?: any; systemPrompt?: string } = {};
531
+
532
+ // 1 · orientation (once per session).
533
+ const okey = `${sid(ctx)}@${cwd}`;
534
+ if (!oriented.has(okey)) {
535
+ oriented.add(okey);
536
+ try {
537
+ const idx = readFileSync(indexPath(cwd), "utf8");
538
+ out.message = {
539
+ customType: "graft-orientation",
540
+ content: formatOrientation(idx, staleNote(cwd)),
541
+ display: false,
542
+ };
543
+ } catch {
544
+ // No INDEX.md (never built here) — nothing to orient with.
545
+ }
546
+ }
547
+
548
+ // 2 · per-prompt retrieval (pointers only, gated).
549
+ try {
550
+ const prompt = String((event as any)?.prompt ?? "").trim();
551
+ if (prompt.length >= MIN_PROMPT_CHARS) {
552
+ const askArgs = ["ask", prompt, ".", "--json", "-n", "3"];
553
+ const stats = readStats(cwd);
554
+ const scopeHint = lastFileScopeHint(cwd, stats?.lastFile);
555
+ if (scopeHint) askArgs.push("--in", scopeHint);
556
+ const ask = graftJson(cwd, askArgs, ASK_TIMEOUT_MS) as AskJson | null;
557
+ if (ask) {
558
+ const id = sid(ctx);
559
+ const s = readSession(cwd, id);
560
+ s.lastQuery = prompt;
561
+ const txt = relevantRetrieval(ask, s);
562
+ writeSession(cwd, id, s);
563
+ if (txt) {
564
+ const base = (event as any)?.systemPrompt ?? ctx.getSystemPrompt();
565
+ out.systemPrompt = `${base}\n\n${txt}`;
566
+ }
567
+ }
568
+ }
569
+ } catch { /* retrieval is advisory — never fail the turn */ }
570
+
571
+ if (out.message || out.systemPrompt) return out;
572
+ });
573
+
574
+ // PostToolUse savings hook (every tool) + post-edit hook (edit/write):
575
+ // shared counters, dirty marking, and inline blast radius.
576
+ pi.on("tool_result", async (event, ctx) => {
577
+ const cwd = ctx.cwd;
578
+ if (!hasGraph(cwd)) return;
579
+ const ev = event as any;
580
+ const toolName: string = ev?.toolName ?? "";
581
+ const input: any = ev?.input;
582
+ let patch: { content?: any; details?: any } | undefined;
583
+
584
+ // 1 · savings tally (all tools — the no-write path keeps it cheap).
585
+ try {
586
+ const command = typeof input?.command === "string" ? input.command : undefined;
587
+ let kind = classifyToolUse(toolName, command);
588
+ const saved = sumSavingsFooters(JSON.stringify(ev?.content ?? ""));
589
+ if (saved > 0 && kind !== "graft") kind = "graft"; // footer proves graft ran
590
+ if (kind || saved > 0) {
591
+ const id = sid(ctx);
592
+ const s = readSession(cwd, id);
593
+ if (kind === "graft") { s.graftReads = (s.graftReads ?? 0) + 1; s.turnUsedGraft = true; }
594
+ else if (kind === "source") s.sourceReads = (s.sourceReads ?? 0) + 1;
595
+ else if (saved > 0) s.turnUsedGraft = true;
596
+ s.savedTokens = (s.savedTokens ?? 0) + saved;
597
+ writeSession(cwd, id, s);
598
+ if (saved > 0) await refreshStatus(ctx, cwd);
599
+ }
600
+ } catch { /* tally must never fail the turn */ }
601
+
602
+ // 2 · post-edit: blast radius inline + dirty marking (edits only).
603
+ try {
604
+ if (ev?.isError) return patch;
605
+ const rel = editedFilePath(toolName, input);
606
+ if (!rel) return patch;
607
+ const abs = isAbsolute(rel) ? rel : join(cwd, rel);
608
+ if (underGraft(cwd, abs)) return patch;
609
+ const w = readWiring(cwd);
610
+ if (w) {
611
+ const br = formatBlastRadius(w, abs);
612
+ if (br) {
613
+ const content = Array.isArray(ev?.content)
614
+ ? [...ev.content, { type: "text", text: br }]
615
+ : [{ type: "text", text: String(ev?.content ?? "") }, { type: "text", text: br }];
616
+ patch = { ...patch, content };
617
+ }
618
+ }
619
+ // Mark dirty + refresh the stale count (pure drift report, no rebuild).
620
+ const check = graftJson(cwd, ["check", ".", "--json"], CHECK_TIMEOUT_MS) as any;
621
+ const g = check?.graph ?? {};
622
+ const staleCount =
623
+ (g.changed?.length ?? 0) + (g.added?.length ?? 0) + (g.removed?.length ?? 0);
624
+ patchStats(cwd, { dirty: true, staleCount, lastFile: basename(abs) });
625
+ await refreshStatus(ctx, cwd);
626
+ } catch { /* post-edit work is advisory */ }
627
+
628
+ return patch;
629
+ });
630
+
631
+ // Stop hook, part 1: did the reply the user just read say what graft saved?
632
+ // Only runs on turns the savings hook flagged, and duplicate ends can't
633
+ // double-count (lastTallyUuid), mirroring graft's tally.
634
+ pi.on("turn_end", async (event, ctx) => {
635
+ const cwd = ctx.cwd;
636
+ if (!hasGraph(cwd)) return;
637
+ try {
638
+ const id = sid(ctx);
639
+ const s = readSession(cwd, id);
640
+ if (!s.turnUsedGraft) return;
641
+ const ev = event as any;
642
+ const text = assistantText(ev?.message);
643
+ const uuid = String(ev?.message?.uuid ?? ev?.message?.id ?? ev?.turnIndex ?? "");
644
+ if (!text || uuid === (s.lastTallyUuid ?? "")) {
645
+ s.turnUsedGraft = false;
646
+ writeSession(cwd, id, s);
647
+ return;
648
+ }
649
+ s.graftTurns = (s.graftTurns ?? 0) + 1;
650
+ if (TALLY_RE.test(text)) s.reportedTurns = (s.reportedTurns ?? 0) + 1;
651
+ s.turnUsedGraft = false;
652
+ s.lastTallyUuid = uuid;
653
+ writeSession(cwd, id, s);
654
+ } catch { /* metrics must never fail the turn */ }
655
+ });
656
+
657
+ // Stop hook, part 2: background structural rebuild at settle (never --deep).
658
+ // Detached so it survives `-p` exits; the completion is recorded in the
659
+ // shared stats cache, which the next status refresh picks up.
660
+ pi.on("agent_settled", async (_event, ctx) => {
661
+ const cwd = ctx.cwd;
662
+ if (!hasGraph(cwd) || syncing.has(cwd)) return;
663
+ let stats: Stats | null = null;
664
+ try { stats = readStats(cwd); } catch { return; }
665
+ if (!stats?.dirty) return;
666
+ syncing.add(cwd);
667
+ try { patchStats(cwd, { syncing: true }); } catch { /* ignore */ }
668
+ await refreshStatus(ctx, cwd);
669
+ const dir = cwd;
670
+ const bin = GRAFT_BIN;
671
+ const syncScript = `
672
+ const {execFileSync} = require("node:child_process");
673
+ const {readFileSync, writeFileSync, mkdirSync, renameSync, existsSync} = require("node:fs");
674
+ const {join, dirname} = require("node:path");
675
+ const [dir, bin] = process.argv.slice(-2);
676
+ const ctxDir = process.env.GRAFT_DIR
677
+ ? (process.env.GRAFT_DIR.startsWith("/") ? process.env.GRAFT_DIR : join(dir, process.env.GRAFT_DIR))
678
+ : join(dir, "graft");
679
+ const statsP = join(ctxDir, ".cache", "stats.json");
680
+ const wiringP = join(ctxDir, ".graph", "wiring.json");
681
+ const readJ = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
682
+ const writeAtomic = (p, v) => {
683
+ mkdirSync(dirname(p), { recursive: true });
684
+ const tmp = p + "." + process.pid + ".tmp";
685
+ try { writeFileSync(tmp, JSON.stringify(v)); renameSync(tmp, p); }
686
+ catch { try { require("node:fs").rmSync(tmp, { force: true }); } catch {} }
687
+ };
688
+ try {
689
+ execFileSync(bin, ["build", "."], { cwd: dir, stdio: "ignore", timeout: ${BUILD_TIMEOUT_MS} });
690
+ const w = readJ(wiringP);
691
+ if (!w) { writeAtomic(statsP, { ...(readJ(statsP) ?? {}), syncing: false }); process.exit(0); }
692
+ const nodes = w.nodes ?? [], edges = w.edges ?? [];
693
+ writeAtomic(statsP, {
694
+ ...(readJ(statsP) ?? {}),
695
+ dirty: false, staleCount: 0, syncing: false, syncedAt: new Date().toISOString(),
696
+ nodeCount: (w.meta && w.meta.nodeCount) || nodes.length,
697
+ edgeCount: (w.meta && w.meta.edgeCount) || edges.length,
698
+ languages: (w.meta && w.meta.languages) || [],
699
+ totalCount: nodes.length,
700
+ readyCount: nodes.filter((n) => n.summary_state === "ready").length,
701
+ });
702
+ } catch {
703
+ try { writeAtomic(statsP, { ...(readJ(statsP) ?? {}), syncing: false }); } catch {}
704
+ }
705
+ `;
706
+ try {
707
+ const child = spawn(process.execPath, ["-e", syncScript, dir, bin], {
708
+ cwd: dir, detached: true, stdio: "ignore", windowsHide: true,
709
+ });
710
+ child.unref();
711
+ } catch { /* best-effort; the next query refreshes anyway */ }
712
+ // Reconcile locally: the detached run owns completion, this only clears
713
+ // our in-flight guard after a grace period (next settle retries if dirty).
714
+ setTimeout(() => { syncing.delete(dir); }, BUILD_TIMEOUT_MS);
715
+ });
716
+
717
+ // ── the six tools (MCP names, CLI-backed — pi has no MCP client) ──────────
718
+
719
+ pi.registerTool({
720
+ name: "graft_find_code",
721
+ label: "Graft find code",
722
+ description:
723
+ "Query the repo context graph in plain words. Returns ranked nodes with exact file:line spans and the relevant source inlined — usually the full answer, no file reads needed. Use for understanding or locating code; for exhaustive 'every occurrence' tasks use graft_find_all instead.",
724
+ promptSnippet: "graft_find_code: conceptual/locational code search over graft/ (ranked, top-N)",
725
+ promptGuidelines: [
726
+ "For 'how does X work / where is Y' questions, call graft_find_code first — one call usually answers.",
727
+ "The top node IS the answer for understanding/editing: cite its covers: file:line spans and edit straight from --source output.",
728
+ "Never pipe graft_find_code output through head/tail/sed — it is already capped and carries the savings line.",
729
+ ],
730
+ parameters: Type.Object({
731
+ query: Type.String({ description: "what you want to understand, in plain words" }),
732
+ limit: Type.Optional(Type.Number({ description: "max results (default 8)" })),
733
+ full: Type.Optional(Type.Boolean({ description: "inline whole definitions instead of ≤8-line crux excerpts" })),
734
+ in: Type.Optional(Type.String({ description: "narrow to nodes under this path prefix, e.g. server/src" })),
735
+ }),
736
+ async execute(_id, params, _signal, _onUpdate, ctx) {
737
+ const args = ["ask", params.query, "--source"];
738
+ if (params.full) args.push("--full");
739
+ if (typeof params.limit === "number" && Number.isFinite(params.limit)) {
740
+ args.push("-n", String(Math.max(1, Math.floor(params.limit))));
741
+ }
742
+ if (params.in) args.push("--in", params.in);
743
+ try {
744
+ const stdout = await runGraftAsync(withContextDirArg(ctx.cwd, args), ctx.cwd);
745
+ return toolText(stdout.trim() || "(no hits — loosen the question or try graft_find_all)");
746
+ } catch (err) {
747
+ throw new Error(graftErrorText(err));
748
+ }
749
+ },
750
+ });
751
+
752
+ pi.registerTool({
753
+ name: "graft_find_all",
754
+ label: "Graft find all",
755
+ description:
756
+ "Exhaustive regex/literal search over graft's indexed files, hits grouped by enclosing symbol and ranked by coupling. Use when you need EVERY occurrence (all call sites, all uses of a constant). For conceptual questions use graft_find_code instead.",
757
+ promptSnippet: "graft_find_all: exhaustive pattern search (complete, not top-N)",
758
+ promptGuidelines: [
759
+ "Search a short symbol name or literal with graft_find_all, not a full signature — over-specific regex returns nothing.",
760
+ "If a graft_find_all search misses, loosen it (drop receiver/signature, keep the bare name) and retry — do not fall back to raw grep except for files graft doesn't index.",
761
+ ],
762
+ parameters: Type.Object({
763
+ pattern: Type.String({ description: "regex pattern (or literal with fixed: true)" }),
764
+ in: Type.Optional(Type.String({ description: "narrow to files at or under this path prefix" })),
765
+ ignore_case: Type.Optional(Type.Boolean({ description: "case-insensitive match" })),
766
+ fixed: Type.Optional(Type.Boolean({ description: "treat pattern as a literal string, not a regex" })),
767
+ }),
768
+ async execute(_id, params, _signal, _onUpdate, ctx) {
769
+ const args = ["grep", params.pattern];
770
+ if (params.in) args.push("--in", params.in);
771
+ if (params.ignore_case) args.push("-i");
772
+ if (params.fixed) args.push("--fixed");
773
+ try {
774
+ const stdout = await runGraftAsync(withContextDirArg(ctx.cwd, args), ctx.cwd);
775
+ return toolText(stdout.trim() || "(no hits)");
776
+ } catch (err) {
777
+ throw new Error(graftErrorText(err));
778
+ }
779
+ },
780
+ });
781
+
782
+ pi.registerTool({
783
+ name: "graft_trace_calls",
784
+ label: "Graft trace calls",
785
+ description:
786
+ "Structural call/reference edges for a symbol (not text search). Default = who calls it (in); direction out = what it calls; depth N / all = transitive blast radius. Run before renaming, deleting, changing a signature, or any multi-file refactor.",
787
+ promptSnippet: "graft_trace_calls: exact caller/callee edges + blast radius",
788
+ promptGuidelines: [
789
+ "Before a rename/delete/signature change, call graft_trace_calls with depth 2.",
790
+ "Before a refactor or multi-file change, call graft_trace_calls with depth all — map every connected file, don't stop at the first.",
791
+ ],
792
+ parameters: Type.Object({
793
+ symbol: Type.String({ description: "bare name, Class.method, pkg.Fn, or a file path" }),
794
+ direction: Type.Optional(Type.String({ description: '"in" (callers, default) or "out" (callees)' })),
795
+ depth: Type.Optional(Type.Union([Type.Number(), Type.String()], { description: '1 = direct edges; N = N hops; "all" = full closure' })),
796
+ in: Type.Optional(Type.String({ description: "narrow matches to this path prefix" })),
797
+ }),
798
+ async execute(_id, params, _signal, _onUpdate, ctx) {
799
+ const args = ["callers", params.symbol];
800
+ args.push("--direction", params.direction === "out" ? "out" : "in");
801
+ if (params.depth !== undefined) args.push("--depth", String(params.depth));
802
+ if (params.in) args.push("--in", params.in);
803
+ try {
804
+ const stdout = await runGraftAsync(withContextDirArg(ctx.cwd, args), ctx.cwd);
805
+ return toolText(stdout.trim() || "(no edges — check spelling or run graft build)");
806
+ } catch (err) {
807
+ throw new Error(graftErrorText(err));
808
+ }
809
+ },
810
+ });
811
+
812
+ pi.registerTool({
813
+ name: "graft_file_api",
814
+ label: "Graft file API",
815
+ description:
816
+ "Signatures-only view of one file — every definition's signature + line span, ~10x cheaper than reading the file. Use for 'what's in this file / what can I call here' before editing.",
817
+ promptSnippet: "graft_file_api: one file's API surface, signatures only",
818
+ promptGuidelines: ["One graft_file_api call is the whole answer for a file; don't call graft_file_api twice on the same file."],
819
+ parameters: Type.Object({
820
+ file: Type.String({ description: "repo-relative path (or unique basename) of the file" }),
821
+ }),
822
+ async execute(_id, params, _signal, _onUpdate, ctx) {
823
+ try {
824
+ const stdout = await runGraftAsync(withContextDirArg(ctx.cwd, ["skeleton", params.file]), ctx.cwd);
825
+ return toolText(stdout.trim() || "(empty — file may not be indexed; try graft build)");
826
+ } catch (err) {
827
+ throw new Error(graftErrorText(err));
828
+ }
829
+ },
830
+ });
831
+
832
+ pi.registerTool({
833
+ name: "graft_repo_map",
834
+ label: "Graft repo map",
835
+ description:
836
+ "Token-budgeted repo orientation — directory clusters, per-directory hubs, global hotspots from the wiring graph. Use when landing in a repo cold or asked for 'the architecture'. map alone is the answer: read the hub cards it names.",
837
+ promptSnippet: "graft_repo_map: cold-start orientation (clusters, hubs, hotspots)",
838
+ promptGuidelines: [
839
+ "After graft_repo_map, read the named hub cards — do not then skeleton/ask your way through every subsystem it lists.",
840
+ ],
841
+ parameters: Type.Object({
842
+ max_dirs: Type.Optional(Type.Number({ description: "max directory entries shown (default 16)" })),
843
+ }),
844
+ async execute(_id, params, _signal, _onUpdate, ctx) {
845
+ const args = ["map"];
846
+ if (typeof params.max_dirs === "number" && Number.isFinite(params.max_dirs)) {
847
+ args.push("--max-dirs", String(Math.max(1, Math.floor(params.max_dirs))));
848
+ }
849
+ try {
850
+ const stdout = await runGraftAsync(withContextDirArg(ctx.cwd, args), ctx.cwd);
851
+ return toolText(stdout.trim() || "(empty map — run graft build)");
852
+ } catch (err) {
853
+ throw new Error(graftErrorText(err));
854
+ }
855
+ },
856
+ });
857
+
858
+ pi.registerTool({
859
+ name: "graft_check_freshness",
860
+ label: "Graft freshness",
861
+ description:
862
+ "Report whether the local graft/ graph has drifted from the code. Use to confirm the graph is trustworthy before a big task, or in CI. Does not rebuild — run graft build to refresh.",
863
+ promptSnippet: "graft_check_freshness: drift report for graft/",
864
+ parameters: Type.Object({}),
865
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
866
+ try {
867
+ const stdout = await runGraftAsync(withContextDirArg(ctx.cwd, ["check"]), ctx.cwd);
868
+ await refreshStatus(ctx, ctx.cwd);
869
+ return toolText(stdout.trim() || "graft check: in sync");
870
+ } catch (err) {
871
+ // `graft check` exits 1 on drift — that output IS the answer.
872
+ const text = graftErrorText(err);
873
+ await refreshStatus(ctx, ctx.cwd);
874
+ return toolText(text);
875
+ }
876
+ },
877
+ });
878
+
879
+ // ── commands ─────────────────────────────────────────────────────
880
+
881
+ pi.registerCommand("graft-build", {
882
+ description: "Build/refresh the graft/ context graph (deterministic, no key)",
883
+ handler: async (args, ctx) => {
884
+ const extra = args.trim() ? args.trim().split(/\s+/) : [];
885
+ ctx.ui.notify("Running graft build…", "info");
886
+ try {
887
+ const stdout = await runGraftAsync(["build", ...extra], ctx.cwd);
888
+ ctx.ui.notify(stdout.trim().split("\n").slice(-3).join("\n") || "graft build done", "info");
889
+ } catch (err) {
890
+ ctx.ui.notify(graftErrorText(err), "error");
891
+ }
892
+ await refreshStatus(ctx, ctx.cwd);
893
+ },
894
+ });
895
+
896
+ pi.registerCommand("graft-check", {
897
+ description: "Check whether graft/ has drifted from the code",
898
+ handler: async (_args, ctx) => {
899
+ try {
900
+ const stdout = await runGraftAsync(["check"], ctx.cwd);
901
+ ctx.ui.notify(stdout.trim() || "graft check: in sync", "info");
902
+ } catch (err) {
903
+ ctx.ui.notify(graftErrorText(err), "warning");
904
+ }
905
+ await refreshStatus(ctx, ctx.cwd);
906
+ },
907
+ });
908
+
909
+ pi.registerCommand("graft-map", {
910
+ description: "Show the token-budgeted repo map (orientation)",
911
+ handler: async (_args, ctx) => {
912
+ try {
913
+ const stdout = await runGraftAsync(["map"], ctx.cwd);
914
+ ctx.ui.notify(stdout.trim().slice(0, 4000) || "(empty map)", "info");
915
+ } catch (err) {
916
+ ctx.ui.notify(graftErrorText(err), "error");
917
+ }
918
+ },
919
+ });
920
+
921
+ pi.registerCommand("graft-status", {
922
+ description: "Show graft graph stats and this session's tokens-saved tally",
923
+ handler: async (_args, ctx) => {
924
+ const stats = resolveStats(ctx.cwd);
925
+ if (!stats) {
926
+ ctx.ui.notify("graft: no graph — run graft build", "warning");
927
+ return;
928
+ }
929
+ let session: SessionState | null = null;
930
+ try { session = readSession(ctx.cwd, sid(ctx)); } catch { /* ignore */ }
931
+ const s = session ?? emptySession();
932
+ ctx.ui.notify(
933
+ `${renderStatusline(stats, session)}\n` +
934
+ `reads: ${s.graftReads ?? 0} graft / ${s.sourceReads ?? 0} source · ` +
935
+ `tally reported on ${s.reportedTurns ?? 0}/${s.graftTurns ?? 0} graft turns`,
936
+ "info",
937
+ );
938
+ },
939
+ });
940
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "pi-graft",
3
+ "version": "0.1.0",
4
+ "description": "Graft context-graph support for the pi coding harness — Claude-parity hooks, CLI-backed tools, skill, statusline and commands",
5
+ "license": "MIT",
6
+ "keywords": ["pi-package", "graft", "context-graph"],
7
+ "type": "module",
8
+ "files": ["extensions/", "skills/", "README.md", "LICENSE"],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/KSonny4/pi-graft.git"
12
+ },
13
+ "homepage": "https://github.com/KSonny4/pi-graft#readme",
14
+ "pi": {
15
+ "extensions": ["./extensions/graft.ts"],
16
+ "skills": ["./skills/graft"]
17
+ },
18
+ "peerDependencies": {
19
+ "@earendil-works/pi-coding-agent": "*"
20
+ }
21
+ }
@@ -0,0 +1,170 @@
1
+ ---
2
+ name: graft
3
+ description: This repo is indexed by graft/. For ANY task here — understanding how something works, finding where code lives, tracing what calls a symbol or what a change breaks, or scoping an edit — get context from graft before grepping or reading source files.
4
+ ---
5
+
6
+ # graft
7
+
8
+ `graft/` holds a graph of this repo: small markdown nodes that each explain one
9
+ part in prose and name the exact `file:line` spans they cover, plus a wiring
10
+ graph of who-calls-what. Querying a node costs a few hundred tokens; rebuilding
11
+ that understanding by reading source costs thousands, and misses the edges.
12
+
13
+ Every command below is `$0`, needs no API key, and returns in under a second.
14
+ There are six of them. **Pick the one that fits the task, run it, act on the
15
+ answer; don't chain tools hoping for more. Most tasks need one call.**
16
+
17
+ In pi you have **two surfaces** with identical guidance — prefer the native
18
+ tools when available, fall back to the CLI otherwise:
19
+
20
+ | Native pi tool | CLI equivalent |
21
+ |---|---|
22
+ | `graft_find_code` | `graft ask "<q>" --source` |
23
+ | `graft_find_all` | `graft grep "<pattern>"` |
24
+ | `graft_trace_calls` | `graft callers <symbol>` |
25
+ | `graft_file_api` | `graft skeleton <file>` |
26
+ | `graft_repo_map` | `graft map` |
27
+ | `graft_check_freshness` | `graft check` |
28
+
29
+ ## The tools
30
+
31
+ ### 1 · Find code: locate + understand (the default)
32
+
33
+ - Tool: `graft_find_code` with `{ query, limit?, full?, in? }`
34
+ - CLI: `graft ask "<question>" --source` (add `--full` only when the crux is
35
+ too small, `--in <path>` to narrow, `-n N` to cap results, default 8)
36
+
37
+ Ranked retrieval over the graph, returning the top hits with exact `file:line`
38
+ plus the ≤8-line **crux** of each definition inlined — the result IS the code
39
+ you need, no follow-up file read.
40
+
41
+ - **Use it when** the question is conceptual or locational: "how does auth
42
+ work", "where is rate-limiting handled", "what assembles the request pipeline".
43
+ - One ask usually answers. A genuinely multi-part question needs one ask per
44
+ distinct sub-aspect, never the same question reworded. Few or weak hits mean
45
+ switch tool (find-all / file-api / trace-calls), don't re-ask.
46
+
47
+ ### 2 · Find all: exhaustive find
48
+
49
+ - Tool: `graft_find_all` with `{ pattern, in?, ignore_case?, fixed? }`
50
+ - CLI: `graft grep "<pattern>"` (add `--fixed` for a literal, `-i` for
51
+ case-insensitive, `--in <path>` to scope)
52
+
53
+ Regex over every indexed file, hits **grouped by enclosing symbol** and ranked
54
+ by coupling.
55
+
56
+ - **Use it when** you need every occurrence: all call sites, all uses of a
57
+ constant, all providers. Ranked find-code is top-N and *will* miss instances;
58
+ find-all won't. One find-all replaces a spray of find-code calls.
59
+ - Search a **short symbol name or literal**, not a full guessed signature: an
60
+ over-specific regex returns nothing even when the code is indexed. If a search
61
+ misses, **loosen it** (drop the receiver and signature, keep the bare name)
62
+ and retry — do NOT switch to raw `grep -rn`, which is slower and unranked.
63
+ - Raw `grep -rn` is only for files graft genuinely doesn't index (docs, configs,
64
+ brand-new files).
65
+
66
+ ### 3 · File API: a file's API at a glance
67
+
68
+ - Tool: `graft_file_api` with `{ file }`
69
+ - CLI: `graft skeleton <file>`
70
+
71
+ Signatures-only view of one file (every function / method / type with its span)
72
+ in ~200 tokens, ~10x cheaper than reading the file.
73
+
74
+ - **Use it when** you need "what's in this file / what can I call here" before
75
+ editing or wiring into it. One skeleton is the whole answer for a file; don't
76
+ re-skeleton the same file, and don't skeleton every file `map` already named.
77
+
78
+ ### 4 · Trace calls: the exact edges
79
+
80
+ - Tool: `graft_trace_calls` with `{ symbol, direction?, depth?, in? }`
81
+ - CLI: `graft callers <symbol>` (`--direction in|out`, `--depth N|all`)
82
+
83
+ Precomputed call/reference edges, not a text search. Symbol can be bare
84
+ (`Foo`), qualified (`Class.method`), or package-qualified (`pkg.Fn`).
85
+
86
+ - `direction: in` (default): **who calls/references** this; run before you
87
+ rename, delete, or change its signature.
88
+ - `direction: out`: **what this symbol itself calls/depends on**.
89
+ - `depth: 2`: the usual "what breaks if I touch this".
90
+ - `depth: "all"`: the **entire connected closure** — reach for this before a
91
+ **refactor, rename, or any multi-file change**: it surfaces the sibling and
92
+ downstream files that a single-file edit would miss.
93
+
94
+ ### 5 · Repo map: orientation for an unfamiliar repo or area
95
+
96
+ - Tool: `graft_repo_map` with `{ max_dirs? }`
97
+ - CLI: `graft map` (`--max-dirs N` widens it)
98
+
99
+ A token-budgeted tour: directory clusters, per-directory hubs, and global
100
+ hotspots, straight from the wiring graph.
101
+
102
+ - **Use it when** you land in a repo cold or are asked for "the architecture".
103
+ `map` alone is the answer: read the hub cards it names; do NOT then skeleton
104
+ or ask your way through every subsystem it lists.
105
+
106
+ ### 6 · Lifecycle: build / check
107
+
108
+ - CLI: `graft build` / `graft check` (also `/graft-build`, `/graft-check` in pi)
109
+ - Tool: `graft_check_freshness` (drift report only — it never rebuilds)
110
+
111
+ Every tool above refreshes the graph itself before answering, so results always
112
+ describe the code as it is right now — including edits you just made and have
113
+ not committed. You do **not** need to run `build` after editing.
114
+
115
+ `build` is for the LLM layer (`--deep` adds a concept map; skip unless asked);
116
+ `check` fails when `graft/` is stale, for CI.
117
+
118
+ ## Scenarios: the shortest path through a coding task
119
+
120
+ | When you're… | Reach for | Calls |
121
+ |---|---|---|
122
+ | Onboarding / "explain this codebase" | repo-map, then read the named hub cards | 1 |
123
+ | Understanding a flow ("how does X work") | find-code | 1 |
124
+ | Finding where a change belongs | find-code ("where is <behavior>") | 1 |
125
+ | Editing a symbol you can already name | find-all (`<symbol>`), edit at the `file:line` (skip find-code — you know where it is) | 1 |
126
+ | Renaming / deleting / changing a signature | trace-calls depth 2 first | 1 |
127
+ | Refactor / multi-file change (before editing) | trace-calls depth all — map every connected file | 1 |
128
+ | "What does this depend on?" | trace-calls direction out | 1 |
129
+ | Finding every occurrence of a pattern | find-all (`<literal>`) | 1 |
130
+ | "What's the API of this file?" | file-api | 1 |
131
+ | Debugging a failure in area X | find-code (`<symptom>`), then trace-calls on the suspect | 1–2 |
132
+ | Judging a diff's risk before merge | trace-calls depth 2 per changed symbol | 1 / symbol |
133
+ | Working inside one repo of a monorepo | add `in: <scope>/` to find-code / find-all / trace-calls | n/a |
134
+
135
+ ## Spend the fewest calls
136
+
137
+ - A node's `covers:` list already gives exact `file:line` for every symbol, so
138
+ cite straight from it. The spans are generated from source and authoritative;
139
+ don't re-open or re-grep files to "double-check".
140
+ - When the task already names the file or symbol to change, go straight there:
141
+ find-all for the exact `file:line`, then edit. Reserve find-code for when you
142
+ don't yet know where the code lives.
143
+ - Trust the answer and act. Reach for a second tool only when the first genuinely
144
+ fell short: weak hits, a truncated span, or a need to be exhaustive.
145
+ - If graft names a path that isn't on disk, its index is ahead of your checkout
146
+ (a branch switch or unpulled move). Don't read the missing file — find-all the
147
+ symbol to find where it lives now, or run `graft build` to refresh.
148
+
149
+ ## Report what graft saved, every turn
150
+
151
+ Each retrieval tool **opens** its output with a `[graft] tokens saved ≈ N` line:
152
+ the estimated tokens that call saved versus reading the files it covers whole.
153
+ Whenever you used any graft tool in a turn, close your reply with a one-line
154
+ tally summing those numbers across every graft call you made, e.g.
155
+ `🌱 graft saved ~12,400 tokens this turn (3 calls)`. A call with no such line
156
+ (tiny files, where the pointers cost as much as the source) saved nothing, so
157
+ skip it.
158
+
159
+ **Never pipe a graft command through `head`, `tail`, or `sed -n`.** Every tool
160
+ is already capped and states what it dropped; clipping it costs you hits you
161
+ asked for, and it silently drops the savings line.
162
+
163
+ ## When graft isn't enough
164
+
165
+ - Span truncated ("+N more lines"): open the file at that exact range.
166
+ - A node lacks a detail: ask a more specific question; only then read source at
167
+ the exact `file:line`, never a whole file to rebuild understanding graft gives.
168
+ - You may also grep / ls / cat inside `graft/` directly (plain markdown;
169
+ `graft/INDEX.md` indexes the nodes), but the tools above are faster and
170
+ exhaustive where it matters, so reach for them first.