@flyingrobots/graft 0.3.1
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/CHANGELOG.md +218 -0
- package/LICENSE +190 -0
- package/NOTICE +4 -0
- package/README.md +119 -0
- package/bin/graft.js +11 -0
- package/docs/GUIDE.md +374 -0
- package/package.json +76 -0
- package/src/adapters/canonical-json.ts +56 -0
- package/src/adapters/node-fs.ts +39 -0
- package/src/git/diff.ts +96 -0
- package/src/guards/stream-boundary.ts +110 -0
- package/src/hooks/posttooluse-read.ts +107 -0
- package/src/hooks/pretooluse-read.ts +88 -0
- package/src/hooks/shared.ts +168 -0
- package/src/mcp/cache.ts +94 -0
- package/src/mcp/cached-file.ts +38 -0
- package/src/mcp/context.ts +52 -0
- package/src/mcp/metrics.ts +53 -0
- package/src/mcp/receipt.ts +83 -0
- package/src/mcp/server.ts +166 -0
- package/src/mcp/stdio.ts +6 -0
- package/src/mcp/tools/budget.ts +20 -0
- package/src/mcp/tools/changed-since.ts +68 -0
- package/src/mcp/tools/doctor.ts +20 -0
- package/src/mcp/tools/explain.ts +80 -0
- package/src/mcp/tools/file-outline.ts +57 -0
- package/src/mcp/tools/graft-diff.ts +24 -0
- package/src/mcp/tools/read-range.ts +21 -0
- package/src/mcp/tools/run-capture.ts +67 -0
- package/src/mcp/tools/safe-read.ts +135 -0
- package/src/mcp/tools/state.ts +30 -0
- package/src/mcp/tools/stats.ts +20 -0
- package/src/metrics/logger.ts +69 -0
- package/src/metrics/types.ts +12 -0
- package/src/operations/file-outline.ts +38 -0
- package/src/operations/graft-diff.ts +117 -0
- package/src/operations/read-range.ts +65 -0
- package/src/operations/safe-read.ts +96 -0
- package/src/operations/state.ts +33 -0
- package/src/parser/diff.ts +142 -0
- package/src/parser/lang.ts +12 -0
- package/src/parser/outline.ts +327 -0
- package/src/parser/types.ts +67 -0
- package/src/policy/evaluate.ts +178 -0
- package/src/policy/graftignore.ts +6 -0
- package/src/policy/types.ts +86 -0
- package/src/ports/codec.ts +13 -0
- package/src/ports/filesystem.ts +17 -0
- package/src/session/tracker.ts +114 -0
- package/src/session/types.ts +20 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Session metrics — replaces loose counters in server.ts
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export interface MetricsSnapshot {
|
|
6
|
+
readonly reads: number;
|
|
7
|
+
readonly outlines: number;
|
|
8
|
+
readonly refusals: number;
|
|
9
|
+
readonly cacheHits: number;
|
|
10
|
+
readonly bytesReturned: number;
|
|
11
|
+
readonly bytesAvoided: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class Metrics {
|
|
15
|
+
private totalReads = 0;
|
|
16
|
+
private totalOutlines = 0;
|
|
17
|
+
private totalRefusals = 0;
|
|
18
|
+
private totalCacheHits = 0;
|
|
19
|
+
private totalBytesAvoidedByCache = 0;
|
|
20
|
+
private cumulativeBytesReturned = 0;
|
|
21
|
+
|
|
22
|
+
recordRead(): void {
|
|
23
|
+
this.totalReads++;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
recordOutline(): void {
|
|
27
|
+
this.totalOutlines++;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
recordRefusal(): void {
|
|
31
|
+
this.totalRefusals++;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
recordCacheHit(bytesAvoided: number): void {
|
|
35
|
+
this.totalCacheHits++;
|
|
36
|
+
this.totalBytesAvoidedByCache += bytesAvoided;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
addBytesReturned(n: number): void {
|
|
40
|
+
this.cumulativeBytesReturned += n;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
snapshot(): MetricsSnapshot {
|
|
44
|
+
return {
|
|
45
|
+
reads: this.totalReads,
|
|
46
|
+
outlines: this.totalOutlines,
|
|
47
|
+
refusals: this.totalRefusals,
|
|
48
|
+
cacheHits: this.totalCacheHits,
|
|
49
|
+
bytesReturned: this.cumulativeBytesReturned,
|
|
50
|
+
bytesAvoided: this.totalBytesAvoidedByCache,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Receipt builder — attaches decision metadata to every tool response
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
+
import type { MetricsSnapshot } from "./metrics.js";
|
|
7
|
+
import type { Tripwire } from "../session/types.js";
|
|
8
|
+
import type { JsonCodec } from "../ports/codec.js";
|
|
9
|
+
|
|
10
|
+
export type McpToolResult = CallToolResult;
|
|
11
|
+
|
|
12
|
+
export interface ReceiptDeps {
|
|
13
|
+
readonly sessionId: string;
|
|
14
|
+
readonly seq: number;
|
|
15
|
+
readonly metrics: MetricsSnapshot;
|
|
16
|
+
readonly tripwires: Tripwire[];
|
|
17
|
+
readonly codec: JsonCodec;
|
|
18
|
+
readonly budget?: { total: number; consumed: number; remaining: number; fraction: number } | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build a tool response with an attached receipt.
|
|
23
|
+
* Returns the finalized MCP result and the byte count of the serialized text
|
|
24
|
+
* so the caller can feed it back into cumulative metrics.
|
|
25
|
+
*/
|
|
26
|
+
export function buildReceiptResult(
|
|
27
|
+
tool: string,
|
|
28
|
+
data: Record<string, unknown>,
|
|
29
|
+
deps: ReceiptDeps,
|
|
30
|
+
): { result: McpToolResult; textBytes: number } {
|
|
31
|
+
const receipt: Record<string, unknown> = {
|
|
32
|
+
sessionId: deps.sessionId,
|
|
33
|
+
seq: deps.seq,
|
|
34
|
+
ts: new Date().toISOString(),
|
|
35
|
+
tool,
|
|
36
|
+
projection: (data["projection"] as string | undefined) ?? "none",
|
|
37
|
+
reason: (data["reason"] as string | undefined) ?? "none",
|
|
38
|
+
fileBytes: (data["actual"] as { bytes: number } | undefined)?.bytes ?? null,
|
|
39
|
+
returnedBytes: 0,
|
|
40
|
+
cumulative: {
|
|
41
|
+
reads: deps.metrics.reads,
|
|
42
|
+
outlines: deps.metrics.outlines,
|
|
43
|
+
refusals: deps.metrics.refusals,
|
|
44
|
+
cacheHits: deps.metrics.cacheHits,
|
|
45
|
+
bytesReturned: 0,
|
|
46
|
+
bytesAvoided: deps.metrics.bytesAvoided,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (deps.budget != null) {
|
|
51
|
+
receipt["budget"] = deps.budget;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const fullData: Record<string, unknown> = { ...data, _receipt: receipt };
|
|
55
|
+
if (deps.tripwires.length > 0) {
|
|
56
|
+
fullData["tripwire"] = deps.tripwires;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Stabilize self-referential size fields (use UTF-8 byte length, not char count)
|
|
60
|
+
let prev = 0;
|
|
61
|
+
let text = "";
|
|
62
|
+
for (let i = 0; i < 5; i++) {
|
|
63
|
+
text = deps.codec.encode(fullData);
|
|
64
|
+
const byteLen = Buffer.byteLength(text, "utf8");
|
|
65
|
+
if (byteLen === prev) break;
|
|
66
|
+
prev = byteLen;
|
|
67
|
+
receipt["returnedBytes"] = byteLen;
|
|
68
|
+
const fb = receipt["fileBytes"] as number | null;
|
|
69
|
+
receipt["compressionRatio"] = fb !== null && fb > 0
|
|
70
|
+
? Math.round((byteLen / fb) * 1000) / 1000
|
|
71
|
+
: null;
|
|
72
|
+
(receipt["cumulative"] as Record<string, number>)["bytesReturned"] =
|
|
73
|
+
deps.metrics.bytesReturned + byteLen;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
Object.freeze(receipt["cumulative"]);
|
|
77
|
+
Object.freeze(receipt);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
result: { content: [{ type: "text", text }] },
|
|
81
|
+
textBytes: Buffer.byteLength(text, "utf8"),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { SessionTracker } from "../session/tracker.js";
|
|
6
|
+
import { Metrics } from "./metrics.js";
|
|
7
|
+
import { ObservationCache } from "./cache.js";
|
|
8
|
+
import { buildReceiptResult } from "./receipt.js";
|
|
9
|
+
import type { ToolHandler, ToolContext, ToolDefinition } from "./context.js";
|
|
10
|
+
import { createPathResolver } from "./context.js";
|
|
11
|
+
import type { McpToolResult } from "./receipt.js";
|
|
12
|
+
import { nodeFs } from "../adapters/node-fs.js";
|
|
13
|
+
import { CanonicalJsonCodec } from "../adapters/canonical-json.js";
|
|
14
|
+
import { evaluatePolicy } from "../policy/evaluate.js";
|
|
15
|
+
import { RefusedResult } from "../policy/types.js";
|
|
16
|
+
|
|
17
|
+
// Tool definitions — each file exports a ToolDefinition object
|
|
18
|
+
import { safeReadTool } from "./tools/safe-read.js";
|
|
19
|
+
import { fileOutlineTool } from "./tools/file-outline.js";
|
|
20
|
+
import { readRangeTool } from "./tools/read-range.js";
|
|
21
|
+
import { changedSinceTool } from "./tools/changed-since.js";
|
|
22
|
+
import { graftDiffTool } from "./tools/graft-diff.js";
|
|
23
|
+
import { runCaptureTool } from "./tools/run-capture.js";
|
|
24
|
+
import { stateSaveTool, stateLoadTool } from "./tools/state.js";
|
|
25
|
+
import { doctorTool } from "./tools/doctor.js";
|
|
26
|
+
import { statsTool } from "./tools/stats.js";
|
|
27
|
+
import { explainTool } from "./tools/explain.js";
|
|
28
|
+
import { setBudgetTool } from "./tools/budget.js";
|
|
29
|
+
|
|
30
|
+
export type { McpToolResult, ToolHandler, ToolContext };
|
|
31
|
+
|
|
32
|
+
/** All registered tool definitions. Add new tools here. */
|
|
33
|
+
const TOOL_REGISTRY: readonly ToolDefinition[] = [
|
|
34
|
+
safeReadTool,
|
|
35
|
+
fileOutlineTool,
|
|
36
|
+
readRangeTool,
|
|
37
|
+
changedSinceTool,
|
|
38
|
+
graftDiffTool,
|
|
39
|
+
runCaptureTool,
|
|
40
|
+
stateSaveTool,
|
|
41
|
+
stateLoadTool,
|
|
42
|
+
doctorTool,
|
|
43
|
+
statsTool,
|
|
44
|
+
explainTool,
|
|
45
|
+
setBudgetTool,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export interface GraftServer {
|
|
49
|
+
getRegisteredTools(): string[];
|
|
50
|
+
callTool(name: string, args: Record<string, unknown>): Promise<McpToolResult>;
|
|
51
|
+
injectSessionMessages(count: number): void;
|
|
52
|
+
getMcpServer(): McpServer;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createGraftServer(): GraftServer {
|
|
56
|
+
const mcpServer = new McpServer({ name: "graft", version: "0.0.0" });
|
|
57
|
+
const session = new SessionTracker();
|
|
58
|
+
const sessionId = crypto.randomUUID();
|
|
59
|
+
const projectRoot = process.cwd();
|
|
60
|
+
const graftDir = path.join(projectRoot, ".graft");
|
|
61
|
+
const metrics = new Metrics();
|
|
62
|
+
const cache = new ObservationCache();
|
|
63
|
+
const codec = new CanonicalJsonCodec();
|
|
64
|
+
const handlers = new Map<string, ToolHandler>();
|
|
65
|
+
const schemas = new Map<string, z.ZodObject>();
|
|
66
|
+
let seq = 0;
|
|
67
|
+
|
|
68
|
+
function respond(tool: string, data: Record<string, unknown>): McpToolResult {
|
|
69
|
+
seq++;
|
|
70
|
+
const { result, textBytes } = buildReceiptResult(tool, data, {
|
|
71
|
+
sessionId, seq, codec,
|
|
72
|
+
metrics: metrics.snapshot(),
|
|
73
|
+
tripwires: session.checkTripwires(),
|
|
74
|
+
budget: session.getBudget(),
|
|
75
|
+
});
|
|
76
|
+
metrics.addBytesReturned(textBytes);
|
|
77
|
+
session.recordBytesConsumed(textBytes);
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const ctx: ToolContext = { projectRoot, graftDir, session, cache, metrics, respond, resolvePath: createPathResolver(projectRoot), fs: nodeFs, codec };
|
|
82
|
+
|
|
83
|
+
function wrapWithPolicyCheck(toolName: string, inner: ToolHandler): ToolHandler {
|
|
84
|
+
return (args: Record<string, unknown>) => {
|
|
85
|
+
const rawPath = args["path"] as string | undefined;
|
|
86
|
+
if (rawPath === undefined) return inner(args);
|
|
87
|
+
const filePath = ctx.resolvePath(rawPath);
|
|
88
|
+
let content: string;
|
|
89
|
+
try {
|
|
90
|
+
content = ctx.fs.readFileSync(filePath, "utf-8");
|
|
91
|
+
} catch {
|
|
92
|
+
// File unreadable — let the inner handler deal with the error
|
|
93
|
+
return inner(args);
|
|
94
|
+
}
|
|
95
|
+
const actual = { lines: content.split("\n").length, bytes: Buffer.byteLength(content) };
|
|
96
|
+
const budget = session.getBudget();
|
|
97
|
+
const policy = evaluatePolicy(
|
|
98
|
+
{ path: filePath, lines: actual.lines, bytes: actual.bytes },
|
|
99
|
+
{
|
|
100
|
+
sessionDepth: session.getSessionDepth(),
|
|
101
|
+
budgetRemaining: budget?.remaining,
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
if (policy instanceof RefusedResult) {
|
|
105
|
+
metrics.recordRefusal();
|
|
106
|
+
return respond(toolName, {
|
|
107
|
+
path: filePath,
|
|
108
|
+
projection: "refused",
|
|
109
|
+
reason: policy.reason,
|
|
110
|
+
reasonDetail: policy.reasonDetail,
|
|
111
|
+
next: [...policy.next],
|
|
112
|
+
actual,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return inner(args);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const def of TOOL_REGISTRY) {
|
|
120
|
+
const rawHandler = def.createHandler(ctx);
|
|
121
|
+
const handler = def.policyCheck === true ? wrapWithPolicyCheck(def.name, rawHandler) : rawHandler;
|
|
122
|
+
handlers.set(def.name, handler);
|
|
123
|
+
|
|
124
|
+
if (def.schema !== undefined) {
|
|
125
|
+
const zodSchema = z.object(def.schema).strict();
|
|
126
|
+
schemas.set(def.name, zodSchema);
|
|
127
|
+
mcpServer.registerTool(def.name, { description: def.description, inputSchema: def.schema }, async (args) => {
|
|
128
|
+
session.recordMessage();
|
|
129
|
+
session.recordToolCall(def.name);
|
|
130
|
+
const parsed: Record<string, unknown> = zodSchema.parse(args);
|
|
131
|
+
return handler(parsed);
|
|
132
|
+
});
|
|
133
|
+
} else {
|
|
134
|
+
mcpServer.registerTool(def.name, { description: def.description }, async () => {
|
|
135
|
+
session.recordMessage();
|
|
136
|
+
session.recordToolCall(def.name);
|
|
137
|
+
return handler({});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
getRegisteredTools(): string[] {
|
|
144
|
+
return [...handlers.keys()];
|
|
145
|
+
},
|
|
146
|
+
async callTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
|
|
147
|
+
const handler = handlers.get(name);
|
|
148
|
+
if (handler === undefined) {
|
|
149
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
150
|
+
}
|
|
151
|
+
session.recordMessage();
|
|
152
|
+
session.recordToolCall(name);
|
|
153
|
+
const schema = schemas.get(name);
|
|
154
|
+
const parsed: Record<string, unknown> = schema !== undefined ? schema.parse(args) : args;
|
|
155
|
+
return handler(parsed);
|
|
156
|
+
},
|
|
157
|
+
injectSessionMessages(count: number): void {
|
|
158
|
+
for (let i = 0; i < count; i++) {
|
|
159
|
+
session.recordMessage();
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
getMcpServer(): McpServer {
|
|
163
|
+
return mcpServer;
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
package/src/mcp/stdio.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
|
|
3
|
+
|
|
4
|
+
export const setBudgetTool: ToolDefinition = {
|
|
5
|
+
name: "set_budget",
|
|
6
|
+
description:
|
|
7
|
+
"Declare a context byte budget for this session. Graft tightens " +
|
|
8
|
+
"read thresholds as the budget drains — no single read may consume " +
|
|
9
|
+
"more than 5% of remaining budget. Call once at session start.",
|
|
10
|
+
schema: { bytes: z.number() },
|
|
11
|
+
createHandler(ctx: ToolContext): ToolHandler {
|
|
12
|
+
return (args) => {
|
|
13
|
+
const bytes = args["bytes"] as number;
|
|
14
|
+
ctx.session.setBudget(bytes);
|
|
15
|
+
return ctx.respond("set_budget", {
|
|
16
|
+
budget: ctx.session.getBudget(),
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { evaluatePolicy } from "../../policy/evaluate.js";
|
|
3
|
+
import { RefusedResult } from "../../policy/types.js";
|
|
4
|
+
import { extractOutline } from "../../parser/outline.js";
|
|
5
|
+
import { diffOutlines } from "../../parser/diff.js";
|
|
6
|
+
import { detectLang } from "../../parser/lang.js";
|
|
7
|
+
import { hashContent } from "../cache.js";
|
|
8
|
+
import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
|
|
9
|
+
|
|
10
|
+
export const changedSinceTool: ToolDefinition = {
|
|
11
|
+
name: "changed_since",
|
|
12
|
+
description:
|
|
13
|
+
"Check if a file changed since it was last read. Returns structural " +
|
|
14
|
+
"diff (added/removed/changed symbols) or 'unchanged'. Peek mode by " +
|
|
15
|
+
"default; pass consume: true to update the observation cache.",
|
|
16
|
+
schema: { path: z.string(), consume: z.boolean().optional() },
|
|
17
|
+
createHandler(ctx: ToolContext): ToolHandler {
|
|
18
|
+
return (args) => {
|
|
19
|
+
const filePath = ctx.resolvePath(args["path"] as string);
|
|
20
|
+
const consume = (args["consume"] as boolean | undefined) === true;
|
|
21
|
+
|
|
22
|
+
// Policy check: refuse banned files even via changed_since.
|
|
23
|
+
// Read the file first to get dimensions for policy evaluation.
|
|
24
|
+
let rawContent: string;
|
|
25
|
+
try {
|
|
26
|
+
rawContent = ctx.fs.readFileSync(filePath, "utf-8");
|
|
27
|
+
} catch {
|
|
28
|
+
return ctx.respond("changed_since", { status: "file_not_found" });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const actual = {
|
|
32
|
+
lines: rawContent.split("\n").length,
|
|
33
|
+
bytes: Buffer.byteLength(rawContent),
|
|
34
|
+
};
|
|
35
|
+
const policy = evaluatePolicy(
|
|
36
|
+
{ path: filePath, lines: actual.lines, bytes: actual.bytes },
|
|
37
|
+
{ sessionDepth: ctx.session.getSessionDepth() },
|
|
38
|
+
);
|
|
39
|
+
if (policy instanceof RefusedResult) {
|
|
40
|
+
return ctx.respond("changed_since", { status: "refused", reason: policy.reason });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const cacheResult = ctx.cache.check(filePath, rawContent);
|
|
44
|
+
if (cacheResult.hit) {
|
|
45
|
+
return ctx.respond("changed_since", { status: "unchanged" });
|
|
46
|
+
}
|
|
47
|
+
if (cacheResult.stale === null) {
|
|
48
|
+
return ctx.respond("changed_since", { status: "no_previous_observation" });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Use extractOutline with rawContent directly to avoid snapshot race.
|
|
52
|
+
const newOutlineResult = extractOutline(rawContent, detectLang(filePath) ?? "ts");
|
|
53
|
+
const diff = diffOutlines(cacheResult.stale.outline, newOutlineResult.entries);
|
|
54
|
+
|
|
55
|
+
if (consume) {
|
|
56
|
+
ctx.cache.record(
|
|
57
|
+
filePath,
|
|
58
|
+
hashContent(rawContent),
|
|
59
|
+
newOutlineResult.entries,
|
|
60
|
+
newOutlineResult.jumpTable ?? [],
|
|
61
|
+
actual,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return ctx.respond("changed_since", { diff, consumed: consume });
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { STATIC_THRESHOLDS } from "../../policy/evaluate.js";
|
|
2
|
+
import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
|
|
3
|
+
|
|
4
|
+
export const doctorTool: ToolDefinition = {
|
|
5
|
+
name: "doctor",
|
|
6
|
+
description:
|
|
7
|
+
"Runtime health check. Shows project root, parser status, active " +
|
|
8
|
+
"thresholds, session depth, and message count.",
|
|
9
|
+
createHandler(ctx: ToolContext): ToolHandler {
|
|
10
|
+
return () => {
|
|
11
|
+
return ctx.respond("doctor", {
|
|
12
|
+
projectRoot: ctx.projectRoot,
|
|
13
|
+
parserHealthy: true,
|
|
14
|
+
thresholds: { lines: STATIC_THRESHOLDS.lines, bytes: STATIC_THRESHOLDS.bytes },
|
|
15
|
+
sessionDepth: ctx.session.getSessionDepth(),
|
|
16
|
+
totalMessages: ctx.session.getMessageCount(),
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
|
|
3
|
+
|
|
4
|
+
const EXPLANATIONS: Readonly<Record<string, { meaning: string; action: string }>> = {
|
|
5
|
+
CONTENT: {
|
|
6
|
+
meaning: "File is within size thresholds — full content returned.",
|
|
7
|
+
action: "No action needed. You have the complete file.",
|
|
8
|
+
},
|
|
9
|
+
OUTLINE: {
|
|
10
|
+
meaning: "File exceeds line or byte thresholds. Structural outline returned instead of content.",
|
|
11
|
+
action: "Use read_range with the jump table to read specific sections. Do not re-request the full file.",
|
|
12
|
+
},
|
|
13
|
+
SESSION_CAP: {
|
|
14
|
+
meaning: "Session-depth byte cap triggered. The file might fit static thresholds but exceeds the dynamic session cap.",
|
|
15
|
+
action: "Use read_range for targeted reads. Consider whether you truly need this file at this stage of the session.",
|
|
16
|
+
},
|
|
17
|
+
BINARY: {
|
|
18
|
+
meaning: "Binary file (image, PDF, etc.) cannot be usefully read as text.",
|
|
19
|
+
action: "Use file_outline for metadata. Check for a text alternative.",
|
|
20
|
+
},
|
|
21
|
+
LOCKFILE: {
|
|
22
|
+
meaning: "Machine-generated lockfile is not useful to read directly.",
|
|
23
|
+
action: "Read the package manifest (package.json, Cargo.toml, etc.) for dependency info instead.",
|
|
24
|
+
},
|
|
25
|
+
MINIFIED: {
|
|
26
|
+
meaning: "Minified file is not human-readable.",
|
|
27
|
+
action: "Look for the unminified source file.",
|
|
28
|
+
},
|
|
29
|
+
BUILD_OUTPUT: {
|
|
30
|
+
meaning: "File is in a build output directory (dist/, build/, .next/, etc.).",
|
|
31
|
+
action: "Read the source in src/ instead of the compiled output.",
|
|
32
|
+
},
|
|
33
|
+
SECRET: {
|
|
34
|
+
meaning: "File may contain secrets (keys, credentials, environment variables).",
|
|
35
|
+
action: "Check for a .example or template version of this file.",
|
|
36
|
+
},
|
|
37
|
+
BUDGET_CAP: {
|
|
38
|
+
meaning: "File exceeds the budget-proportional byte cap. No single read may consume more than 5% of remaining budget.",
|
|
39
|
+
action: "Use file_outline or read_range for targeted reads. Consider whether this file is worth the budget cost.",
|
|
40
|
+
},
|
|
41
|
+
GRAFTIGNORE: {
|
|
42
|
+
meaning: "File matches a pattern in .graftignore.",
|
|
43
|
+
action: "Check .graftignore if you believe this file should be readable.",
|
|
44
|
+
},
|
|
45
|
+
REREAD_UNCHANGED: {
|
|
46
|
+
meaning: "File was already read and has not changed. Cached outline returned.",
|
|
47
|
+
action: "Use the cached outline and jump table. The file content has not changed since your last read.",
|
|
48
|
+
},
|
|
49
|
+
CHANGED_SINCE_LAST_READ: {
|
|
50
|
+
meaning: "File changed since last read. Structural diff returned showing what changed.",
|
|
51
|
+
action: "Review the diff to understand what changed. Use read_range for details on changed sections.",
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const explainTool: ToolDefinition = {
|
|
56
|
+
name: "explain",
|
|
57
|
+
description:
|
|
58
|
+
"Explain a graft reason code. Returns human-readable meaning and " +
|
|
59
|
+
"recommended next action for any reason code returned by graft tools.",
|
|
60
|
+
schema: { code: z.string() },
|
|
61
|
+
createHandler(ctx: ToolContext): ToolHandler {
|
|
62
|
+
return (args) => {
|
|
63
|
+
const code = (args["code"] as string).toUpperCase();
|
|
64
|
+
const entry = EXPLANATIONS[code];
|
|
65
|
+
if (entry === undefined) {
|
|
66
|
+
const known = Object.keys(EXPLANATIONS).join(", ");
|
|
67
|
+
return ctx.respond("explain", {
|
|
68
|
+
code,
|
|
69
|
+
error: "Unknown reason code",
|
|
70
|
+
knownCodes: known,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return ctx.respond("explain", {
|
|
74
|
+
code,
|
|
75
|
+
meaning: entry.meaning,
|
|
76
|
+
action: entry.action,
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { fileOutline } from "../../operations/file-outline.js";
|
|
3
|
+
import { hashContent } from "../cache.js";
|
|
4
|
+
import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
|
|
5
|
+
|
|
6
|
+
export const fileOutlineTool: ToolDefinition = {
|
|
7
|
+
name: "file_outline",
|
|
8
|
+
description:
|
|
9
|
+
"Structural skeleton of a file \u2014 function signatures, class shapes, " +
|
|
10
|
+
"exports. Includes a jump table mapping each symbol to its line range " +
|
|
11
|
+
"for targeted read_range follow-ups.",
|
|
12
|
+
schema: { path: z.string() },
|
|
13
|
+
createHandler(ctx: ToolContext): ToolHandler {
|
|
14
|
+
return async (args) => {
|
|
15
|
+
const filePath = ctx.resolvePath(args["path"] as string);
|
|
16
|
+
|
|
17
|
+
// Check cache
|
|
18
|
+
let rawContent: string | null = null;
|
|
19
|
+
try {
|
|
20
|
+
rawContent = ctx.fs.readFileSync(filePath, "utf-8");
|
|
21
|
+
} catch {
|
|
22
|
+
// proceed to fileOutline for error handling
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (rawContent !== null) {
|
|
26
|
+
const cacheResult = ctx.cache.check(filePath, rawContent);
|
|
27
|
+
if (cacheResult.hit) {
|
|
28
|
+
cacheResult.obs.touch();
|
|
29
|
+
ctx.metrics.recordCacheHit(cacheResult.obs.actual.bytes);
|
|
30
|
+
return ctx.respond("file_outline", {
|
|
31
|
+
path: filePath,
|
|
32
|
+
outline: cacheResult.obs.outline,
|
|
33
|
+
jumpTable: cacheResult.obs.jumpTable,
|
|
34
|
+
cacheHit: true,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
// If stale, fall through to fresh parse (no diff for file_outline)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const result = await fileOutline(filePath, { fs: ctx.fs });
|
|
41
|
+
ctx.metrics.recordOutline();
|
|
42
|
+
|
|
43
|
+
// Record observation
|
|
44
|
+
if (rawContent !== null) {
|
|
45
|
+
ctx.cache.record(
|
|
46
|
+
filePath,
|
|
47
|
+
hashContent(rawContent),
|
|
48
|
+
result.outline,
|
|
49
|
+
result.jumpTable,
|
|
50
|
+
{ lines: rawContent.split("\n").length, bytes: Buffer.byteLength(rawContent) },
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return ctx.respond("file_outline", result);
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { graftDiff } from "../../operations/graft-diff.js";
|
|
3
|
+
import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
|
|
4
|
+
|
|
5
|
+
export const graftDiffTool: ToolDefinition = {
|
|
6
|
+
name: "graft_diff",
|
|
7
|
+
description:
|
|
8
|
+
"Structural diff between two git refs. Shows added, removed, and " +
|
|
9
|
+
"changed symbols per file \u2014 not line hunks. Defaults to working " +
|
|
10
|
+
"tree vs HEAD.",
|
|
11
|
+
schema: { base: z.string().optional(), head: z.string().optional(), path: z.string().optional() },
|
|
12
|
+
createHandler(ctx: ToolContext): ToolHandler {
|
|
13
|
+
return (args) => {
|
|
14
|
+
const result = graftDiff({
|
|
15
|
+
cwd: ctx.projectRoot,
|
|
16
|
+
fs: ctx.fs,
|
|
17
|
+
base: args["base"] as string | undefined,
|
|
18
|
+
head: args["head"] as string | undefined,
|
|
19
|
+
path: args["path"] as string | undefined,
|
|
20
|
+
});
|
|
21
|
+
return ctx.respond("graft_diff", result);
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { readRange } from "../../operations/read-range.js";
|
|
3
|
+
import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
|
|
4
|
+
|
|
5
|
+
export const readRangeTool: ToolDefinition = {
|
|
6
|
+
name: "read_range",
|
|
7
|
+
description:
|
|
8
|
+
"Read a bounded range of lines from a file. Maximum 250 lines. " +
|
|
9
|
+
"Use jump table entries from file_outline or safe_read to target " +
|
|
10
|
+
"specific symbols.",
|
|
11
|
+
schema: { path: z.string(), start: z.number(), end: z.number() },
|
|
12
|
+
policyCheck: true,
|
|
13
|
+
createHandler(ctx: ToolContext): ToolHandler {
|
|
14
|
+
return async (args) => {
|
|
15
|
+
const filePath = ctx.resolvePath(args["path"] as string);
|
|
16
|
+
const result = await readRange(filePath, args["start"] as number, args["end"] as number, { fs: ctx.fs });
|
|
17
|
+
ctx.metrics.recordRead();
|
|
18
|
+
return ctx.respond("read_range", result);
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
|
|
5
|
+
|
|
6
|
+
export const runCaptureTool: ToolDefinition = {
|
|
7
|
+
name: "run_capture",
|
|
8
|
+
description:
|
|
9
|
+
"Execute a shell command and return the last N lines of output " +
|
|
10
|
+
"(default 60). Full output saved to .graft/logs/capture.log for " +
|
|
11
|
+
"follow-up read_range calls.",
|
|
12
|
+
schema: { command: z.string(), tail: z.number().optional() },
|
|
13
|
+
createHandler(ctx: ToolContext): ToolHandler {
|
|
14
|
+
return async (args) => {
|
|
15
|
+
const command = args["command"] as string;
|
|
16
|
+
const tail = Math.max(1, Math.floor((args["tail"] as number | undefined) ?? 60));
|
|
17
|
+
// execFileSync is intentional: MCP tool calls are sequential per-session,
|
|
18
|
+
// and synchronous execution simplifies stdout/stderr capture with timeout.
|
|
19
|
+
let output: string;
|
|
20
|
+
try {
|
|
21
|
+
output = execFileSync("sh", ["-c", command], {
|
|
22
|
+
cwd: ctx.projectRoot,
|
|
23
|
+
encoding: "utf-8",
|
|
24
|
+
timeout: 30000,
|
|
25
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
26
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
27
|
+
});
|
|
28
|
+
} catch (err: unknown) {
|
|
29
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
30
|
+
const stdout = (err as { stdout?: string }).stdout ?? "";
|
|
31
|
+
const stderr = (err as { stderr?: string }).stderr ?? "";
|
|
32
|
+
// Return whatever stdout was captured before failure
|
|
33
|
+
const tailed = typeof stdout === "string"
|
|
34
|
+
? stdout.split("\n").slice(-tail).join("\n")
|
|
35
|
+
: "";
|
|
36
|
+
const totalLines = typeof stdout === "string" ? stdout.split("\n").length : 0;
|
|
37
|
+
return ctx.respond("run_capture", {
|
|
38
|
+
error: msg,
|
|
39
|
+
output: tailed,
|
|
40
|
+
totalLines,
|
|
41
|
+
tailedLines: Math.min(tail, totalLines),
|
|
42
|
+
truncated: totalLines > tail,
|
|
43
|
+
stderr: typeof stderr === "string" ? stderr.slice(0, 2000) : "",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const lines = output.split("\n");
|
|
48
|
+
const tailed = lines.slice(-tail).join("\n");
|
|
49
|
+
const logPath = path.join(ctx.graftDir, "logs", "capture.log");
|
|
50
|
+
let logWriteSucceeded = true;
|
|
51
|
+
try {
|
|
52
|
+
await ctx.fs.mkdir(path.dirname(logPath), { recursive: true });
|
|
53
|
+
await ctx.fs.writeFile(logPath, output, "utf-8");
|
|
54
|
+
} catch {
|
|
55
|
+
// Log persistence failure must not mask a successful command
|
|
56
|
+
logWriteSucceeded = false;
|
|
57
|
+
}
|
|
58
|
+
return ctx.respond("run_capture", {
|
|
59
|
+
output: tailed,
|
|
60
|
+
totalLines: lines.length,
|
|
61
|
+
tailedLines: Math.min(tail, lines.length),
|
|
62
|
+
logPath: logWriteSucceeded ? logPath : null,
|
|
63
|
+
truncated: lines.length > tail,
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
};
|