agent-dir 0.5.0 → 1.0.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.
@@ -4,7 +4,7 @@ export interface ServerHandle {
4
4
  }
5
5
  export declare function startServer({ root, port, token, commandConfig, telemetry, }: ServerOptions): Promise<ServerHandle>;
6
6
  export declare function validateMcpHeaders(request: Request, message: Record<string, unknown> | null): Response | undefined;
7
- export declare function getMcpLog(body: Buffer): {
7
+ export declare function getMcpLog(body: Buffer, responseBody?: unknown): {
8
8
  name: string;
9
9
  detail: string;
10
10
  } | undefined;
@@ -3,7 +3,6 @@ import { authenticate, unauthorized } from "./auth.js";
3
3
  import { logRequest } from "./logging.js";
4
4
  import { createMcpHandler } from "./mcp.js";
5
5
  import { TelemetryRecorder } from "./telemetry/recorder.js";
6
- import { deleteFile, listFiles, readFile, writeFile } from "./tools/files.js";
7
6
  const MAX_BODY_BYTES = 10 * 1024 * 1024;
8
7
  const REQUEST_BODY_TIMEOUT_MS = 30_000;
9
8
  const REQUEST_TIMEOUT_MS = 120_000;
@@ -83,9 +82,13 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
83
82
  return;
84
83
  }
85
84
  const response = await mcp(request);
85
+ const mcpResponseBody = await response
86
+ .clone()
87
+ .json()
88
+ .catch(() => undefined);
86
89
  const errorDetail = await getResponseErrorDetail(response);
90
+ const mcpLog = getMcpLog(body, mcpResponseBody);
87
91
  await sendResponse(res, response);
88
- const mcpLog = getMcpLog(body);
89
92
  const mcpErrorContext = response.status >= 400 ? getMcpErrorContext(body, request) : undefined;
