@remcp/runtime 0.2.4 → 0.2.5
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/package.json +1 -1
- package/src/catalog.mjs +38 -2
- package/src/config.mjs +5 -3
- package/src/patch.mjs +95 -0
- package/src/tools/files.mjs +78 -5
package/package.json
CHANGED
package/src/catalog.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export const toolDefinitions = [
|
|
|
37
37
|
properties: {
|
|
38
38
|
path: { type: 'string', description: 'Absolute directory (or single file) to start from.' },
|
|
39
39
|
pattern: { type: 'string', description: 'Glob matched against the relative path and the file name, such as "src/**/*.ts" or "*.md". Default **/* .' },
|
|
40
|
-
max_files: { type: 'number', description: 'Stop after this many files. Default
|
|
40
|
+
max_files: { type: 'number', description: 'Stop after this many files. Default 100, maximum 500.' },
|
|
41
41
|
max_lines_per_file: { type: 'number', description: 'Lines kept per file. Default 2000.' },
|
|
42
42
|
include_ignored: { type: 'boolean', description: 'Also descend into .git and node_modules. Default false.' },
|
|
43
43
|
},
|
|
@@ -85,7 +85,7 @@ export const toolDefinitions = [
|
|
|
85
85
|
properties: {
|
|
86
86
|
path: { type: 'string', description: 'Absolute path of the file to read.' },
|
|
87
87
|
offset_bytes: { type: 'number', description: 'Byte offset to start at. Default 0.' },
|
|
88
|
-
length_bytes: { type: 'number', description: 'Chunk size in bytes. Default and maximum
|
|
88
|
+
length_bytes: { type: 'number', description: 'Chunk size in bytes. Default and maximum 1048576 (1 MiB).' },
|
|
89
89
|
},
|
|
90
90
|
required: ['path'],
|
|
91
91
|
additionalProperties: false,
|
|
@@ -203,6 +203,42 @@ export const toolDefinitions = [
|
|
|
203
203
|
annotations: mutating,
|
|
204
204
|
handler: fileToolHandlers.write_files,
|
|
205
205
|
},
|
|
206
|
+
{
|
|
207
|
+
name: 'apply_patch',
|
|
208
|
+
title: 'Apply patch',
|
|
209
|
+
description: 'Apply a unified diff to one file or to several files at once, matching each hunk with a little fuzz so small offsets and whitespace differences still apply. This is the fastest way to land a multi-line change a model has already worked out. Pass dry_run to see the result as a diff first.',
|
|
210
|
+
inputSchema: {
|
|
211
|
+
type: 'object',
|
|
212
|
+
properties: {
|
|
213
|
+
patch: { type: 'string', description: 'Unified diff, including ---/+++ headers and @@ hunks.' },
|
|
214
|
+
path: { type: 'string', description: 'Apply every hunk to this file, ignoring the patch headers.' },
|
|
215
|
+
dry_run: { type: 'boolean', description: 'Report the diff without writing. Default false.' },
|
|
216
|
+
},
|
|
217
|
+
required: ['patch'],
|
|
218
|
+
additionalProperties: false,
|
|
219
|
+
},
|
|
220
|
+
annotations: mutating,
|
|
221
|
+
handler: fileToolHandlers.apply_patch,
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: 'set_permissions',
|
|
225
|
+
title: 'Set permissions',
|
|
226
|
+
description: 'Change the permission mode of a file or directory, optionally recursively and optionally with a numeric owner. Use this to make a script executable after writing it.',
|
|
227
|
+
inputSchema: {
|
|
228
|
+
type: 'object',
|
|
229
|
+
properties: {
|
|
230
|
+
path: { type: 'string', description: 'Absolute path whose permissions should change.' },
|
|
231
|
+
mode: { type: 'string', description: 'Octal mode such as "755" or "0644".' },
|
|
232
|
+
recursive: { type: 'boolean', description: 'Apply to a directory and everything inside it. Default false.' },
|
|
233
|
+
uid: { type: 'number', description: 'Optional numeric user id to set as owner.' },
|
|
234
|
+
gid: { type: 'number', description: 'Optional numeric group id to set as owner.' },
|
|
235
|
+
},
|
|
236
|
+
required: ['path', 'mode'],
|
|
237
|
+
additionalProperties: false,
|
|
238
|
+
},
|
|
239
|
+
annotations: mutating,
|
|
240
|
+
handler: fileToolHandlers.set_permissions,
|
|
241
|
+
},
|
|
206
242
|
{
|
|
207
243
|
name: 'edit_block',
|
|
208
244
|
title: 'Edit file',
|
package/src/config.mjs
CHANGED
|
@@ -80,15 +80,17 @@ const telemetryEnabled = telemetryDisabled
|
|
|
80
80
|
? false
|
|
81
81
|
: booleanValue(process.env.REMCP_RUNTIME_TELEMETRY ?? file.telemetryEnabled, true);
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
// 2 MiB of tool result per call by default: enough for a large file read or a batch of
|
|
84
|
+
// files, still comfortably below the transport ceiling.
|
|
85
|
+
const configuredOutputBytes = positiveNumber(process.env.REMCP_RUNTIME_MAX_OUTPUT_BYTES ?? file.maxOutputBytes, 2 * 1024 * 1024);
|
|
84
86
|
|
|
85
87
|
export const runtimeConfig = Object.freeze({
|
|
86
88
|
allowedRoots: Object.freeze(allowedRoots),
|
|
87
89
|
blockedCommands: Object.freeze(stringList(process.env.REMCP_RUNTIME_BLOCKED_COMMANDS ?? file.blockedCommands)),
|
|
88
90
|
dangerousCommands: dangerousMode(process.env.REMCP_RUNTIME_DANGEROUS_COMMANDS ?? file.dangerousCommands),
|
|
89
91
|
maxOutputBytes: Math.min(configuredOutputBytes, HARD_OUTPUT_CEILING_BYTES),
|
|
90
|
-
maxReadLines: positiveNumber(process.env.REMCP_RUNTIME_MAX_READ_LINES ?? file.maxReadLines,
|
|
91
|
-
maxBufferedLines: positiveNumber(process.env.REMCP_RUNTIME_MAX_BUFFERED_LINES ?? file.maxBufferedLines,
|
|
92
|
+
maxReadLines: positiveNumber(process.env.REMCP_RUNTIME_MAX_READ_LINES ?? file.maxReadLines, 4000),
|
|
93
|
+
maxBufferedLines: positiveNumber(process.env.REMCP_RUNTIME_MAX_BUFFERED_LINES ?? file.maxBufferedLines, 100000),
|
|
92
94
|
maxWriteBytes: positiveNumber(process.env.REMCP_RUNTIME_MAX_WRITE_BYTES ?? file.maxWriteBytes, 8 * 1024 * 1024),
|
|
93
95
|
defaultShell: String(process.env.REMCP_RUNTIME_SHELL || file.defaultShell || '').trim(),
|
|
94
96
|
name: String(process.env.REMCP_RUNTIME_NAME || file.name || os.hostname()).trim(),
|
package/src/patch.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { splitLines } from './util.mjs';
|
|
2
|
+
|
|
3
|
+
// Minimal unified-diff applier. Models produce `--- a/file` / `+++ b/file` patches with
|
|
4
|
+
// `@@ -start,count +start,count @@` hunks; applying them directly is far more reliable
|
|
5
|
+
// than asking a model to re-send whole files or exact blocks.
|
|
6
|
+
|
|
7
|
+
const HUNK_HEADER = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/;
|
|
8
|
+
|
|
9
|
+
export function parseUnifiedDiff(patch) {
|
|
10
|
+
const lines = String(patch).replace(/\r\n/g, '\n').split('\n');
|
|
11
|
+
const files = [];
|
|
12
|
+
let current = null;
|
|
13
|
+
let hunk = null;
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
if (line.startsWith('--- ')) {
|
|
16
|
+
current = { oldPath: line.slice(4).trim(), newPath: null, hunks: [] };
|
|
17
|
+
files.push(current);
|
|
18
|
+
hunk = null;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (line.startsWith('+++ ')) {
|
|
22
|
+
if (current) current.newPath = line.slice(4).trim();
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const header = line.match(HUNK_HEADER);
|
|
26
|
+
if (header) {
|
|
27
|
+
if (!current) { current = { oldPath: null, newPath: null, hunks: [] }; files.push(current); }
|
|
28
|
+
hunk = {
|
|
29
|
+
oldStart: Number(header[1]),
|
|
30
|
+
oldCount: header[2] === undefined ? 1 : Number(header[2]),
|
|
31
|
+
newStart: Number(header[3]),
|
|
32
|
+
newCount: header[4] === undefined ? 1 : Number(header[4]),
|
|
33
|
+
lines: [],
|
|
34
|
+
};
|
|
35
|
+
current.hunks.push(hunk);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!hunk) continue;
|
|
39
|
+
if (line.startsWith('\\')) continue; // ""
|
|
40
|
+
if (line === '' && hunk.lines.length === 0) continue;
|
|
41
|
+
const marker = line[0];
|
|
42
|
+
if (marker === ' ' || marker === '+' || marker === '-') hunk.lines.push({ type: marker, text: line.slice(1) });
|
|
43
|
+
}
|
|
44
|
+
return files.filter(file => file.hunks.length);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalize(line) {
|
|
48
|
+
return String(line).replace(/[ \t]+/g, ' ').trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Find the block a hunk expects, allowing for a few lines of drift and for whitespace
|
|
52
|
+
// differences, the same way patch(1) does with fuzz.
|
|
53
|
+
function locate(lines, hunk) {
|
|
54
|
+
const expected = hunk.lines.filter(entry => entry.type !== '+').map(entry => entry.text);
|
|
55
|
+
if (!expected.length) return { index: hunk.oldStart - 1, fuzz: 0 };
|
|
56
|
+
const candidates = [];
|
|
57
|
+
const anchor = Math.max(0, hunk.oldStart - 1);
|
|
58
|
+
for (let offset = 0; offset <= 200; offset += 1) {
|
|
59
|
+
for (const index of offset === 0 ? [anchor] : [anchor - offset, anchor + offset]) {
|
|
60
|
+
if (index < 0 || index + expected.length > lines.length) continue;
|
|
61
|
+
const window = lines.slice(index, index + expected.length);
|
|
62
|
+
if (window.every((line, position) => line === expected[position])) candidates.push({ index, fuzz: offset });
|
|
63
|
+
else if (window.every((line, position) => normalize(line) === normalize(expected[position]))) candidates.push({ index, fuzz: offset + 1000 });
|
|
64
|
+
}
|
|
65
|
+
if (candidates.length) break;
|
|
66
|
+
}
|
|
67
|
+
if (!candidates.length) return null;
|
|
68
|
+
candidates.sort((a, b) => a.fuzz - b.fuzz || a.index - b.index);
|
|
69
|
+
return candidates[0];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function applyHunks(content, hunks) {
|
|
73
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
74
|
+
const endsWithNewline = /\n$/.test(content);
|
|
75
|
+
const lines = splitLines(content);
|
|
76
|
+
const applied = [];
|
|
77
|
+
const failed = [];
|
|
78
|
+
// Apply from the bottom of the file upwards so earlier hunks keep their line numbers.
|
|
79
|
+
const ordered = [...hunks].sort((a, b) => b.oldStart - a.oldStart);
|
|
80
|
+
for (const hunk of ordered) {
|
|
81
|
+
const found = locate(lines, hunk);
|
|
82
|
+
if (!found) { failed.push(hunk); continue; }
|
|
83
|
+
let cursor = found.index;
|
|
84
|
+
const replacement = [];
|
|
85
|
+
for (const entry of hunk.lines) {
|
|
86
|
+
if (entry.type === ' ') { replacement.push(lines[cursor]); cursor += 1; continue; }
|
|
87
|
+
if (entry.type === '-') { cursor += 1; continue; }
|
|
88
|
+
replacement.push(entry.text);
|
|
89
|
+
}
|
|
90
|
+
lines.splice(found.index, cursor - found.index, ...replacement);
|
|
91
|
+
applied.push({ hunk, fuzz: found.fuzz });
|
|
92
|
+
}
|
|
93
|
+
const updated = `${lines.join(eol)}${endsWithNewline && lines.length ? eol : ''}`;
|
|
94
|
+
return { updated, applied, failed };
|
|
95
|
+
}
|
package/src/tools/files.mjs
CHANGED
|
@@ -3,16 +3,17 @@ import os from 'node:os';
|
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import { constants, createReadStream } from 'node:fs';
|
|
6
|
-
import { access, copyFile, cp, mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { access, chmod, chown, copyFile, cp, mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
7
7
|
import { pipeline } from 'node:stream/promises';
|
|
8
8
|
import { runtimeConfig } from '../config.mjs';
|
|
9
9
|
import { diffStats, unifiedDiff } from '../diff.mjs';
|
|
10
|
+
import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
|
|
10
11
|
import { countEvent, recordEvent } from '../telemetry.mjs';
|
|
11
12
|
import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, looksBinary, multi, pageLines, resolveSafePath, splitLines, text } from '../util.mjs';
|
|
12
13
|
|
|
13
|
-
const MAX_INLINE_FILE_BYTES =
|
|
14
|
+
const MAX_INLINE_FILE_BYTES = 20 * 1024 * 1024;
|
|
14
15
|
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
15
|
-
const MAX_BINARY_CHUNK_BYTES =
|
|
16
|
+
const MAX_BINARY_CHUNK_BYTES = 1024 * 1024;
|
|
16
17
|
const IMAGE_TYPES = new Map([
|
|
17
18
|
['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'],
|
|
18
19
|
['.webp', 'image/webp'], ['.bmp', 'image/bmp'], ['.svg', 'image/svg+xml'], ['.avif', 'image/avif'],
|
|
@@ -466,8 +467,8 @@ export async function readFilesTool(args) {
|
|
|
466
467
|
// instead of one round trip per path.
|
|
467
468
|
const root = await resolveSafePath(args.path || '.');
|
|
468
469
|
const pattern = typeof args.pattern === 'string' && args.pattern.trim() ? args.pattern.trim() : '**/*';
|
|
469
|
-
const maxFiles = clampInteger(args.max_files,
|
|
470
|
-
const maxLinesPerFile = clampInteger(args.max_lines_per_file, runtimeConfig.maxReadLines, 1,
|
|
470
|
+
const maxFiles = clampInteger(args.max_files, 100, 1, 500);
|
|
471
|
+
const maxLinesPerFile = clampInteger(args.max_lines_per_file, runtimeConfig.maxReadLines, 1, 20000);
|
|
471
472
|
const includeIgnored = args.include_ignored === true;
|
|
472
473
|
const matcher = globToRegExp(pattern);
|
|
473
474
|
const files = [];
|
|
@@ -646,6 +647,76 @@ export async function movePathsTool(args) {
|
|
|
646
647
|
return text(`${moved}/${pairs.length} path(s) moved\n${results.join('\n')}`, moved !== pairs.length);
|
|
647
648
|
}
|
|
648
649
|
|
|
650
|
+
// Applying a unified diff is the fastest path from "the model knows the change" to "the
|
|
651
|
+
// change is on disk": no exact-block matching, no re-sending whole files.
|
|
652
|
+
export async function applyPatchTool(args) {
|
|
653
|
+
const patch = typeof args.patch === 'string' && args.patch.trim() ? args.patch : fail('patch must be a unified diff');
|
|
654
|
+
const dryRun = args.dry_run === true;
|
|
655
|
+
const forcePath = typeof args.path === 'string' && args.path.trim() ? args.path : null;
|
|
656
|
+
const files = parseUnifiedDiff(patch);
|
|
657
|
+
if (!files.length) fail('patch does not contain any @@ hunks');
|
|
658
|
+
const results = [];
|
|
659
|
+
let changed = 0;
|
|
660
|
+
for (const file of files) {
|
|
661
|
+
const target = forcePath || (file.newPath && file.newPath !== '/dev/null' ? file.newPath : file.oldPath);
|
|
662
|
+
if (!target || target === '/dev/null') { results.push('failed: a hunk has no target path; pass path explicitly'); continue; }
|
|
663
|
+
try {
|
|
664
|
+
const absolute = await resolveSafePath(target);
|
|
665
|
+
let original = '';
|
|
666
|
+
try { original = (await readTextFile(absolute)).content; } catch (error) {
|
|
667
|
+
if (file.oldPath === '/dev/null' || /not found/i.test(error?.message || '')) original = '';
|
|
668
|
+
else throw error;
|
|
669
|
+
}
|
|
670
|
+
const { updated, applied, failed } = applyHunks(original, file.hunks);
|
|
671
|
+
if (failed.length && !applied.length) { results.push(`failed ${displayPath(absolute)}: none of the ${file.hunks.length} hunk(s) matched`); continue; }
|
|
672
|
+
const stats = diffStats(original, updated);
|
|
673
|
+
if (!dryRun) {
|
|
674
|
+
assertWritableSize(updated);
|
|
675
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
676
|
+
await writeFile(absolute, updated, 'utf8');
|
|
677
|
+
}
|
|
678
|
+
changed += 1;
|
|
679
|
+
const fuzzy = applied.filter(entry => entry.fuzz > 0).length;
|
|
680
|
+
results.push(`${dryRun ? 'would patch' : 'patched'} ${displayPath(absolute)} · ${applied.length}/${file.hunks.length} hunk(s), +${stats.added}/-${stats.removed} lines${fuzzy ? `, ${fuzzy} with fuzz` : ''}${failed.length ? `, ${failed.length} hunk(s) did not match` : ''}`);
|
|
681
|
+
if (dryRun) results.push(unifiedDiff(original, updated, { oldLabel: displayPath(absolute), newLabel: 'after' }));
|
|
682
|
+
} catch (error) {
|
|
683
|
+
results.push(`failed ${target}: ${error instanceof Error ? error.message : String(error)}`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
const failedCount = results.filter(line => line.startsWith('failed')).length;
|
|
687
|
+
return text([`${changed}/${files.length} file(s) ${dryRun ? 'would be patched' : 'patched'}`, ...results].join('\n'), failedCount > 0);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export async function setPermissionsTool(args) {
|
|
691
|
+
const absolute = await resolveSafePath(args.path);
|
|
692
|
+
const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
|
|
693
|
+
const raw = typeof args.mode === 'string' ? args.mode.trim() : String(args.mode ?? '');
|
|
694
|
+
if (!/^[0-7]{3,4}$/.test(raw)) fail('mode must be an octal string such as "755" or "0644"');
|
|
695
|
+
const mode = Number.parseInt(raw, 8);
|
|
696
|
+
const recursive = args.recursive === true;
|
|
697
|
+
const uid = Number.isInteger(Number(args.uid)) ? Number(args.uid) : null;
|
|
698
|
+
const gid = Number.isInteger(Number(args.gid)) ? Number(args.gid) : null;
|
|
699
|
+
const targets = [];
|
|
700
|
+
async function collect(target) {
|
|
701
|
+
targets.push(target);
|
|
702
|
+
const entry = await stat(target).catch(() => null);
|
|
703
|
+
if (!entry?.isDirectory() || !recursive) return;
|
|
704
|
+
for (const child of await readdir(target).catch(() => [])) await collect(path.join(target, child));
|
|
705
|
+
}
|
|
706
|
+
await collect(absolute);
|
|
707
|
+
let changed = 0;
|
|
708
|
+
for (const target of targets) {
|
|
709
|
+
try {
|
|
710
|
+
await chmod(target, mode);
|
|
711
|
+
if (uid !== null || gid !== null) await chown(target, uid ?? -1, gid ?? -1);
|
|
712
|
+
changed += 1;
|
|
713
|
+
} catch (error) {
|
|
714
|
+
fail(`Could not change permissions on ${displayPath(target)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return text(`Set mode ${raw}${uid !== null || gid !== null ? ` (uid ${uid ?? '-'} gid ${gid ?? '-'})` : ''} on ${changed} path(s) starting at ${displayPath(absolute)}.`);
|
|
718
|
+
}
|
|
719
|
+
|
|
649
720
|
export async function createDirectoryTool(args) {
|
|
650
721
|
const list = Array.isArray(args.paths) ? args.paths : [args.path];
|
|
651
722
|
if (!list.filter(Boolean).length) fail('path (or paths) is required');
|
|
@@ -845,6 +916,8 @@ export const fileToolHandlers = {
|
|
|
845
916
|
replace_in_files: replaceInFilesTool,
|
|
846
917
|
diff_files: diffFilesTool,
|
|
847
918
|
create_directory: createDirectoryTool,
|
|
919
|
+
apply_patch: applyPatchTool,
|
|
920
|
+
set_permissions: setPermissionsTool,
|
|
848
921
|
delete_path: deletePathTool,
|
|
849
922
|
delete_paths: deletePathsTool,
|
|
850
923
|
move_file: moveFileTool,
|