@remcp/runtime 0.2.0 → 0.2.4

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/src/catalog.mjs CHANGED
@@ -28,6 +28,24 @@ export const toolDefinitions = [
28
28
  annotations: readOnly,
29
29
  handler: fileToolHandlers.read_file,
30
30
  },
31
+ {
32
+ name: 'read_files',
33
+ title: 'Read files by glob',
34
+ description: 'Read every file matching a glob under a directory in one call, each section prefixed with its path and line count. Use this to load a whole project area into context quickly instead of one read per file.',
35
+ inputSchema: {
36
+ type: 'object',
37
+ properties: {
38
+ path: { type: 'string', description: 'Absolute directory (or single file) to start from.' },
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 50, maximum 200.' },
41
+ max_lines_per_file: { type: 'number', description: 'Lines kept per file. Default 2000.' },
42
+ include_ignored: { type: 'boolean', description: 'Also descend into .git and node_modules. Default false.' },
43
+ },
44
+ additionalProperties: false,
45
+ },
46
+ annotations: readOnly,
47
+ handler: fileToolHandlers.read_files,
48
+ },
31
49
  {
32
50
  name: 'read_multiple_files',
33
51
  title: 'Read multiple files',
@@ -43,15 +61,81 @@ export const toolDefinitions = [
43
61
  annotations: readOnly,
44
62
  handler: fileToolHandlers.read_multiple_files,
45
63
  },
64
+ {
65
+ name: 'read_image',
66
+ title: 'Read image',
67
+ description: 'Return an image file (PNG, JPEG, GIF, WebP, BMP, AVIF, or SVG) as a viewable image, so screenshots and diagrams can be inspected. Fails above the inline size limit.',
68
+ inputSchema: {
69
+ type: 'object',
70
+ properties: {
71
+ path: { type: 'string', description: 'Absolute path of the image file.' },
72
+ },
73
+ required: ['path'],
74
+ additionalProperties: false,
75
+ },
76
+ annotations: readOnly,
77
+ handler: fileToolHandlers.read_image,
78
+ },
79
+ {
80
+ name: 'read_binary',
81
+ title: 'Read binary chunk',
82
+ description: 'Read any file as base64, in chunks, for transferring binaries, images, archives, or documents off the computer. Returns size, offset, and nextOffsetBytes; call again with offset_bytes set to nextOffsetBytes until complete is true.',
83
+ inputSchema: {
84
+ type: 'object',
85
+ properties: {
86
+ path: { type: 'string', description: 'Absolute path of the file to read.' },
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 524288 (512 KiB).' },
89
+ },
90
+ required: ['path'],
91
+ additionalProperties: false,
92
+ },
93
+ annotations: readOnly,
94
+ handler: fileToolHandlers.read_binary,
95
+ },
96
+ {
97
+ name: 'write_binary',
98
+ title: 'Write binary chunk',
99
+ description: 'Write base64 data to a file byte for byte, creating parent directories. Use mode "append" to send a large file as consecutive chunks. Replaces the file by default.',
100
+ inputSchema: {
101
+ type: 'object',
102
+ properties: {
103
+ path: { type: 'string', description: 'Absolute path of the file to write.' },
104
+ data: { type: 'string', description: 'Base64-encoded content.' },
105
+ mode: { type: 'string', enum: ['rewrite', 'append'], description: 'rewrite replaces the file, append adds to the end. Default rewrite.' },
106
+ },
107
+ required: ['path', 'data'],
108
+ additionalProperties: false,
109
+ },
110
+ annotations: mutating,
111
+ handler: fileToolHandlers.write_binary,
112
+ },
113
+ {
114
+ name: 'hash_file',
115
+ title: 'Hash file',
116
+ description: 'Compute a checksum of a file without reading it into memory. Useful to verify a copy, compare two files, or confirm a download.',
117
+ inputSchema: {
118
+ type: 'object',
119
+ properties: {
120
+ path: { type: 'string', description: 'Absolute path of the file to hash.' },
121
+ algorithm: { type: 'string', enum: ['sha256', 'sha1', 'md5'], description: 'Hash algorithm. Default sha256.' },
122
+ },
123
+ required: ['path'],
124
+ additionalProperties: false,
125
+ },
126
+ annotations: readOnly,
127
+ handler: fileToolHandlers.hash_file,
128
+ },
46
129
  {
47
130
  name: 'list_directory',
48
131
  title: 'List directory',
49
- description: 'List the files and directories at a path. Entries are prefixed with [DIR], [FILE], or [LINK]; depth controls how many directory levels are included.',
132
+ description: 'List the files and directories at a path. Entries are prefixed with [DIR], [FILE], [LINK], or [DENIED] when a subdirectory cannot be read. depth controls how many directory levels are included and pattern filters file names by glob.',
50
133
  inputSchema: {
51
134
  type: 'object',
52
135
  properties: {
53
136
  path: { type: 'string', description: 'Absolute path of the directory to list.' },
54
137
  depth: { type: 'number', description: 'Directory levels to list, from 1 to 5. Default 1.' },
138
+ pattern: { type: 'string', description: 'Optional glob that filters file names, such as "*.log". Directories are always listed.' },
55
139
  },
56
140
  required: ['path'],
57
141
  additionalProperties: false,
@@ -77,7 +161,7 @@ export const toolDefinitions = [
77
161
  {
78
162
  name: 'write_file',
79
163
  title: 'Write file',
80
- description: 'Create a file or replace its full content. Parent directories are created automatically. Use mode "append" to add to the end instead of replacing the file.',
164
+ description: 'Create a file or change its full content. Parent directories are created automatically. Replaces the file by default; use mode "append" to add to the end. For binary data pass encoding-free base64 through write_binary instead.',
81
165
  inputSchema: {
82
166
  type: 'object',
83
167
  properties: {
@@ -91,10 +175,38 @@ export const toolDefinitions = [
91
175
  annotations: mutating,
92
176
  handler: fileToolHandlers.write_file,
93
177
  },
178
+ {
179
+ name: 'write_files',
180
+ title: 'Write multiple files',
181
+ description: 'Create or replace many files in one call, each with its own path, content, and optional mode. Use this to scaffold a project or apply a multi-file change without one round trip per file.',
182
+ inputSchema: {
183
+ type: 'object',
184
+ properties: {
185
+ files: {
186
+ type: 'array',
187
+ description: 'Files to write, at most 200 per call.',
188
+ items: {
189
+ type: 'object',
190
+ properties: {
191
+ path: { type: 'string', description: 'Absolute path of the file.' },
192
+ content: { type: 'string', description: 'Full file content.' },
193
+ mode: { type: 'string', enum: ['rewrite', 'append'], description: 'rewrite replaces the file (default), append adds to the end.' },
194
+ },
195
+ required: ['path', 'content'],
196
+ additionalProperties: false,
197
+ },
198
+ },
199
+ },
200
+ required: ['files'],
201
+ additionalProperties: false,
202
+ },
203
+ annotations: mutating,
204
+ handler: fileToolHandlers.write_files,
205
+ },
94
206
  {
95
207
  name: 'edit_block',
96
208
  title: 'Edit file',
97
- description: 'Replace an exact block of text in a file. Provide enough surrounding context to make old_string unique; the call fails unless the number of matches equals expected_replacements. When the exact text is not found, a whitespace-tolerant match is attempted and reported.',
209
+ description: 'Replace an exact block of text in a file. Provide enough surrounding context to make old_string unique; the call fails unless the number of matches equals expected_replacements. When the exact text is not found, a whitespace-tolerant match is attempted and reported. Pass dry_run to preview the change as a diff without writing.',
98
210
  inputSchema: {
99
211
  type: 'object',
100
212
  properties: {
@@ -103,6 +215,7 @@ export const toolDefinitions = [
103
215
  new_string: { type: 'string', description: 'Replacement text.' },
104
216
  expected_replacements: { type: 'number', description: 'Number of matches required for the edit to apply. Default 1.' },
105
217
  allow_fuzzy: { type: 'boolean', description: 'Allow a whitespace-tolerant fallback when the exact text is not found. Default true.' },
218
+ dry_run: { type: 'boolean', description: 'Return the diff without changing the file. Default false.' },
106
219
  },
107
220
  required: ['file_path', 'old_string', 'new_string'],
108
221
  additionalProperties: false,
@@ -110,16 +223,88 @@ export const toolDefinitions = [
110
223
  annotations: mutating,
111
224
  handler: fileToolHandlers.edit_block,
112
225
  },
226
+ {
227
+ name: 'replace_lines',
228
+ title: 'Replace lines',
229
+ description: 'Replace an inclusive 1-based line range with new text. The rest of the file, including its line endings, is preserved. Pass dry_run to preview the change as a diff without writing.',
230
+ inputSchema: {
231
+ type: 'object',
232
+ properties: {
233
+ path: { type: 'string', description: 'Absolute path of the file to edit.' },
234
+ start_line: { type: 'number', description: 'First line to replace, 1-based and inclusive.' },
235
+ end_line: { type: 'number', description: 'Last line to replace, 1-based and inclusive.' },
236
+ content: { type: 'string', description: 'Replacement text; an empty string deletes the range.' },
237
+ dry_run: { type: 'boolean', description: 'Return the diff without changing the file. Default false.' },
238
+ },
239
+ required: ['path', 'start_line', 'end_line', 'content'],
240
+ additionalProperties: false,
241
+ },
242
+ annotations: mutating,
243
+ handler: fileToolHandlers.replace_lines,
244
+ },
245
+ {
246
+ name: 'replace_in_files',
247
+ title: 'Replace in files',
248
+ description: 'Replace text or a regular expression across the text files under a path and report what changed. Applies immediately; pass dry_run true to preview the affected files first.',
249
+ inputSchema: {
250
+ type: 'object',
251
+ properties: {
252
+ path: { type: 'string', description: 'Absolute path of a file or directory to search.' },
253
+ pattern: { type: 'string', description: 'Text or regular expression to find.' },
254
+ replacement: { type: 'string', description: 'Replacement text. In regex mode, $1 and friends refer to capture groups.' },
255
+ filePattern: { type: 'string', description: 'Optional glob limiting which file names are changed, such as "*.ts".' },
256
+ regex: { type: 'boolean', description: 'Treat pattern as a regular expression. Default false (plain text).' },
257
+ dry_run: { type: 'boolean', description: 'Only report the files that would change. Default false.' },
258
+ maxFiles: { type: 'number', description: 'Stop after this many changed files. Default 100, maximum 500.' },
259
+ },
260
+ required: ['path', 'pattern', 'replacement'],
261
+ additionalProperties: false,
262
+ },
263
+ annotations: mutating,
264
+ handler: fileToolHandlers.replace_in_files,
265
+ },
266
+ {
267
+ name: 'diff_files',
268
+ title: 'Diff files',
269
+ description: 'Show a unified diff between two local text files, with line counts. Useful to check what changed before reporting or reverting it.',
270
+ inputSchema: {
271
+ type: 'object',
272
+ properties: {
273
+ left: { type: 'string', description: 'Absolute path of the original file.' },
274
+ right: { type: 'string', description: 'Absolute path of the file to compare against it.' },
275
+ context_lines: { type: 'number', description: 'Lines of context around each change. Default 3, maximum 20.' },
276
+ },
277
+ required: ['left', 'right'],
278
+ additionalProperties: false,
279
+ },
280
+ annotations: readOnly,
281
+ handler: fileToolHandlers.diff_files,
282
+ },
283
+ {
284
+ name: 'move_to_trash',
285
+ title: 'Move to trash',
286
+ description: 'Move a file or directory to the system trash instead of deleting it, so the change can be undone. When the trash is outside the device allowed roots, a .remcp-trash folder beside the file is used instead.',
287
+ inputSchema: {
288
+ type: 'object',
289
+ properties: {
290
+ source: { type: 'string', description: 'Absolute path to move to the trash.' },
291
+ },
292
+ required: ['source'],
293
+ additionalProperties: false,
294
+ },
295
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
296
+ handler: fileToolHandlers.move_to_trash,
297
+ },
113
298
  {
114
299
  name: 'create_directory',
115
- title: 'Create directory',
116
- description: 'Create a directory, including any missing parent directories. Succeeds when the directory already exists.',
300
+ title: 'Create directories',
301
+ description: 'Create one directory or many in a single call, including any missing parent directories. Succeeds when a directory already exists.',
117
302
  inputSchema: {
118
303
  type: 'object',
119
304
  properties: {
120
305
  path: { type: 'string', description: 'Absolute path of the directory to create.' },
306
+ paths: { type: 'array', items: { type: 'string' }, description: 'Several directories to create at once, at most 200.' },
121
307
  },
122
- required: ['path'],
123
308
  additionalProperties: false,
124
309
  },
125
310
  annotations: additive,
@@ -128,36 +313,173 @@ export const toolDefinitions = [
128
313
  {
129
314
  name: 'move_file',
130
315
  title: 'Move or rename',
131
- description: 'Move or rename a file or directory. The call fails when the destination already exists, so nothing is overwritten.',
316
+ description: 'Move or rename a file or directory. Replaces an existing destination file by default; pass overwrite false to refuse instead.',
132
317
  inputSchema: {
133
318
  type: 'object',
134
319
  properties: {
135
320
  source: { type: 'string', description: 'Absolute path to move.' },
136
321
  destination: { type: 'string', description: 'Absolute destination path.' },
322
+ overwrite: { type: 'boolean', description: 'Replace an existing destination file. Default true.' },
137
323
  },
138
324
  required: ['source', 'destination'],
139
325
  additionalProperties: false,
140
326
  },
141
- annotations: additive,
327
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
142
328
  handler: fileToolHandlers.move_file,
143
329
  },
144
330
  {
145
331
  name: 'copy_file',
146
332
  title: 'Copy file',
147
- description: 'Copy one file to a new path. The call fails when the destination exists unless overwrite is true. Directories are not copied recursively.',
333
+ description: 'Copy one file to a new path, replacing the destination by default. Pass overwrite false to refuse an existing destination. Directories are not copied recursively.',
148
334
  inputSchema: {
149
335
  type: 'object',
150
336
  properties: {
151
337
  source: { type: 'string', description: 'Absolute path of the file to copy.' },
152
338
  destination: { type: 'string', description: 'Absolute destination path.' },
153
- overwrite: { type: 'boolean', description: 'Replace the destination when it already exists. Default false.' },
339
+ overwrite: { type: 'boolean', description: 'Replace the destination when it already exists. Default true.' },
154
340
  },
155
341
  required: ['source', 'destination'],
156
342
  additionalProperties: false,
157
343
  },
158
- annotations: additive,
344
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
159
345
  handler: fileToolHandlers.copy_file,
160
346
  },
347
+ {
348
+ name: 'copy_paths',
349
+ title: 'Copy paths',
350
+ description: 'Copy many files or whole directories in one call, each with its own source and destination. Directories are copied recursively.',
351
+ inputSchema: {
352
+ type: 'object',
353
+ properties: {
354
+ paths: {
355
+ type: 'array',
356
+ description: 'Pairs to copy, at most 200 per call.',
357
+ items: {
358
+ type: 'object',
359
+ properties: {
360
+ source: { type: 'string', description: 'Absolute path to copy.' },
361
+ destination: { type: 'string', description: 'Absolute destination path.' },
362
+ },
363
+ required: ['source', 'destination'],
364
+ additionalProperties: false,
365
+ },
366
+ },
367
+ overwrite: { type: 'boolean', description: 'Replace an existing destination. Default true.' },
368
+ },
369
+ required: ['paths'],
370
+ additionalProperties: false,
371
+ },
372
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
373
+ handler: fileToolHandlers.copy_paths,
374
+ },
375
+ {
376
+ name: 'move_paths',
377
+ title: 'Move paths',
378
+ description: 'Move or rename many files or whole directories in one call, each with its own source and destination. Falls back to copy-and-delete across filesystems.',
379
+ inputSchema: {
380
+ type: 'object',
381
+ properties: {
382
+ paths: {
383
+ type: 'array',
384
+ description: 'Pairs to move, at most 200 per call.',
385
+ items: {
386
+ type: 'object',
387
+ properties: {
388
+ source: { type: 'string', description: 'Absolute path to move.' },
389
+ destination: { type: 'string', description: 'Absolute destination path.' },
390
+ },
391
+ required: ['source', 'destination'],
392
+ additionalProperties: false,
393
+ },
394
+ },
395
+ overwrite: { type: 'boolean', description: 'Replace an existing destination. Default true.' },
396
+ },
397
+ required: ['paths'],
398
+ additionalProperties: false,
399
+ },
400
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
401
+ handler: fileToolHandlers.move_paths,
402
+ },
403
+ {
404
+ name: 'delete_path',
405
+ title: 'Delete path',
406
+ description: 'Delete a file or a directory on the computer. Directories are removed with their contents unless recursive is false. The filesystem root is refused.',
407
+ inputSchema: {
408
+ type: 'object',
409
+ properties: {
410
+ path: { type: 'string', description: 'Absolute path to delete.' },
411
+ recursive: { type: 'boolean', description: 'Delete a non-empty directory with its contents. Default true.' },
412
+ },
413
+ required: ['path'],
414
+ additionalProperties: false,
415
+ },
416
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
417
+ handler: fileToolHandlers.delete_path,
418
+ },
419
+ {
420
+ name: 'delete_paths',
421
+ title: 'Delete paths',
422
+ description: 'Delete many files and directories in one call, reporting each result. Use move_to_trash instead when the deletion should be reversible.',
423
+ inputSchema: {
424
+ type: 'object',
425
+ properties: {
426
+ paths: { type: 'array', items: { type: 'string' }, description: 'Absolute paths to delete, at most 500 per call.' },
427
+ recursive: { type: 'boolean', description: 'Delete non-empty directories with their contents. Default true.' },
428
+ },
429
+ required: ['paths'],
430
+ additionalProperties: false,
431
+ },
432
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
433
+ handler: fileToolHandlers.delete_paths,
434
+ },
435
+ {
436
+ name: 'create_archive',
437
+ title: 'Create archive',
438
+ description: 'Pack files and directories into a tar, tar.gz, or zip archive on the device, so a whole tree can be transferred or backed up in one call.',
439
+ inputSchema: {
440
+ type: 'object',
441
+ properties: {
442
+ paths: { type: 'array', items: { type: 'string' }, description: 'Absolute paths of the files and directories to include.' },
443
+ destination: { type: 'string', description: 'Absolute path of the archive to create.' },
444
+ format: { type: 'string', enum: ['tar', 'tar.gz', 'zip'], description: 'Archive format. Default tar.gz, or zip when the destination ends in .zip.' },
445
+ },
446
+ required: ['paths', 'destination'],
447
+ additionalProperties: false,
448
+ },
449
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
450
+ handler: fileToolHandlers.create_archive,
451
+ },
452
+ {
453
+ name: 'extract_archive',
454
+ title: 'Extract archive',
455
+ description: 'Extract a tar, tar.gz, tar.bz2, tar.xz, or zip archive on the device into a directory, creating it when needed.',
456
+ inputSchema: {
457
+ type: 'object',
458
+ properties: {
459
+ archive: { type: 'string', description: 'Absolute path of the archive to extract.' },
460
+ destination: { type: 'string', description: 'Absolute directory to extract into. Defaults to the archive directory.' },
461
+ },
462
+ required: ['archive'],
463
+ additionalProperties: false,
464
+ },
465
+ annotations: mutating,
466
+ handler: fileToolHandlers.extract_archive,
467
+ },
468
+ {
469
+ name: 'take_screenshot',
470
+ title: 'Take screenshot',
471
+ description: 'Capture the screen of the paired computer and return it as an image, for GUI work, visual checks, and demonstrating what is on screen. Uses grim, gnome-screenshot, spectacle, scrot, ImageMagick import, screencapture, or PowerShell depending on the platform.',
472
+ inputSchema: {
473
+ type: 'object',
474
+ properties: {
475
+ directory: { type: 'string', description: 'Absolute directory to write the temporary PNG into. Defaults to the system temp directory.' },
476
+ keep: { type: 'boolean', description: 'Keep the PNG on disk instead of deleting it after it is returned. Default false.' },
477
+ },
478
+ additionalProperties: false,
479
+ },
480
+ annotations: readOnly,
481
+ handler: fileToolHandlers.take_screenshot,
482
+ },
161
483
  {
162
484
  name: 'start_search',
163
485
  title: 'Start search',
@@ -313,6 +635,14 @@ export const toolDefinitions = [
313
635
  annotations: readOnly,
314
636
  handler: terminalToolHandlers.list_sessions,
315
637
  },
638
+ {
639
+ name: 'get_system_info',
640
+ title: 'Get system info',
641
+ description: 'Report host details for the paired computer: operating system and kernel, CPU model and load, memory pressure, free disk space on the working volume, uptime, and the default shell.',
642
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
643
+ annotations: readOnly,
644
+ handler: systemToolHandlers.get_system_info,
645
+ },
316
646
  {
317
647
  name: 'list_processes',
318
648
  title: 'List processes',
package/src/config.mjs CHANGED
@@ -7,8 +7,35 @@ const configDir = process.env.REMCP_RUNTIME_CONFIG_DIR || path.join(os.homedir()
7
7
  export const runtimeConfigPath = path.join(configDir, 'runtime.json');
8
8
  export const runtimeConfigDir = configDir;
9
9
 
10
+ // The MCP SDK's stdio client closes the connection on a message above 10 MB, which kills
11
+ // this process. Keep the runtime's own output cap well below that so raising an env var
12
+ // cannot turn a large tool result into a dead device.
13
+ export const HARD_OUTPUT_CEILING_BYTES = 8 * 1024 * 1024;
14
+
15
+ let configError = null;
16
+
10
17
  function readConfigFile() {
11
- try { return JSON.parse(readFileSync(runtimeConfigPath, 'utf8')); } catch { return {}; }
18
+ let raw;
19
+ try {
20
+ raw = readFileSync(runtimeConfigPath, 'utf8');
21
+ } catch (error) {
22
+ if (error?.code === 'ENOENT') return {};
23
+ configError = `Could not read ${runtimeConfigPath}: ${error instanceof Error ? error.message : String(error)}`;
24
+ return {};
25
+ }
26
+ if (!raw.trim()) return {};
27
+ try {
28
+ const parsed = JSON.parse(raw);
29
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
30
+ configError = `${runtimeConfigPath} must contain a JSON object`;
31
+ return {};
32
+ }
33
+ return parsed;
34
+ } catch (error) {
35
+ // A trailing comma used to silently drop allowedRoots and re-enable usage metrics.
36
+ configError = `${runtimeConfigPath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`;
37
+ return {};
38
+ }
12
39
  }
13
40
 
14
41
  const file = readConfigFile();
@@ -34,7 +61,10 @@ function booleanValue(value, fallback) {
34
61
 
35
62
  const DANGEROUS_MODES = ['block', 'warn', 'allow'];
36
63
 
37
- function dangerousMode(value, fallback = 'block') {
64
+ // `warn` runs the command and adds a note when it matches the destructive-command list:
65
+ // nothing is ever blocked or delayed, but a catastrophic command is visible in the tool
66
+ // result. Set `allow` for zero noise or `block` to refuse.
67
+ function dangerousMode(value, fallback = 'warn') {
38
68
  const normalized = String(value ?? '').trim().toLowerCase();
39
69
  return DANGEROUS_MODES.includes(normalized) ? normalized : fallback;
40
70
  }
@@ -50,11 +80,13 @@ const telemetryEnabled = telemetryDisabled
50
80
  ? false
51
81
  : booleanValue(process.env.REMCP_RUNTIME_TELEMETRY ?? file.telemetryEnabled, true);
52
82
 
83
+ const configuredOutputBytes = positiveNumber(process.env.REMCP_RUNTIME_MAX_OUTPUT_BYTES ?? file.maxOutputBytes, 1024 * 1024);
84
+
53
85
  export const runtimeConfig = Object.freeze({
54
86
  allowedRoots: Object.freeze(allowedRoots),
55
87
  blockedCommands: Object.freeze(stringList(process.env.REMCP_RUNTIME_BLOCKED_COMMANDS ?? file.blockedCommands)),
56
88
  dangerousCommands: dangerousMode(process.env.REMCP_RUNTIME_DANGEROUS_COMMANDS ?? file.dangerousCommands),
57
- maxOutputBytes: positiveNumber(process.env.REMCP_RUNTIME_MAX_OUTPUT_BYTES ?? file.maxOutputBytes, 1024 * 1024),
89
+ maxOutputBytes: Math.min(configuredOutputBytes, HARD_OUTPUT_CEILING_BYTES),
58
90
  maxReadLines: positiveNumber(process.env.REMCP_RUNTIME_MAX_READ_LINES ?? file.maxReadLines, 2000),
59
91
  maxBufferedLines: positiveNumber(process.env.REMCP_RUNTIME_MAX_BUFFERED_LINES ?? file.maxBufferedLines, 50000),
60
92
  maxWriteBytes: positiveNumber(process.env.REMCP_RUNTIME_MAX_WRITE_BYTES ?? file.maxWriteBytes, 8 * 1024 * 1024),
@@ -63,6 +95,12 @@ export const runtimeConfig = Object.freeze({
63
95
  telemetryEnabled,
64
96
  });
65
97
 
98
+ // A configuration the user cannot read is not a configuration we should quietly ignore:
99
+ // it is how allowedRoots and an opt-out silently disappear.
100
+ export function configurationError() {
101
+ return configError;
102
+ }
103
+
66
104
  export function describeConfig() {
67
105
  return {
68
106
  name: runtimeConfig.name,
@@ -70,10 +108,12 @@ export function describeConfig() {
70
108
  arch: process.arch,
71
109
  node: process.versions.node,
72
110
  configFile: runtimeConfigPath,
111
+ configError,
73
112
  allowedRoots: [...runtimeConfig.allowedRoots],
74
113
  blockedCommands: [...runtimeConfig.blockedCommands],
75
114
  dangerousCommands: runtimeConfig.dangerousCommands,
76
115
  maxOutputBytes: runtimeConfig.maxOutputBytes,
116
+ maxOutputBytesCeiling: HARD_OUTPUT_CEILING_BYTES,
77
117
  maxReadLines: runtimeConfig.maxReadLines,
78
118
  maxBufferedLines: runtimeConfig.maxBufferedLines,
79
119
  maxWriteBytes: runtimeConfig.maxWriteBytes,
package/src/diff.mjs ADDED
@@ -0,0 +1,86 @@
1
+ import { splitLines } from './util.mjs';
2
+
3
+ // Compact line diff. Files routinely have a large identical prefix and suffix, so those
4
+ // are trimmed first; the remaining window is diffed with an LCS table capped at a size
5
+ // that keeps memory bounded on pathological input.
6
+
7
+ const MAX_DIFF_LINES = 4000;
8
+ const CONTEXT = 3;
9
+
10
+ function commonPrefix(a, b) {
11
+ const limit = Math.min(a.length, b.length);
12
+ let index = 0;
13
+ while (index < limit && a[index] === b[index]) index += 1;
14
+ return index;
15
+ }
16
+
17
+ function commonSuffix(a, b, fromStart) {
18
+ const limit = Math.min(a.length, b.length) - fromStart;
19
+ let count = 0;
20
+ while (count < limit && a[a.length - 1 - count] === b[b.length - 1 - count]) count += 1;
21
+ return count;
22
+ }
23
+
24
+ function lcsOperations(a, b) {
25
+ const rows = a.length + 1;
26
+ const cols = b.length + 1;
27
+ const table = Array.from({ length: rows }, () => new Uint32Array(cols));
28
+ for (let i = a.length - 1; i >= 0; i -= 1) {
29
+ for (let j = b.length - 1; j >= 0; j -= 1) {
30
+ table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
31
+ }
32
+ }
33
+ const operations = [];
34
+ let i = 0;
35
+ let j = 0;
36
+ while (i < a.length && j < b.length) {
37
+ if (a[i] === b[j]) { operations.push({ type: 'equal', line: a[i] }); i += 1; j += 1; }
38
+ else if (table[i + 1][j] >= table[i][j + 1]) { operations.push({ type: 'delete', line: a[i] }); i += 1; }
39
+ else { operations.push({ type: 'insert', line: b[j] }); j += 1; }
40
+ }
41
+ while (i < a.length) { operations.push({ type: 'delete', line: a[i] }); i += 1; }
42
+ while (j < b.length) { operations.push({ type: 'insert', line: b[j] }); j += 1; }
43
+ return operations;
44
+ }
45
+
46
+ export function diffStats(oldText, newText) {
47
+ const before = splitLines(oldText);
48
+ const after = splitLines(newText);
49
+ const prefix = commonPrefix(before, after);
50
+ const suffix = commonSuffix(before, after, prefix);
51
+ const middleBefore = before.slice(prefix, before.length - suffix);
52
+ const middleAfter = after.slice(prefix, after.length - suffix);
53
+ const operations = middleBefore.length > MAX_DIFF_LINES || middleAfter.length > MAX_DIFF_LINES
54
+ ? [...middleBefore.map(line => ({ type: 'delete', line })), ...middleAfter.map(line => ({ type: 'insert', line }))]
55
+ : lcsOperations(middleBefore, middleAfter);
56
+ return {
57
+ added: operations.filter(op => op.type === 'insert').length,
58
+ removed: operations.filter(op => op.type === 'delete').length,
59
+ truncated: middleBefore.length > MAX_DIFF_LINES || middleAfter.length > MAX_DIFF_LINES,
60
+ };
61
+ }
62
+
63
+ export function unifiedDiff(oldText, newText, { oldLabel = 'before', newLabel = 'after', context = CONTEXT } = {}) {
64
+ const before = splitLines(oldText);
65
+ const after = splitLines(newText);
66
+ if (before.join('\n') === after.join('\n')) return '';
67
+
68
+ const prefix = commonPrefix(before, after);
69
+ const suffix = commonSuffix(before, after, prefix);
70
+ const middleBefore = before.slice(prefix, before.length - suffix);
71
+ const middleAfter = after.slice(prefix, after.length - suffix);
72
+ const truncated = middleBefore.length > MAX_DIFF_LINES || middleAfter.length > MAX_DIFF_LINES;
73
+ const operations = truncated
74
+ ? [...middleBefore.map(line => ({ type: 'delete', line })), ...middleAfter.map(line => ({ type: 'insert', line }))]
75
+ : lcsOperations(middleBefore, middleAfter);
76
+
77
+ const rows = [];
78
+ for (let index = 0; index < Math.min(prefix, context); index += 1) rows.push({ type: 'equal', line: before[prefix - Math.min(prefix, context) + index] });
79
+ rows.push(...operations);
80
+ for (let index = 0; index < Math.min(suffix, context); index += 1) rows.push({ type: 'equal', line: before[before.length - suffix + index] });
81
+
82
+ const header = `--- ${oldLabel}\n+++ ${newLabel}`;
83
+ const body = rows.map(row => `${row.type === 'insert' ? '+' : row.type === 'delete' ? '-' : ' '}${row.line}`);
84
+ if (truncated) body.unshift('… diff truncated to the changed region (file is very large) …');
85
+ return [header, `@@ -${prefix + 1},${middleBefore.length} +${prefix + 1},${middleAfter.length} @@`, ...body].join('\n');
86
+ }