90
93
  logRequest({
91
94
  method: req.method,
@@ -99,50 +102,6 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
99
102
  recordHttpTelemetry(httpTelemetry, req.method, url.pathname, response.status, authenticated, startedAt);
100
103
  return;
101
104
  }
102
- if (url.pathname === "/__tree" && req.method === "GET") {
103
- const tree = await listFiles(root);
104
- sendText(res, 200, JSON.stringify(tree, null, 2), "application/json; charset=utf-8");
105
- logRequest({ method: req.method, path: url.pathname, status: 200, detail: "directory" });
106
- recordHttpTelemetry(httpTelemetry, req.method, url.pathname, 200, authenticated, startedAt);
107
- return;
108
- }
109
- const relative = decodeURIComponent(url.pathname.slice(1));
110
- if (!relative) {
111
- sendText(res, 200, "agent-dir\n");
112
- logRequest({
113
- method: req.method ?? "UNKNOWN",
114
- path: url.pathname,
115
- status: 200,
116
- detail: "server",
117
- });
118
- recordHttpTelemetry(httpTelemetry, req.method ?? "UNKNOWN", url.pathname, 200, authenticated, startedAt);
119
- return;
120
- }
121
- if (req.method === "GET") {
122
- sendText(res, 200, await readFile(root, relative), "text/plain; charset=utf-8");
123
- logRequest({ method: req.method, path: url.pathname, status: 200, detail: "REST GET" });
124
- recordHttpTelemetry(httpTelemetry, req.method, url.pathname, 200, authenticated, startedAt);
125
- return;
126
- }
127
- if (req.method === "PUT") {
128
- await writeFile(root, relative, body.toString("utf8"));
129
- sendText(res, 200, "File updated successfully\n");
130
- logRequest({ method: req.method, path: url.pathname, status: 200, detail: "REST PUT" });
131
- recordHttpTelemetry(httpTelemetry, req.method, url.pathname, 200, authenticated, startedAt);
132
- return;
133
- }
134
- if (req.method === "DELETE") {
135
- await deleteFile(root, relative);
136
- sendText(res, 200, "File deleted successfully\n");
137
- logRequest({
138
- method: req.method,
139
- path: url.pathname,
140
- status: 200,
141
- detail: "REST DELETE",
142
- });
143
- recordHttpTelemetry(httpTelemetry, req.method, url.pathname, 200, authenticated, startedAt);
144
- return;
145
- }
146
105
  sendText(res, 404, "Not found\n");
147
106
  logRequest({
148
107
  method: req.method ?? "UNKNOWN",
@@ -324,7 +283,7 @@ async function getResponseErrorDetail(response) {
324
283
  }
325
284
  return undefined;
326
285
  }
327
- export function getMcpLog(body) {
286
+ export function getMcpLog(body, responseBody) {
328
287
  try {
329
288
  const message = JSON.parse(body.toString("utf8"));
330
289
  if (typeof message.method !== "string")
@@ -337,20 +296,7 @@ export function getMcpLog(body) {
337
296
  const name = message.params.name;
338
297
  const args = isRecordValue(message.params.arguments) ? message.params.arguments : {};
339
298
  const detail = (() => {
340
- const paths = Array.isArray(args.paths)
341
- ? args.paths.filter((value) => typeof value === "string")
342
- : [];
343
- const files = Array.isArray(args.files)
344
- ? args.files
345
- .filter((value) => typeof value === "object" && value !== null)
346
- .map((value) => value.path)
347
- .filter((value) => typeof value === "string")
348
- : [];
349
299
  switch (name) {
350
- case "list_files":
351
- return "all project files";
352
- case "list_dirs":
353
- return paths.join(", ");
354
300
  case "read_range": {
355
301
  const path = typeof args.path === "string" ? args.path : "";
356
302
  const startLine = typeof args.startLine === "number" ? args.startLine : undefined;
@@ -359,21 +305,12 @@ export function getMcpLog(body) {
359
305
  ? `${path}:${startLine}-${endLine}`
360
306
  : path;
361
307
  }
362
- case "read_files":
363
- case "delete_files":
364
- case "git_stage":
365
- case "git_unstage":
366
- return paths.join(", ");
367
- case "write_files":
368
- case "patch_files":
369
- return files.join(", ");
370
308
  case "run_npm_batch": {
371
309
  const scripts = Array.isArray(args.scripts)
372
310
  ? args.scripts.filter((value) => typeof value === "string")
373
311
  : [];
374
312
  return scripts.map((script) => `npm run ${script}`).join(", ");
375
313
  }
376
- case "search_files":
377
314
  case "search_code": {
378
315
  const query = typeof args.query === "string" ? args.query : "";
379
316
  const maxResults = typeof args.maxResults === "number" ? args.maxResults : undefined;
@@ -397,15 +334,6 @@ export function getMcpLog(body) {
397
334
  : `symbol: ${symbol}`
398
335
  : "";
399
336
  }
400
- case "find_definition": {
401
- const symbol = typeof args.symbol === "string" ? args.symbol : "";
402
- const maxResults = typeof args.maxResults === "number" ? args.maxResults : undefined;
403
- return symbol
404
- ? maxResults !== undefined
405
- ? `definition: ${symbol} (max ${maxResults})`
406
- : `definition: ${symbol}`
407
- : "";
408
- }
409
337
  case "find_references": {
410
338
  const symbol = typeof args.symbol === "string" ? args.symbol : "";
411
339
  const maxResults = typeof args.maxResults === "number" ? args.maxResults : undefined;
@@ -415,36 +343,36 @@ export function getMcpLog(body) {
415
343
  : `references: ${symbol}`
416
344
  : "";
417
345
  }
418
- case "git_commit":
419
- return "commit (message omitted)";
420
- case "find_imports": {
421
- const maxResults = typeof args.maxResults === "number" ? args.maxResults : undefined;
422
- return maxResults !== undefined ? `all imports (max ${maxResults})` : "all imports";
423
- }
424
- case "find_exports": {
425
- const maxResults = typeof args.maxResults === "number" ? args.maxResults : undefined;
426
- return maxResults !== undefined ? `all exports (max ${maxResults})` : "all exports";
427
- }
428
- case "diagnostics": {
429
- const maxResults = typeof args.maxResults === "number" ? args.maxResults : undefined;
430
- return maxResults !== undefined ? `max ${maxResults}` : "";
431
- }
432
- case "git_restore":
433
- return paths.join(", ");
434
- case "git_push":
435
- return "push (remote/branch omitted)";
436
- case "git_diff": {
437
- const staged = args.staged === true ? "staged" : "working tree";
438
- const path = typeof args.path === "string" ? args.path : "";
439
- return path ? `${staged}: ${path}` : staged;
440
- }
441
- case "git_log": {
442
- const limit = typeof args.limit === "number" ? args.limit : 20;
443
- const path = typeof args.path === "string" ? args.path : "";
444
- return path ? `last ${limit}: ${path}` : `last ${limit}`;
445
- }
446
346
  case "file_info":
447
347
  return typeof args.path === "string" ? args.path : "";
348
+ case "apply_changes": {
349
+ const response = isRecordValue(responseBody) ? responseBody : undefined;
350
+ const result = response && isRecordValue(response.result) ? response.result : undefined;
351
+ if (result?.isError === true || response?.error !== undefined)
352
+ return "change failed";
353
+ const applied = Array.isArray(result?.structuredContent) ? result.structuredContent : [];
354
+ if (applied.length) {
355
+ const entries = applied
356
+ .filter((item) => isRecordValue(item) && item.changed === true)
357
+ .map((item) => {
358
+ const kind = item.kind === "write" || item.kind === "patch" || item.kind === "delete"
359
+ ? item.kind
360
+ : "change";
361
+ const path = typeof item.path === "string" ? item.path : "(unknown path)";
362
+ return `${kind} ${path}`;
363
+ });
364
+ if (!entries.length)
365
+ return args.dryRun === true ? "preview: no file changes" : "no file changes";
366
+ const shown = entries.slice(0, 5);
367
+ const suffix = entries.length > shown.length ? ` • +${entries.length - shown.length} more` : "";
368
+ return `${args.dryRun === true ? "preview" : "changed"}: ${shown.join(" • ")}${suffix}`;
369
+ }
370
+ const changes = Array.isArray(args.changes) ? args.changes : [];
371
+ const paths = changes
372
+ .filter((item) => isRecordValue(item))
373
+ .map((item) => (typeof item.path === "string" ? item.path : "(unknown path)"));
374
+ return `requested: ${paths.slice(0, 5).join(" • ")}${paths.length > 5 ? ` • +${paths.length - 5} more` : ""}`;
375
+ }
448
376
  case "run_command_batch": {
449
377
  const commands = Array.isArray(args.commands) ? args.commands : [];
450
378
  return commands
@@ -464,33 +392,7 @@ export function getMcpLog(body) {
464
392
  }
465
393
  function getMcpMethodDetail(method, params) {
466
394
  const value = params ?? {};
467
- const capabilities = isRecordValue(value.capabilities)
468
- ? Object.keys(value.capabilities).sort()
469
- : [];
470
- const clientInfo = isRecordValue(value.clientInfo) ? value.clientInfo : undefined;
471
- const clientName = typeof clientInfo?.name === "string" ? clientInfo.name : undefined;
472
- const clientVersion = typeof clientInfo?.version === "string" ? clientInfo.version : undefined;
473
- const protocol = typeof value.protocolVersion === "string"
474
- ? value.protocolVersion
475
- : isRecordValue(value._meta) &&
476
- typeof value._meta["io.modelcontextprotocol/protocolVersion"] === "string"
477
- ? value._meta["io.modelcontextprotocol/protocolVersion"]
478
- : undefined;
479
395
  switch (method) {
480
- case "initialize": {
481
- const client = clientName
482
- ? clientVersion
483
- ? `${clientName} v${clientVersion}`
484
- : clientName
485
- : undefined;
486
- return ([
487
- protocol ? `protocol: ${protocol}` : undefined,
488
- client ? `client: ${client}` : undefined,
489
- capabilities.length ? `capabilities: ${capabilities.join(", ")}` : undefined,
490
- ]
491
- .filter((part) => Boolean(part))
492
- .join(" • ") || "initialization handshake");
493
- }
494
396
  case "notifications/initialized":
495
397
  return "initialization complete";
496
398
  case "tools/list":
@@ -535,15 +437,7 @@ function formatCommand(command, args) {
535
437
  return count ? `${command} (args: ${count})` : command;
536
438
  }
537
439
  function recordHttpTelemetry(recorder, method, pathname, status, authenticated, startedAt) {
538
- const route = pathname === "/mcp"
539
- ? "mcp"
540
- : pathname === "/__tree"
541
- ? "tree"
542
- : pathname === "/"
543
- ? "root"
544
- : pathname.startsWith("/") && pathname.length > 1
545
- ? "file"
546
- : "other";
440
+ const route = pathname === "/mcp" ? "mcp" : "other";
547
441
  recorder.record({
548
442
  event: "http_request",
549
443
  method,
@@ -31,6 +31,8 @@ export function sanitizeAnonymousEvent(event) {
31
31
  method: sanitizeMcpMethod(event.method),
32
32
  success: event.success === true,
33
33
  durationMs: boundedNumber(event.durationMs),
34
+ ...optionalNumber("responseBytes", event.responseBytes),
35
+ ...optionalNumber("estimatedResponseTokens", event.estimatedResponseTokens),
34
36
  ...(event.errorCategory === undefined
35
37
  ? {}
36
38
  : { errorCategory: sanitizeErrorCategory(event.errorCategory) }),
@@ -20,6 +20,8 @@ export interface TelemetryMcpRequestEvent {
20
20
  method: string;
21
21
  success: boolean;
22
22
  durationMs: number;
23
+ responseBytes?: number;
24
+ estimatedResponseTokens?: number;
23
25
  errorCategory?: string;
24
26
  }
25
27
  export interface TelemetryFileMetric {
@@ -8,19 +8,7 @@ export interface SymbolMatch extends SearchMatch {
8
8
  symbol: string;
9
9
  kind: string;
10
10
  }
11
- export interface ImportMatch extends SearchMatch {
12
- module: string;
13
- kind: "import" | "require";
14
- }
15
- export interface ExportMatch extends SearchMatch {
16
- symbol: string;
17
- kind: "export";
18
- }
19
- export declare function searchFiles(root: string, query: string, regex?: boolean, maxResults?: number, maxBytes?: number): Promise<SearchMatch[]>;
20
11
  export declare function searchCode(root: string, query: string, regex?: boolean, maxResults?: number, maxBytes?: number): Promise<SearchMatch[]>;
21
12
  export declare function findFiles(root: string, pattern: string, maxResults?: number): Promise<string[]>;
22
13
  export declare function findSymbol(root: string, symbol: string, maxResults?: number, maxBytes?: number): Promise<SymbolMatch[]>;
23
- export declare function findDefinition(root: string, symbol: string, maxResults?: number, maxBytes?: number): Promise<SymbolMatch[]>;
24
14
  export declare function findReferences(root: string, symbol: string, maxResults?: number, maxBytes?: number): Promise<SearchMatch[]>;
25
- export declare function findImports(root: string, maxResults?: number, maxBytes?: number): Promise<ImportMatch[]>;
26
- export declare function findExports(root: string, maxResults?: number, maxBytes?: number): Promise<ExportMatch[]>;
@@ -34,7 +34,8 @@ const CODE_EXTENSIONS = new Set([
34
34
  ".yaml",
35
35
  ".yml",
36
36
  ]);
37
- const DEFAULT_OUTPUT_BYTES = 32_000;
37
+ const DEFAULT_OUTPUT_BYTES = 8_000;
38
+ const MAX_SEARCH_FILE_BYTES = 1_000_000;
38
39
  function truncateUtf8(value, maxBytes) {
39
40
  if (Buffer.byteLength(value, "utf8") <= maxBytes)
40
41
  return value;
@@ -84,7 +85,13 @@ async function readMatches(root, query, options = {}) {
84
85
  break;
85
86
  let content;
86
87
  try {
87
- content = await fs.readFile(await safePath(root, relativePath), "utf8");
88
+ const target = await safePath(root, relativePath);
89
+ const stat = await fs.stat(target);
90
+ if (!stat.isFile() || stat.size > MAX_SEARCH_FILE_BYTES)
91
+ continue;
92
+ content = await fs.readFile(target, "utf8");
93
+ if (content.includes(String.fromCharCode(0)))
94
+ continue;
88
95
  }
89
96
  catch {
90
97
  continue;
@@ -101,11 +108,6 @@ async function readMatches(root, query, options = {}) {
101
108
  }
102
109
  return matches;
103
110
  }
104
- export async function searchFiles(root, query, regex = false, maxResults = 200, maxBytes = DEFAULT_OUTPUT_BYTES) {
105
- if (!query.trim())
106
- throw new Error("Search query cannot be empty.");
107
- return boundMatches(await readMatches(root, query, { regex, maxResults }), maxBytes);
108
- }
109
111
  export async function searchCode(root, query, regex = false, maxResults = 200, maxBytes = DEFAULT_OUTPUT_BYTES) {
110
112
  if (!query.trim())
111
113
  throw new Error("Search query cannot be empty.");
@@ -132,34 +134,12 @@ export async function findSymbol(root, symbol, maxResults = 100, maxBytes = DEFA
132
134
  const matches = (await readMatches(root, pattern.source, { codeOnly: true, regex: true, maxResults })).map((match) => ({ ...match, symbol, kind: symbolKind(match.text) }));
133
135
  return boundMatches(matches, maxBytes);
134
136
  }
135
- export function findDefinition(root, symbol, maxResults = 100, maxBytes = DEFAULT_OUTPUT_BYTES) {
136
- return findSymbol(root, symbol, maxResults, maxBytes);
137
- }
138
137
  export function findReferences(root, symbol, maxResults = 200, maxBytes = DEFAULT_OUTPUT_BYTES) {
139
138
  if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(symbol))
140
139
  throw new Error("Symbol must be a valid identifier.");
141
140
  const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
142
141
  return searchCode(root, `\\b${escaped}\\b`, true, maxResults, maxBytes);
143
142
  }
144
- export async function findImports(root, maxResults = 200, maxBytes = DEFAULT_OUTPUT_BYTES) {
145
- const matches = await readMatches(root, String.raw `(?:^|[;{}])\s*import\s*(?:[^"'\n]*?from\s*)?["']([^"']+)["']|require\(\s*["']([^"']+)["']\s*\)`, { codeOnly: true, regex: true, maxResults });
146
- return boundMatches(matches.map((match) => {
147
- const captured = match.text.match(/(?:from\s*)?["']([^"']+)["']|require\(\s*["']([^"']+)["']\s*\)/);
148
- return {
149
- ...match,
150
- module: captured?.[1] ?? captured?.[2] ?? match.text,
151
- kind: /require\s*\(/.test(match.text) ? "require" : "import",
152
- };
153
- }), maxBytes);
154
- }
155
- export async function findExports(root, maxResults = 200, maxBytes = DEFAULT_OUTPUT_BYTES) {
156
- const matches = await readMatches(root, String.raw `\bexport\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type|enum)\s+([A-Za-z_$][A-Za-z0-9_$]*)`, { codeOnly: true, regex: true, maxResults });
157
- return boundMatches(matches.map((match) => ({
158
- ...match,
159
- symbol: match.text.match(/(?:function|class|const|let|var|interface|type|enum)\s+([A-Za-z_$][A-Za-z0-9_$]*)/)?.[1] ?? "default",
160
- kind: "export",
161
- })), maxBytes);
162
- }
163
143
  function symbolKind(line) {
164
144
  if (/\bclass\b/i.test(line))
165
145
  return "class";
@@ -8,7 +8,9 @@ export async function diagnostics(root, maxResults = 200) {
8
8
  const entries = await listFiles(root);
9
9
  const results = [];
10
10
  for (const entry of entries) {
11
- if (results.length >= maxResults || entry.type !== "file")
11
+ if (results.length >= maxResults)
12
+ break;
13
+ if (entry.type !== "file")
12
14
  continue;
13
15
  const target = path.join(resolvedRoot, entry.path);
14
16
  let stat;
@@ -9,33 +9,14 @@ export interface FilePatch {
9
9
  count?: number;
10
10
  }
11
11
  export declare function listFiles(root: string): Promise<FileEntry[]>;
12
- export declare function listDir(root: string, relativePath?: string): Promise<FileEntry[]>;
13
- export declare function listDirs(root: string, relativePaths: string[]): Promise<Array<{
14
- path: string;
15
- entries: FileEntry[];
16
- }>>;
17
12
  export declare function readFile(root: string, relativePath: string): Promise<string>;
18
- export declare function readRange(root: string, relativePath: string, startLine: number, endLine: number): Promise<{
13
+ export declare function readRange(root: string, relativePath: string, startLine: number, endLine: number, maxBytes?: number): Promise<{
19
14
  path: string;
20
15
  startLine: number;
21
16
  endLine: number;
22
17
  content: string;
18
+ truncated: boolean;
23
19
  }>;
24
- export declare function writeFile(root: string, relativePath: string, content: string): Promise<void>;
25
- export declare function patchFiles(root: string, files: Array<{
26
- path: string;
27
- patches: FilePatch[];
28
- }>): Promise<Array<{
29
- path: string;
30
- applied: number;
31
- content: string;
32
- }>>;
33
- export declare function patchFile(root: string, relativePath: string, patches: FilePatch[]): Promise<{
34
- path: string;
35
- applied: number;
36
- content: string;
37
- }>;
38
- export declare function deleteFile(root: string, relativePath: string): Promise<void>;
39
20
  export type FileChange = {
40
21
  kind: "write";
41
22
  path: string;
@@ -1,5 +1,6 @@
1
- import { promises as fs } from "node:fs";
1
+ import { createReadStream, promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
+ import { StringDecoder } from "node:string_decoder";
3
4
  const IGNORED = new Set([".git", "node_modules", ".next", "dist", "build", "coverage"]);
4
5
  export async function safePath(root, relativePath = ".", allowMissing = false) {
5
6
  const resolvedRoot = await fs.realpath(root);
@@ -55,30 +56,10 @@ export async function listFiles(root) {
55
56
  };
56
57
  return walk(resolvedRoot);
57
58
  }
58
- export async function listDir(root, relativePath = ".") {
59
- const resolvedRoot = await fs.realpath(root);
60
- const directory = await safePath(resolvedRoot, relativePath);
61
- const entries = await fs.readdir(directory, { withFileTypes: true });
62
- return entries
63
- .filter((entry) => !IGNORED.has(entry.name))
64
- .sort((a, b) => a.name.localeCompare(b.name))
65
- .map((entry) => ({
66
- path: path.relative(resolvedRoot, path.join(directory, entry.name)),
67
- type: entry.isDirectory() ? "directory" : "file",
68
- }));
69
- }
70
- export async function listDirs(root, relativePaths) {
71
- if (relativePaths.length === 0)
72
- throw new Error("At least one directory is required.");
73
- return Promise.all(relativePaths.map(async (relativePath) => ({
74
- path: relativePath,
75
- entries: await listDir(root, relativePath),
76
- })));
77
- }
78
59
  export function readFile(root, relativePath) {
79
60
  return safePath(root, relativePath).then((target) => fs.readFile(target, "utf8"));
80
61
  }
81
- export async function readRange(root, relativePath, startLine, endLine) {
62
+ export async function readRange(root, relativePath, startLine, endLine, maxBytes = 8_000) {
82
63
  if (!Number.isInteger(startLine) ||
83
64
  !Number.isInteger(endLine) ||
84
65
  startLine < 1 ||
@@ -86,58 +67,92 @@ export async function readRange(root, relativePath, startLine, endLine) {
86
67
  throw new Error("Line range must use positive integers with endLine >= startLine.");
87
68
  if (endLine - startLine > 2000)
88
69
  throw new Error("A read range may contain at most 2000 lines.");
89
- const content = await readFile(root, relativePath);
90
- const lines = content.split(/\r?\n/);
91
- return {
92
- path: relativePath,
93
- startLine,
94
- endLine: Math.min(endLine, lines.length),
95
- content: lines.slice(startLine - 1, endLine).join("\n"),
96
- };
97
- }
98
- export async function writeFile(root, relativePath, content) {
99
- const target = await safePath(root, relativePath, true);
100
- await fs.mkdir(path.dirname(target), { recursive: true });
101
- await fs.writeFile(target, content, "utf8");
102
- }
103
- export async function patchFiles(root, files) {
104
- if (files.length === 0)
105
- throw new Error("At least one file is required.");
106
- return Promise.all(files.map(({ path: relativePath, patches }) => patchFile(root, relativePath, patches)));
107
- }
108
- export async function patchFile(root, relativePath, patches) {
109
- if (patches.length === 0)
110
- throw new Error("At least one patch is required.");
70
+ if (!Number.isInteger(maxBytes) || maxBytes < 1)
71
+ throw new Error("maxBytes must be a positive integer.");
111
72
  const target = await safePath(root, relativePath);
112
- let content = await fs.readFile(target, "utf8");
113
- for (const patch of patches) {
114
- if (!patch.search)
115
- throw new Error("Patch search text cannot be empty.");
116
- const occurrences = content.split(patch.search).length - 1;
117
- if (occurrences === 0)
118
- throw new Error(`Patch text was not found in '${relativePath}'.`);
119
- const count = patch.count ?? 1;
120
- if (!Number.isInteger(count) || count < 1)
121
- throw new Error("Patch count must be a positive integer.");
122
- if (occurrences < count)
123
- throw new Error(`Patch text occurs ${occurrences} time(s), but ${count} replacement(s) were requested.`);
73
+ const decoder = new StringDecoder("utf8");
74
+ const stream = createReadStream(target, { highWaterMark: 64 * 1024 });
75
+ const output = [];
76
+ let line = "";
77
+ let lineNumber = 1;
78
+ let outputBytes = 0;
79
+ let truncated = false;
80
+ const marker = "…[truncated]";
81
+ const finishLine = () => {
82
+ if (lineNumber >= startLine && lineNumber <= endLine) {
83
+ if (truncated && line.endsWith(marker)) {
84
+ output.push(line);
85
+ outputBytes = maxBytes;
86
+ lineNumber++;
87
+ line = "";
88
+ return;
89
+ }
90
+ const value = line.endsWith("\r") ? line.slice(0, -1) : line;
91
+ const separatorBytes = output.length ? 1 : 0;
92
+ const remaining = maxBytes - outputBytes - separatorBytes;
93
+ if (Buffer.byteLength(value, "utf8") > remaining) {
94
+ const allowance = Math.max(0, remaining - Buffer.byteLength(marker, "utf8") - 1);
95
+ let clipped = Buffer.from(value, "utf8").subarray(0, allowance).toString("utf8");
96
+ while (clipped.endsWith("\uFFFD"))
97
+ clipped = clipped.slice(0, -1);
98
+ output.push(`${clipped}\n${marker}`);
99
+ outputBytes = maxBytes;
100
+ truncated = true;
101
+ }
102
+ else {
103
+ output.push(value);
104
+ outputBytes += separatorBytes + Buffer.byteLength(value, "utf8");
105
+ }
106
+ }
107
+ lineNumber++;
108
+ line = "";
109
+ };
110
+ for await (const chunk of stream) {
111
+ const text = decoder.write(chunk);
124
112
  let offset = 0;
125
- for (let i = 0; i < count; i++) {
126
- const index = content.indexOf(patch.search, offset);
127
- content =
128
- content.slice(0, index) + patch.replace + content.slice(index + patch.search.length);
129
- offset = index + patch.replace.length;
113
+ while (offset < text.length) {
114
+ const newline = text.indexOf("\n", offset);
115
+ const end = newline === -1 ? text.length : newline;
116
+ if (lineNumber >= startLine && lineNumber <= endLine && !truncated) {
117
+ const piece = text.slice(offset, end);
118
+ const remaining = maxBytes - outputBytes - Buffer.byteLength(line, "utf8");
119
+ if (remaining <= 0) {
120
+ line = "";
121
+ truncated = true;
122
+ break;
123
+ }
124
+ if (Buffer.byteLength(piece, "utf8") > remaining) {
125
+ const allowance = Math.max(0, remaining - Buffer.byteLength(marker, "utf8") - 1);
126
+ let clipped = Buffer.from(piece, "utf8").subarray(0, allowance).toString("utf8");
127
+ while (clipped.endsWith("\uFFFD"))
128
+ clipped = clipped.slice(0, -1);
129
+ line += `${clipped}\n${marker}`;
130
+ truncated = true;
131
+ }
132
+ else
133
+ line += piece;
134
+ }
135
+ if (newline === -1)
136
+ break;
137
+ finishLine();
138
+ offset = newline + 1;
130
139
  }
140
+ if (lineNumber > endLine || truncated)
141
+ break;
131
142
  }
132
- await fs.writeFile(target, content, "utf8");
133
- return { path: relativePath, applied: patches.length, content };
134
- }
135
- export async function deleteFile(root, relativePath) {
136
- const target = await safePath(root, relativePath);
137
- const stat = await fs.stat(target);
138
- if (!stat.isFile())
139
- throw new Error("delete_files only deletes files.");
140
- await fs.unlink(target);
143
+ stream.destroy();
144
+ const tail = decoder.end();
145
+ if (tail && lineNumber >= startLine && lineNumber <= endLine && !truncated)
146
+ line += tail;
147
+ if (lineNumber >= startLine && lineNumber <= endLine && !(truncated && line.length === 0))
148
+ finishLine();
149
+ return {
150
+ path: relativePath,
151
+ startLine,
152
+ endLine: Math.min(endLine, lineNumber - 1),
153
+ content: output.join("\n"),
154
+ truncated,
155
+ };
141
156
  }
142
157
  export async function applyFileChanges(root, changes, dryRun = false) {
143
158
  if (changes.length === 0)
@@ -4,11 +4,4 @@ export interface GitResult {
4
4
  stdout: string;
5
5
  stderr: string;
6
6
  }
7
- export declare function gitStage(root: string, paths: string[]): Promise<GitResult>;
8
- export declare function gitUnstage(root: string, paths: string[]): Promise<GitResult>;
9
- export declare function gitCommit(root: string, message: string): Promise<GitResult>;
10
- export declare function gitPush(root: string, remote?: string, branch?: string): Promise<GitResult>;
11
7
  export declare function gitStatus(root: string): Promise<GitResult>;
12
- export declare function gitDiff(root: string, staged?: boolean, pathspec?: string): Promise<GitResult>;
13
- export declare function gitRestore(root: string, paths: string[], staged?: boolean): Promise<GitResult>;
14
- export declare function gitLog(root: string, limit?: number, pathspec?: string): Promise<GitResult>;