@juspay/neurolink 12.0.5 → 12.2.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/CHANGELOG.md +3 -3
- package/dist/agent/agentToolRegistrar.d.ts +30 -0
- package/dist/agent/agentToolRegistrar.js +72 -18
- package/dist/agent/backgroundCommands.d.ts +110 -0
- package/dist/agent/backgroundCommands.js +914 -0
- package/dist/agent/backgroundDelegation.d.ts +87 -0
- package/dist/agent/backgroundDelegation.js +753 -0
- package/dist/agent/gitTools.d.ts +43 -0
- package/dist/agent/gitTools.js +618 -0
- package/dist/agent/taskChecklist.d.ts +58 -0
- package/dist/agent/taskChecklist.js +322 -0
- package/dist/artifacts/artifactBanking.d.ts +57 -0
- package/dist/artifacts/artifactBanking.js +123 -0
- package/dist/artifacts/artifactStore.d.ts +36 -8
- package/dist/artifacts/artifactStore.js +164 -13
- package/dist/browser/neurolink.min.js +442 -414
- package/dist/cli/commands/setup.js +2 -1
- package/dist/constants/enums.d.ts +19 -0
- package/dist/constants/enums.js +20 -0
- package/dist/factories/providerDescriptors.js +16 -1
- package/dist/models/manifestRegistry.js +2 -0
- package/dist/models/manifests/cerebras.d.ts +9 -0
- package/dist/models/manifests/cerebras.js +19 -0
- package/dist/neurolink.d.ts +294 -3
- package/dist/neurolink.js +447 -4
- package/dist/providers/openaiCompatCatalog.d.ts +1 -1
- package/dist/providers/openaiCompatCatalog.js +34 -3
- package/dist/types/artifact.d.ts +54 -0
- package/dist/types/backgroundCommand.d.ts +174 -0
- package/dist/types/backgroundCommand.js +22 -0
- package/dist/types/delegation.d.ts +178 -0
- package/dist/types/delegation.js +18 -0
- package/dist/types/gitTools.d.ts +69 -0
- package/dist/types/gitTools.js +22 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +8 -0
- package/dist/types/pathSandbox.d.ts +23 -0
- package/dist/types/pathSandbox.js +12 -0
- package/dist/types/providers.d.ts +4 -0
- package/dist/types/tasks.d.ts +85 -0
- package/dist/types/tasks.js +14 -0
- package/dist/types/tools.d.ts +11 -0
- package/dist/utils/modelChoices.js +17 -1
- package/dist/utils/pathSandbox.d.ts +49 -0
- package/dist/utils/pathSandbox.js +127 -0
- package/dist/utils/providerConfig.d.ts +4 -0
- package/dist/utils/providerConfig.js +17 -0
- package/package.json +5 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only git toolset (N4.4) — six bounded tools on the hardened runner.
|
|
3
|
+
*
|
|
4
|
+
* A reviewing agent asks git the same handful of questions over and over: what
|
|
5
|
+
* changed, against what base, who wrote this line, what files does the tree
|
|
6
|
+
* hold. The tempting shape — "let it run `git ...` through the shell" — is
|
|
7
|
+
* wrong twice. It hands the model a shell, and it hands git a free-form
|
|
8
|
+
* argument string, which is not read-only at all: `--output=<file>` writes,
|
|
9
|
+
* and `diff.external` runs an arbitrary program.
|
|
10
|
+
*
|
|
11
|
+
* So the model supplies VALUES, never flags. Each tool validates a ref, a
|
|
12
|
+
* path, a line range or a count, assembles a fixed argv from them, and runs it
|
|
13
|
+
* through {@link startCommandWithPolicy} with a policy of its own — a
|
|
14
|
+
* one-executable allowlist rooted at the repository. Registering these tools
|
|
15
|
+
* therefore widens nothing: it does not let `run_command_bg` execute git, and
|
|
16
|
+
* it does not require a general command policy to exist.
|
|
17
|
+
*
|
|
18
|
+
* Output follows the same rule as every other big payload here: the COMPLETE
|
|
19
|
+
* stdout is banked (N3) and the tool returns a bounded preview plus the
|
|
20
|
+
* read-back call. A 40 MB diff costs a few hundred tokens and loses nothing.
|
|
21
|
+
*
|
|
22
|
+
* @module agent/gitTools
|
|
23
|
+
*/
|
|
24
|
+
import type { NeuroLink } from "../neurolink.js";
|
|
25
|
+
import type { GitToolResult, GitToolRuntimeSettings, GitToolsetOptions, MCPExecutableTool } from "../types/index.js";
|
|
26
|
+
/** Resolve and remember one host's git-toolset settings. */
|
|
27
|
+
export declare function configureGitTools(host: NeuroLink, options: GitToolsetOptions): GitToolRuntimeSettings;
|
|
28
|
+
/**
|
|
29
|
+
* Run one fixed argv against the repository and return a bounded result whose
|
|
30
|
+
* full output is banked.
|
|
31
|
+
*
|
|
32
|
+
* Exported so host code can drive the same six questions without a model in
|
|
33
|
+
* the loop; `args` is the git argument list (`["log", "--oneline"]`), assembled
|
|
34
|
+
* by a caller that validated every value in it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function runGitCommand(host: NeuroLink, args: string[], sessionId?: string): Promise<GitToolResult>;
|
|
37
|
+
/**
|
|
38
|
+
* The six read-only git tools, bound to `host`. Register them with
|
|
39
|
+
* `host.registerTool()` (see `NeuroLink.registerGitTools()`), never on the
|
|
40
|
+
* tool registry directly: only the "user-defined" category reaches the LLM's
|
|
41
|
+
* tool schema.
|
|
42
|
+
*/
|
|
43
|
+
export declare function createGitTools(host: NeuroLink): Record<string, MCPExecutableTool>;
|
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only git toolset (N4.4) — six bounded tools on the hardened runner.
|
|
3
|
+
*
|
|
4
|
+
* A reviewing agent asks git the same handful of questions over and over: what
|
|
5
|
+
* changed, against what base, who wrote this line, what files does the tree
|
|
6
|
+
* hold. The tempting shape — "let it run `git ...` through the shell" — is
|
|
7
|
+
* wrong twice. It hands the model a shell, and it hands git a free-form
|
|
8
|
+
* argument string, which is not read-only at all: `--output=<file>` writes,
|
|
9
|
+
* and `diff.external` runs an arbitrary program.
|
|
10
|
+
*
|
|
11
|
+
* So the model supplies VALUES, never flags. Each tool validates a ref, a
|
|
12
|
+
* path, a line range or a count, assembles a fixed argv from them, and runs it
|
|
13
|
+
* through {@link startCommandWithPolicy} with a policy of its own — a
|
|
14
|
+
* one-executable allowlist rooted at the repository. Registering these tools
|
|
15
|
+
* therefore widens nothing: it does not let `run_command_bg` execute git, and
|
|
16
|
+
* it does not require a general command policy to exist.
|
|
17
|
+
*
|
|
18
|
+
* Output follows the same rule as every other big payload here: the COMPLETE
|
|
19
|
+
* stdout is banked (N3) and the tool returns a bounded preview plus the
|
|
20
|
+
* read-back call. A 40 MB diff costs a few hundred tokens and loses nothing.
|
|
21
|
+
*
|
|
22
|
+
* @module agent/gitTools
|
|
23
|
+
*/
|
|
24
|
+
import { relative } from "node:path";
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import { logger } from "../utils/logger.js";
|
|
27
|
+
import { resolvePathWithinRoot } from "../utils/pathSandbox.js";
|
|
28
|
+
import { awaitBackgroundCommand, readBackgroundCommandOutput, startCommandWithPolicy, } from "./backgroundCommands.js";
|
|
29
|
+
import { resolveChecklistSessionId } from "./taskChecklist.js";
|
|
30
|
+
const hostSettings = new WeakMap();
|
|
31
|
+
/** Wall-clock budget per git invocation. */
|
|
32
|
+
const DEFAULT_GIT_TIMEOUT_MS = 60_000;
|
|
33
|
+
/** Byte cap per stream — a repository-sized diff is still a legitimate answer. */
|
|
34
|
+
const DEFAULT_GIT_MAX_OUTPUT_BYTES = 33_554_432;
|
|
35
|
+
/** Characters of stdout returned inline. */
|
|
36
|
+
const DEFAULT_GIT_PREVIEW_CHARS = 2_000;
|
|
37
|
+
/** Ceiling on the inline preview, whatever the caller configures. */
|
|
38
|
+
const MAX_GIT_PREVIEW_CHARS = 4_000;
|
|
39
|
+
/** Characters of stderr returned inline when git wrote any. */
|
|
40
|
+
const GIT_STDERR_PREVIEW_CHARS = 600;
|
|
41
|
+
/** Commits returned by `git_log` when the caller names no bound. */
|
|
42
|
+
const DEFAULT_LOG_MAX_COUNT = 20;
|
|
43
|
+
/** Hard bound on commits per `git_log` call. */
|
|
44
|
+
const MAX_LOG_MAX_COUNT = 500;
|
|
45
|
+
/** Lines returned by `git_blame` when only a start line is given. */
|
|
46
|
+
const DEFAULT_BLAME_SPAN = 100;
|
|
47
|
+
/**
|
|
48
|
+
* Global arguments prepended to every invocation.
|
|
49
|
+
*
|
|
50
|
+
* `--no-pager` and the empty `diff.external` are the two that matter: a user
|
|
51
|
+
* gitconfig can point either at a program, and a "read-only" tool that runs
|
|
52
|
+
* whatever `diff.external` names is not read-only. Per-driver programs
|
|
53
|
+
* (`diff.<driver>.command`, `diff.<driver>.textconv`) have unbounded names, so
|
|
54
|
+
* they cannot be neutralised here — every diff-producing subcommand passes
|
|
55
|
+
* `--no-ext-diff --no-textconv` instead.
|
|
56
|
+
*/
|
|
57
|
+
const GIT_GLOBAL_ARGS = [
|
|
58
|
+
"--no-pager",
|
|
59
|
+
"-c",
|
|
60
|
+
"color.ui=false",
|
|
61
|
+
"-c",
|
|
62
|
+
"diff.external=",
|
|
63
|
+
"-c",
|
|
64
|
+
"core.fsmonitor=false",
|
|
65
|
+
];
|
|
66
|
+
/**
|
|
67
|
+
* Ref characters git actually uses — including `..`/`...` ranges, `^`/`~`
|
|
68
|
+
* ancestry, `@{upstream}` and `rev:path`. A leading `-` is refused separately,
|
|
69
|
+
* so a value can never be read as a flag.
|
|
70
|
+
*/
|
|
71
|
+
const GIT_REF_PATTERN = /^[A-Za-z0-9._/^~@{}:+-]{1,200}$/;
|
|
72
|
+
/** Date expressions for `--since`, e.g. "2 weeks ago" or "2026-01-31". */
|
|
73
|
+
const GIT_DATE_PATTERN = /^[A-Za-z0-9 :,.+-]{1,64}$/;
|
|
74
|
+
// ── Settings ───────────────────────────────────────────────────────────────
|
|
75
|
+
/** Resolve and remember one host's git-toolset settings. */
|
|
76
|
+
export function configureGitTools(host, options) {
|
|
77
|
+
const settings = {
|
|
78
|
+
repoRoot: options.repoRoot,
|
|
79
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
|
|
80
|
+
maxOutputBytes: options.maxOutputBytes ?? DEFAULT_GIT_MAX_OUTPUT_BYTES,
|
|
81
|
+
previewChars: Math.min(Math.max(1, options.previewChars ?? DEFAULT_GIT_PREVIEW_CHARS), MAX_GIT_PREVIEW_CHARS),
|
|
82
|
+
gitExecutable: options.gitExecutable?.trim() || "git",
|
|
83
|
+
};
|
|
84
|
+
hostSettings.set(host, settings);
|
|
85
|
+
return settings;
|
|
86
|
+
}
|
|
87
|
+
function settingsFor(host) {
|
|
88
|
+
return hostSettings.get(host);
|
|
89
|
+
}
|
|
90
|
+
const NOT_CONFIGURED = "The git toolset is not configured on this instance. The host must call " +
|
|
91
|
+
"registerGitTools({ repoRoot }) before any git tool can run.";
|
|
92
|
+
// ── Small helpers ──────────────────────────────────────────────────────────
|
|
93
|
+
function errorMessage(error) {
|
|
94
|
+
return error instanceof Error ? error.message : String(error);
|
|
95
|
+
}
|
|
96
|
+
/** Matches `agentToolRegistrar`'s convention: the recovery step is IN the text. */
|
|
97
|
+
function refusal(message) {
|
|
98
|
+
return { isError: true, error: message };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A ref the model supplied, or the reason it was refused.
|
|
102
|
+
*
|
|
103
|
+
* The leading-dash check is the load-bearing one: without it `--output=x` in a
|
|
104
|
+
* `ref` field becomes a flag git honours, and the tool stops being read-only.
|
|
105
|
+
*/
|
|
106
|
+
function checkRef(value, field) {
|
|
107
|
+
const trimmed = value.trim();
|
|
108
|
+
if (!trimmed) {
|
|
109
|
+
return `${field} must not be empty. Name a branch, tag or commit.`;
|
|
110
|
+
}
|
|
111
|
+
if (trimmed.startsWith("-")) {
|
|
112
|
+
return (`${field} must not start with "-": these tools take VALUES, not flags, and a ` +
|
|
113
|
+
"value that looks like a flag is refused. Pass a branch, tag or commit.");
|
|
114
|
+
}
|
|
115
|
+
if (!GIT_REF_PATTERN.test(trimmed)) {
|
|
116
|
+
return (`${field} "${trimmed.slice(0, 60)}" is not a valid git revision. Use a branch, ` +
|
|
117
|
+
"tag, commit sha, or a range like main..HEAD.");
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Turn a model-supplied path into one git can be given: repository-relative,
|
|
123
|
+
* proven to resolve inside the repository root.
|
|
124
|
+
*/
|
|
125
|
+
function checkPath(value, settings) {
|
|
126
|
+
const trimmed = value.trim();
|
|
127
|
+
if (!trimmed) {
|
|
128
|
+
return { error: "path must not be empty." };
|
|
129
|
+
}
|
|
130
|
+
if (trimmed.startsWith("-")) {
|
|
131
|
+
return {
|
|
132
|
+
error: 'path must not start with "-": these tools take VALUES, not flags. Pass a ' +
|
|
133
|
+
"path relative to the repository root.",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (trimmed.includes("\0")) {
|
|
137
|
+
return { error: "path must not contain NUL bytes." };
|
|
138
|
+
}
|
|
139
|
+
const resolved = resolvePathWithinRoot(trimmed, settings.repoRoot);
|
|
140
|
+
if (resolved.error !== undefined) {
|
|
141
|
+
return resolved;
|
|
142
|
+
}
|
|
143
|
+
// Canonicalise the root the same way the target was canonicalised: with a
|
|
144
|
+
// symlinked root component (macOS tmpdir's /var -> /private/var is the
|
|
145
|
+
// everyday case) the raw root and the resolved target never share a prefix,
|
|
146
|
+
// and every in-repo path would be refused as outside the repository.
|
|
147
|
+
const root = resolvePathWithinRoot(".", settings.repoRoot);
|
|
148
|
+
if (root.error !== undefined) {
|
|
149
|
+
return root;
|
|
150
|
+
}
|
|
151
|
+
const rel = relative(root.path, resolved.path);
|
|
152
|
+
if (rel.startsWith("..")) {
|
|
153
|
+
return {
|
|
154
|
+
error: `Access denied: "${trimmed}" is outside the repository ${settings.repoRoot}.`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return { path: rel || "." };
|
|
158
|
+
}
|
|
159
|
+
function clamp(value, low, high) {
|
|
160
|
+
return Math.min(Math.max(Math.trunc(value), low), high);
|
|
161
|
+
}
|
|
162
|
+
function logFormatArgs(format) {
|
|
163
|
+
switch (format) {
|
|
164
|
+
case "full":
|
|
165
|
+
return ["--pretty=fuller"];
|
|
166
|
+
case "stat":
|
|
167
|
+
return ["--stat"];
|
|
168
|
+
case "name-only":
|
|
169
|
+
return ["--name-only"];
|
|
170
|
+
default:
|
|
171
|
+
return ["--oneline"];
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Environment for git. It REPLACES the parent environment, so a repository's
|
|
176
|
+
* commands never see the host's credentials, and the three variables that stop
|
|
177
|
+
* git from blocking on a human (`GIT_TERMINAL_PROMPT`) or paging (`GIT_PAGER`)
|
|
178
|
+
* are set explicitly.
|
|
179
|
+
*/
|
|
180
|
+
function gitEnvironment() {
|
|
181
|
+
const env = {
|
|
182
|
+
LANG: "C",
|
|
183
|
+
LC_ALL: "C",
|
|
184
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
185
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
186
|
+
GIT_PAGER: "cat",
|
|
187
|
+
};
|
|
188
|
+
if (process.env.PATH) {
|
|
189
|
+
env.PATH = process.env.PATH;
|
|
190
|
+
}
|
|
191
|
+
// HOME is kept so a repository covered by `safe.directory` still answers.
|
|
192
|
+
if (process.env.HOME) {
|
|
193
|
+
env.HOME = process.env.HOME;
|
|
194
|
+
}
|
|
195
|
+
// Windows: git may not start without SystemRoot, and temporary-file
|
|
196
|
+
// operations need TEMP/TMP. Forwarded only when present, so POSIX
|
|
197
|
+
// environments are byte-identical to before.
|
|
198
|
+
for (const name of ["SystemRoot", "TEMP", "TMP"]) {
|
|
199
|
+
const value = process.env[name];
|
|
200
|
+
if (value) {
|
|
201
|
+
env[name] = value;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return env;
|
|
205
|
+
}
|
|
206
|
+
function fallbackRef(label, reason) {
|
|
207
|
+
return {
|
|
208
|
+
artifactId: "",
|
|
209
|
+
label,
|
|
210
|
+
kind: "command-output",
|
|
211
|
+
sizeBytes: 0,
|
|
212
|
+
preview: "",
|
|
213
|
+
readBackHint: `No artifact was created for this output: ${reason}`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
// ── Running one git command ────────────────────────────────────────────────
|
|
217
|
+
/**
|
|
218
|
+
* Run one fixed argv against the repository and return a bounded result whose
|
|
219
|
+
* full output is banked.
|
|
220
|
+
*
|
|
221
|
+
* Exported so host code can drive the same six questions without a model in
|
|
222
|
+
* the loop; `args` is the git argument list (`["log", "--oneline"]`), assembled
|
|
223
|
+
* by a caller that validated every value in it.
|
|
224
|
+
*/
|
|
225
|
+
export async function runGitCommand(host, args, sessionId) {
|
|
226
|
+
const settings = settingsFor(host);
|
|
227
|
+
if (!settings) {
|
|
228
|
+
throw new Error(NOT_CONFIGURED);
|
|
229
|
+
}
|
|
230
|
+
const argv = [settings.gitExecutable, ...GIT_GLOBAL_ARGS, ...args];
|
|
231
|
+
const policy = {
|
|
232
|
+
allowedExecutables: [settings.gitExecutable],
|
|
233
|
+
cwdRoot: settings.repoRoot,
|
|
234
|
+
defaultTimeoutMs: settings.timeoutMs,
|
|
235
|
+
maxOutputBytes: settings.maxOutputBytes,
|
|
236
|
+
};
|
|
237
|
+
const label = `git ${args[0] ?? ""}`.trim();
|
|
238
|
+
const handle = await startCommandWithPolicy(host, argv, {
|
|
239
|
+
cwd: settings.repoRoot,
|
|
240
|
+
label,
|
|
241
|
+
env: gitEnvironment(),
|
|
242
|
+
...(sessionId && { sessionId }),
|
|
243
|
+
}, policy);
|
|
244
|
+
const status = await awaitBackgroundCommand(host, handle.taskId);
|
|
245
|
+
const head = await readBackgroundCommandOutput(host, handle.taskId, {
|
|
246
|
+
stream: "stdout",
|
|
247
|
+
offset: 0,
|
|
248
|
+
limit: settings.previewChars,
|
|
249
|
+
});
|
|
250
|
+
const errors = await readBackgroundCommandOutput(host, handle.taskId, {
|
|
251
|
+
stream: "stderr",
|
|
252
|
+
offset: 0,
|
|
253
|
+
limit: GIT_STDERR_PREVIEW_CHARS,
|
|
254
|
+
});
|
|
255
|
+
const output = status.stdout ??
|
|
256
|
+
fallbackRef(`${label} [stdout]`, status.error ?? "the command did not settle normally");
|
|
257
|
+
return {
|
|
258
|
+
command: argv,
|
|
259
|
+
ok: status.exitCode === 0,
|
|
260
|
+
...(status.exitCode !== undefined && { exitCode: status.exitCode }),
|
|
261
|
+
state: status.state,
|
|
262
|
+
preview: head.hasMore ? `${head.content}…` : head.content,
|
|
263
|
+
output,
|
|
264
|
+
readBackHint: output.readBackHint,
|
|
265
|
+
...(errors.content && {
|
|
266
|
+
stderrPreview: errors.hasMore ? `${errors.content}…` : errors.content,
|
|
267
|
+
}),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
// ── Model-facing tools ─────────────────────────────────────────────────────
|
|
271
|
+
const LOG_SCHEMA = z.object({
|
|
272
|
+
ref: z
|
|
273
|
+
.string()
|
|
274
|
+
.optional()
|
|
275
|
+
.describe('Branch, tag, commit or range, e.g. "main..HEAD". Default: HEAD.'),
|
|
276
|
+
path: z
|
|
277
|
+
.string()
|
|
278
|
+
.optional()
|
|
279
|
+
.describe("Limit the history to this path, relative to the repository root."),
|
|
280
|
+
maxCount: z
|
|
281
|
+
.number()
|
|
282
|
+
.optional()
|
|
283
|
+
.describe("How many commits to return. Default 20, maximum 500."),
|
|
284
|
+
since: z
|
|
285
|
+
.string()
|
|
286
|
+
.optional()
|
|
287
|
+
.describe('Only commits after this date, e.g. "2 weeks ago" or "2026-01-31".'),
|
|
288
|
+
format: z
|
|
289
|
+
.enum(["oneline", "full", "stat", "name-only"])
|
|
290
|
+
.optional()
|
|
291
|
+
.describe('How much to show per commit: "oneline" (default), "full", "stat" (change ' +
|
|
292
|
+
'counts per file), or "name-only" (file names).'),
|
|
293
|
+
});
|
|
294
|
+
const SHOW_SCHEMA = z.object({
|
|
295
|
+
ref: z
|
|
296
|
+
.string()
|
|
297
|
+
.describe('What to show: a commit, tag, or "<commit>:<path>" for a file at a revision.'),
|
|
298
|
+
path: z.string().optional().describe("Restrict the shown diff to this path."),
|
|
299
|
+
nameOnly: z
|
|
300
|
+
.boolean()
|
|
301
|
+
.optional()
|
|
302
|
+
.describe("Return only the names of the changed files."),
|
|
303
|
+
});
|
|
304
|
+
const DIFF_SCHEMA = z.object({
|
|
305
|
+
base: z
|
|
306
|
+
.string()
|
|
307
|
+
.optional()
|
|
308
|
+
.describe("Left side of the comparison. Omit both sides to diff the working tree."),
|
|
309
|
+
head: z
|
|
310
|
+
.string()
|
|
311
|
+
.optional()
|
|
312
|
+
.describe("Right side of the comparison. Requires base."),
|
|
313
|
+
path: z.string().optional().describe("Restrict the diff to this path."),
|
|
314
|
+
nameOnly: z
|
|
315
|
+
.boolean()
|
|
316
|
+
.optional()
|
|
317
|
+
.describe("Return only the names of the changed files — cheap, and often enough."),
|
|
318
|
+
stat: z
|
|
319
|
+
.boolean()
|
|
320
|
+
.optional()
|
|
321
|
+
.describe("Return per-file change counts instead of a patch."),
|
|
322
|
+
unified: z
|
|
323
|
+
.number()
|
|
324
|
+
.optional()
|
|
325
|
+
.describe("Lines of context around each hunk. Default 3, maximum 50."),
|
|
326
|
+
});
|
|
327
|
+
const BLAME_SCHEMA = z.object({
|
|
328
|
+
path: z.string().describe("File to blame, relative to the repository root."),
|
|
329
|
+
ref: z
|
|
330
|
+
.string()
|
|
331
|
+
.optional()
|
|
332
|
+
.describe("Revision to blame at. Default: the working tree."),
|
|
333
|
+
lineStart: z
|
|
334
|
+
.number()
|
|
335
|
+
.optional()
|
|
336
|
+
.describe("First line to blame. Blame the whole file when omitted."),
|
|
337
|
+
lineEnd: z
|
|
338
|
+
.number()
|
|
339
|
+
.optional()
|
|
340
|
+
.describe("Last line to blame. Defaults to 100 lines after lineStart."),
|
|
341
|
+
});
|
|
342
|
+
const MERGE_BASE_SCHEMA = z.object({
|
|
343
|
+
base: z.string().describe("First revision, e.g. the target branch."),
|
|
344
|
+
head: z.string().describe("Second revision, e.g. the pull request head."),
|
|
345
|
+
});
|
|
346
|
+
const LS_FILES_SCHEMA = z.object({
|
|
347
|
+
path: z
|
|
348
|
+
.string()
|
|
349
|
+
.optional()
|
|
350
|
+
.describe("Restrict the listing to this directory or path, relative to the root."),
|
|
351
|
+
});
|
|
352
|
+
function buildLogArgs(input, settings) {
|
|
353
|
+
const args = ["log", "--no-color", "--no-ext-diff", "--no-textconv"];
|
|
354
|
+
args.push(`--max-count=${clamp(input.maxCount ?? DEFAULT_LOG_MAX_COUNT, 1, MAX_LOG_MAX_COUNT)}`);
|
|
355
|
+
args.push(...logFormatArgs(input.format ?? "oneline"));
|
|
356
|
+
if (input.since !== undefined) {
|
|
357
|
+
const since = input.since.trim();
|
|
358
|
+
if (!GIT_DATE_PATTERN.test(since)) {
|
|
359
|
+
return refusal(`since "${since.slice(0, 60)}" is not a date expression. Use something like ` +
|
|
360
|
+
'"2 weeks ago" or "2026-01-31".');
|
|
361
|
+
}
|
|
362
|
+
args.push(`--since=${since}`);
|
|
363
|
+
}
|
|
364
|
+
if (input.ref !== undefined) {
|
|
365
|
+
const bad = checkRef(input.ref, "ref");
|
|
366
|
+
if (bad) {
|
|
367
|
+
return refusal(bad);
|
|
368
|
+
}
|
|
369
|
+
args.push(input.ref.trim());
|
|
370
|
+
}
|
|
371
|
+
if (input.path !== undefined) {
|
|
372
|
+
const resolved = checkPath(input.path, settings);
|
|
373
|
+
if (resolved.error !== undefined) {
|
|
374
|
+
return refusal(resolved.error);
|
|
375
|
+
}
|
|
376
|
+
args.push("--", resolved.path);
|
|
377
|
+
}
|
|
378
|
+
return args;
|
|
379
|
+
}
|
|
380
|
+
function buildDiffArgs(input, settings) {
|
|
381
|
+
if (input.head !== undefined && input.base === undefined) {
|
|
382
|
+
return refusal("diff needs a base when you name a head. Pass both sides, e.g. " +
|
|
383
|
+
'{ base: "main", head: "HEAD" }.');
|
|
384
|
+
}
|
|
385
|
+
const args = ["diff", "--no-color", "--no-ext-diff", "--no-textconv"];
|
|
386
|
+
if (input.nameOnly) {
|
|
387
|
+
args.push("--name-only");
|
|
388
|
+
}
|
|
389
|
+
if (input.stat) {
|
|
390
|
+
args.push("--stat");
|
|
391
|
+
}
|
|
392
|
+
if (input.unified !== undefined) {
|
|
393
|
+
args.push(`-U${clamp(input.unified, 0, 50)}`);
|
|
394
|
+
}
|
|
395
|
+
for (const [field, value] of [
|
|
396
|
+
["base", input.base],
|
|
397
|
+
["head", input.head],
|
|
398
|
+
]) {
|
|
399
|
+
if (value !== undefined) {
|
|
400
|
+
const bad = checkRef(value, field);
|
|
401
|
+
if (bad) {
|
|
402
|
+
return refusal(bad);
|
|
403
|
+
}
|
|
404
|
+
args.push(value.trim());
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (input.path !== undefined) {
|
|
408
|
+
const resolved = checkPath(input.path, settings);
|
|
409
|
+
if (resolved.error !== undefined) {
|
|
410
|
+
return refusal(resolved.error);
|
|
411
|
+
}
|
|
412
|
+
args.push("--", resolved.path);
|
|
413
|
+
}
|
|
414
|
+
return args;
|
|
415
|
+
}
|
|
416
|
+
function buildBlameArgs(input, settings) {
|
|
417
|
+
const resolved = checkPath(input.path, settings);
|
|
418
|
+
if (resolved.error !== undefined) {
|
|
419
|
+
return refusal(resolved.error);
|
|
420
|
+
}
|
|
421
|
+
// No `--no-color` here: `git blame` has no such option (it offers
|
|
422
|
+
// --color-lines / --color-by-age), and passing it is a usage error. The
|
|
423
|
+
// global `color.ui=false` covers this subcommand instead.
|
|
424
|
+
const args = ["blame"];
|
|
425
|
+
if (input.lineStart !== undefined || input.lineEnd !== undefined) {
|
|
426
|
+
// A lone lineEnd still bounds the blame: derive the start a span back
|
|
427
|
+
// rather than silently blaming the whole file.
|
|
428
|
+
const requestedEnd = input.lineEnd !== undefined
|
|
429
|
+
? clamp(input.lineEnd, 1, Number.MAX_SAFE_INTEGER)
|
|
430
|
+
: undefined;
|
|
431
|
+
const start = input.lineStart !== undefined
|
|
432
|
+
? clamp(input.lineStart, 1, Number.MAX_SAFE_INTEGER)
|
|
433
|
+
: Math.max(1, (requestedEnd ?? 1) - DEFAULT_BLAME_SPAN + 1);
|
|
434
|
+
const end = clamp(requestedEnd ?? start + DEFAULT_BLAME_SPAN - 1, start, Number.MAX_SAFE_INTEGER);
|
|
435
|
+
args.push("-L", `${start},${end}`);
|
|
436
|
+
}
|
|
437
|
+
if (input.ref !== undefined) {
|
|
438
|
+
const bad = checkRef(input.ref, "ref");
|
|
439
|
+
if (bad) {
|
|
440
|
+
return refusal(bad);
|
|
441
|
+
}
|
|
442
|
+
args.push(input.ref.trim());
|
|
443
|
+
}
|
|
444
|
+
args.push("--", resolved.path);
|
|
445
|
+
return args;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* The six read-only git tools, bound to `host`. Register them with
|
|
449
|
+
* `host.registerTool()` (see `NeuroLink.registerGitTools()`), never on the
|
|
450
|
+
* tool registry directly: only the "user-defined" category reaches the LLM's
|
|
451
|
+
* tool schema.
|
|
452
|
+
*/
|
|
453
|
+
export function createGitTools(host) {
|
|
454
|
+
const run = async (args, sessionId) => {
|
|
455
|
+
try {
|
|
456
|
+
return await runGitCommand(host, args, sessionId);
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
logger.debug("[GitTools] Command refused or failed", {
|
|
460
|
+
subcommand: args[0],
|
|
461
|
+
error: errorMessage(error),
|
|
462
|
+
});
|
|
463
|
+
return refusal(errorMessage(error));
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
return {
|
|
467
|
+
git_log: {
|
|
468
|
+
name: "git_log",
|
|
469
|
+
description: "Commit history, optionally for one path or one range. Read-only. Returns a " +
|
|
470
|
+
"bounded preview; the complete output is banked and readable with retrieve_context.",
|
|
471
|
+
inputSchema: LOG_SCHEMA,
|
|
472
|
+
execute: async (params, context) => {
|
|
473
|
+
const parsed = LOG_SCHEMA.safeParse(params ?? {});
|
|
474
|
+
if (!parsed.success) {
|
|
475
|
+
return refusal("git_log expects { ref?, path?, maxCount?, since?, format? }. Call it again " +
|
|
476
|
+
"with those fields, or with no arguments for the last 20 commits.");
|
|
477
|
+
}
|
|
478
|
+
const settings = settingsFor(host);
|
|
479
|
+
if (!settings) {
|
|
480
|
+
return refusal(NOT_CONFIGURED);
|
|
481
|
+
}
|
|
482
|
+
const args = buildLogArgs(parsed.data, settings);
|
|
483
|
+
return Array.isArray(args)
|
|
484
|
+
? run(args, resolveChecklistSessionId(host, context))
|
|
485
|
+
: args;
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
git_show: {
|
|
489
|
+
name: "git_show",
|
|
490
|
+
description: "Show one commit (message plus patch), or a file at a revision with " +
|
|
491
|
+
'"<commit>:<path>". Read-only, output banked in full.',
|
|
492
|
+
inputSchema: SHOW_SCHEMA,
|
|
493
|
+
execute: async (params, context) => {
|
|
494
|
+
const parsed = SHOW_SCHEMA.safeParse(params ?? {});
|
|
495
|
+
if (!parsed.success) {
|
|
496
|
+
return refusal("git_show expects { ref, path?, nameOnly? } with ref naming a commit, tag " +
|
|
497
|
+
'or "<commit>:<path>". Call it again with a ref.');
|
|
498
|
+
}
|
|
499
|
+
const settings = settingsFor(host);
|
|
500
|
+
if (!settings) {
|
|
501
|
+
return refusal(NOT_CONFIGURED);
|
|
502
|
+
}
|
|
503
|
+
const bad = checkRef(parsed.data.ref, "ref");
|
|
504
|
+
if (bad) {
|
|
505
|
+
return refusal(bad);
|
|
506
|
+
}
|
|
507
|
+
const args = ["show", "--no-color", "--no-ext-diff", "--no-textconv"];
|
|
508
|
+
if (parsed.data.nameOnly) {
|
|
509
|
+
args.push("--name-only");
|
|
510
|
+
}
|
|
511
|
+
args.push(parsed.data.ref.trim());
|
|
512
|
+
if (parsed.data.path !== undefined) {
|
|
513
|
+
const resolved = checkPath(parsed.data.path, settings);
|
|
514
|
+
if (resolved.error !== undefined) {
|
|
515
|
+
return refusal(resolved.error);
|
|
516
|
+
}
|
|
517
|
+
args.push("--", resolved.path);
|
|
518
|
+
}
|
|
519
|
+
return run(args, resolveChecklistSessionId(host, context));
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
git_diff: {
|
|
523
|
+
name: "git_diff",
|
|
524
|
+
description: "Diff the working tree, or two revisions. Prefer nameOnly or stat first — a " +
|
|
525
|
+
"full patch of a large change is enormous, and it is banked either way.",
|
|
526
|
+
inputSchema: DIFF_SCHEMA,
|
|
527
|
+
execute: async (params, context) => {
|
|
528
|
+
const parsed = DIFF_SCHEMA.safeParse(params ?? {});
|
|
529
|
+
if (!parsed.success) {
|
|
530
|
+
return refusal("git_diff expects { base?, head?, path?, nameOnly?, stat?, unified? }. Call " +
|
|
531
|
+
"it again with no arguments to diff the working tree.");
|
|
532
|
+
}
|
|
533
|
+
const settings = settingsFor(host);
|
|
534
|
+
if (!settings) {
|
|
535
|
+
return refusal(NOT_CONFIGURED);
|
|
536
|
+
}
|
|
537
|
+
const args = buildDiffArgs(parsed.data, settings);
|
|
538
|
+
return Array.isArray(args)
|
|
539
|
+
? run(args, resolveChecklistSessionId(host, context))
|
|
540
|
+
: args;
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
git_blame: {
|
|
544
|
+
name: "git_blame",
|
|
545
|
+
description: "Who last changed each line of a file, optionally for one line range. " +
|
|
546
|
+
"Read-only, output banked in full.",
|
|
547
|
+
inputSchema: BLAME_SCHEMA,
|
|
548
|
+
execute: async (params, context) => {
|
|
549
|
+
const parsed = BLAME_SCHEMA.safeParse(params ?? {});
|
|
550
|
+
if (!parsed.success) {
|
|
551
|
+
return refusal("git_blame expects { path, ref?, lineStart?, lineEnd? } with path naming a " +
|
|
552
|
+
"file in the repository. Call it again with a path.");
|
|
553
|
+
}
|
|
554
|
+
const settings = settingsFor(host);
|
|
555
|
+
if (!settings) {
|
|
556
|
+
return refusal(NOT_CONFIGURED);
|
|
557
|
+
}
|
|
558
|
+
const args = buildBlameArgs(parsed.data, settings);
|
|
559
|
+
return Array.isArray(args)
|
|
560
|
+
? run(args, resolveChecklistSessionId(host, context))
|
|
561
|
+
: args;
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
git_merge_base: {
|
|
565
|
+
name: "git_merge_base",
|
|
566
|
+
description: "The commit two revisions diverged from — the honest base for reviewing a " +
|
|
567
|
+
"branch, rather than diffing against a moving target.",
|
|
568
|
+
inputSchema: MERGE_BASE_SCHEMA,
|
|
569
|
+
execute: async (params, context) => {
|
|
570
|
+
const parsed = MERGE_BASE_SCHEMA.safeParse(params ?? {});
|
|
571
|
+
if (!parsed.success) {
|
|
572
|
+
return refusal("git_merge_base expects { base, head }, both naming a revision. Call it " +
|
|
573
|
+
"again with both.");
|
|
574
|
+
}
|
|
575
|
+
const settings = settingsFor(host);
|
|
576
|
+
if (!settings) {
|
|
577
|
+
return refusal(NOT_CONFIGURED);
|
|
578
|
+
}
|
|
579
|
+
for (const [field, value] of [
|
|
580
|
+
["base", parsed.data.base],
|
|
581
|
+
["head", parsed.data.head],
|
|
582
|
+
]) {
|
|
583
|
+
const bad = checkRef(value, field);
|
|
584
|
+
if (bad) {
|
|
585
|
+
return refusal(bad);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return run(["merge-base", parsed.data.base.trim(), parsed.data.head.trim()], resolveChecklistSessionId(host, context));
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
git_ls_files: {
|
|
592
|
+
name: "git_ls_files",
|
|
593
|
+
description: "List the files git tracks, optionally under one path. Read-only, output " +
|
|
594
|
+
"banked in full.",
|
|
595
|
+
inputSchema: LS_FILES_SCHEMA,
|
|
596
|
+
execute: async (params, context) => {
|
|
597
|
+
const parsed = LS_FILES_SCHEMA.safeParse(params ?? {});
|
|
598
|
+
if (!parsed.success) {
|
|
599
|
+
return refusal("git_ls_files expects { path? }. Call it again with no arguments to list " +
|
|
600
|
+
"everything git tracks.");
|
|
601
|
+
}
|
|
602
|
+
const settings = settingsFor(host);
|
|
603
|
+
if (!settings) {
|
|
604
|
+
return refusal(NOT_CONFIGURED);
|
|
605
|
+
}
|
|
606
|
+
const args = ["ls-files"];
|
|
607
|
+
if (parsed.data.path !== undefined) {
|
|
608
|
+
const resolved = checkPath(parsed.data.path, settings);
|
|
609
|
+
if (resolved.error !== undefined) {
|
|
610
|
+
return refusal(resolved.error);
|
|
611
|
+
}
|
|
612
|
+
args.push("--", resolved.path);
|
|
613
|
+
}
|
|
614
|
+
return run(args, resolveChecklistSessionId(host, context));
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
};
|
|
618
|
+
}
|