@remcp/runtime 0.2.19 → 0.2.21

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.20
4
+
5
+ - `set_config_value`: a model may change this runtime’s own preferences (telemetry opt-out, read
6
+ and buffer line limits, result size) while it is running; the change applies immediately and is
7
+ saved to runtime.json. Access roots, blocked commands, the command guardrail, the shell and the
8
+ write limit stay with the person at this computer and are refused by the tool.
9
+
3
10
  ## 0.2.16
4
11
 
5
12
  - Mark cursor-consuming `read_process_output` as non-idempotent so clients do not assume retries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "description": "First-party ReMCP local device runtime: file, search, terminal and process tools over MCP for computers paired with ReMCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/catalog.mjs CHANGED
@@ -3,6 +3,7 @@ import { searchToolHandlers } from './tools/search.mjs';
3
3
  import { terminalToolHandlers } from './tools/terminal.mjs';
4
4
  import { systemToolHandlers } from './tools/system.mjs';
5
5
  import { statsToolHandlers } from './tools/stats.mjs';
6
+ import { configToolHandlers } from './tools/config.mjs';
6
7
 
7
8
  // Every tool answers with a text result (image tools add an image part as well), so the
8
9
  // declared output schema is the same shape everywhere and clients can rely on it.
@@ -25,7 +26,7 @@ export const toolDefinitions = [
25
26
  {
26
27
  name: 'read_file',
27
28
  title: 'Read file',
28
- description: 'Read a text file on this computer. Use offset and length to page through large files; a negative offset reads from the end of the file.',
29
+ description: 'Read a file on this computer: text as text, and .docx or .pdf as their extracted document text (a scanned or font-obfuscated PDF says so instead of returning noise). Use offset and length to page through large files; a negative offset reads from the end of the file.',
29
30
  inputSchema: {
30
31
  type: 'object',
31
32
  properties: {
@@ -737,6 +738,23 @@ export const toolDefinitions = [
737
738
  annotations: readOnly,
738
739
  handler: statsToolHandlers.get_runtime_stats,
739
740
  },
741
+ {
742
+ name: 'set_config_value',
743
+ title: 'Change runtime setting',
744
+ description: 'Change one of this runtime’s own preferences on this computer: telemetryEnabled, maxReadLines, maxBufferedLines or maxOutputBytes. The change applies immediately and is saved to runtime.json. Access roots, blocked commands, the command guardrail, the shell and the write limit cannot be set through MCP — they stay with the person at this computer.',
745
+ inputSchema: {
746
+ type: 'object',
747
+ properties: {
748
+ key: { type: 'string', description: 'telemetryEnabled (true/false), maxReadLines, maxBufferedLines or maxOutputBytes.' },
749
+ value: { description: 'New value: a boolean for telemetryEnabled, a number for the limits.' },
750
+ },
751
+ required: ['key', 'value'],
752
+ additionalProperties: false,
753
+ },
754
+ // Preference only, and the same call twice leaves the same state.
755
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
756
+ handler: configToolHandlers.set_config_value,
757
+ },
740
758
  ];
741
759
 
742
760
  export function advertisedTools() {
package/src/config.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
- import { readFileSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
4
4
  import { expandHome } from './util.mjs';
5
5
 
6
6
  const configDir = process.env.REMCP_RUNTIME_CONFIG_DIR || path.join(os.homedir(), '.config', 'remcp');
@@ -97,6 +97,61 @@ export const runtimeConfig = Object.freeze({
97
97
  telemetryEnabled,
98
98
  });
99
99
 
100
+ // --- settings a model may change, and nothing else ------------------------------------------------
101
+ //
102
+ // Desktop Commander lets a model rewrite any of its own configuration, including the directories it
103
+ // may touch and the commands it must refuse. On ReMCP those two decide what the computer exposes, so
104
+ // they stay with the person at the computer: `allowedRoots`, `blockedCommands`, `dangerousCommands`
105
+ // (the command guardrail), `defaultShell`, `maxWriteBytes` and `name` are not settable from a tool.
106
+ //
107
+ // What is settable is a preference and two context limits — telemetry opt-out, the read and buffer
108
+ // line limits, and the result size — all of which the person can also change in runtime.json. The
109
+ // values apply immediately (the tool handlers read them through liveConfig) and are written back to
110
+ // runtime.json so they survive a restart.
111
+ const SETTABLE = Object.freeze({
112
+ telemetryEnabled: { type: 'boolean' },
113
+ maxReadLines: { type: 'integer', min: 1, max: 100_000 },
114
+ maxBufferedLines: { type: 'integer', min: 1, max: 1_000_000 },
115
+ maxOutputBytes: { type: 'integer', min: 1024, max: HARD_OUTPUT_CEILING_BYTES },
116
+ });
117
+ export const settableKeys = Object.freeze(Object.keys(SETTABLE));
118
+
119
+ const live = new Map();
120
+
121
+ // Every read of a settable value goes through here, so a change applies to the next call.
122
+ export function liveConfig(key) {
123
+ return live.has(key) ? live.get(key) : runtimeConfig[key];
124
+ }
125
+
126
+ export function validateConfigValue(key, raw) {
127
+ const rule = SETTABLE[key];
128
+ if (!rule) {
129
+ throw new Error(`Unsupported setting: ${key || '(empty)'}. Settable here: ${settableKeys.join(', ')}. Access roots, blocked commands, the command guardrail, the shell and write limits are changed by the person at this computer.`);
130
+ }
131
+ if (rule.type === 'boolean') {
132
+ if (typeof raw === 'boolean') return raw;
133
+ if (typeof raw === 'string') return booleanValue(raw, null) ?? (() => { throw new Error(`${key} must be true or false`); })();
134
+ throw new Error(`${key} must be true or false`);
135
+ }
136
+ const value = Math.trunc(Number(raw));
137
+ if (!Number.isFinite(value)) throw new Error(`${key} must be a number`);
138
+ if (value < rule.min || value > rule.max) throw new Error(`${key} must be between ${rule.min} and ${rule.max}`);
139
+ return value;
140
+ }
141
+
142
+ export function applyLiveConfig(key, value) {
143
+ live.set(key, value);
144
+ }
145
+
146
+ // Writes only the changed key back, with the file mode the rest of the runtime expects.
147
+ export function persistConfigValue(key, value) {
148
+ const file = readConfigFile();
149
+ const next = { ...file, [key]: value };
150
+ mkdirSync(runtimeConfigDir, { recursive: true, mode: 0o700 });
151
+ writeFileSync(runtimeConfigPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
152
+ return runtimeConfigPath;
153
+ }
154
+
100
155
  // A configuration the user cannot read is not a configuration we should quietly ignore:
101
156
  // it is how allowedRoots and an opt-out silently disappear.
102
157
  export function configurationError() {
@@ -0,0 +1,130 @@
1
+ import { inflateRawSync, inflateSync } from 'node:zlib';
2
+
3
+ // Reading PDF and DOCX without pulling a document stack into the device runtime.
4
+ //
5
+ // Desktop Commander installs libraries for this. ReMCP keeps its one-dependency promise and reads the
6
+ // two formats that are actually documents with text in them:
7
+ //
8
+ // DOCX - a ZIP whose word/document.xml holds the text in <w:t> elements. Inflating a stored or
9
+ // deflated entry is all that is needed.
10
+ // PDF - objects with content streams. Text drawn with the standard encodings (Tj/TJ/'/") is
11
+ // extracted; a PDF that uses embedded subset fonts with custom CMaps cannot be read this
12
+ // way, and says so instead of returning mojibake.
13
+ //
14
+ // Nothing here writes, and nothing leaves the computer.
15
+
16
+ const MAX_INFLATE_BYTES = 32 * 1024 * 1024;
17
+
18
+ function inflate(buffer, raw = false) {
19
+ try {
20
+ const out = raw ? inflateRawSync(buffer, { maxOutputLength: MAX_INFLATE_BYTES }) : inflateSync(buffer, { maxOutputLength: MAX_INFLATE_BYTES });
21
+ return out;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function unzipEntry(buffer, wanted) {
28
+ // Walk the central directory once: enough to find one entry without implementing the whole format.
29
+ const end = buffer.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
30
+ if (end < 0) return null;
31
+ const count = buffer.readUInt16LE(end + 10);
32
+ let offset = buffer.readUInt32LE(end + 16);
33
+ for (let index = 0; index < count && offset + 46 <= buffer.length; index += 1) {
34
+ if (buffer.readUInt32LE(offset) !== 0x02014b50) return null;
35
+ const method = buffer.readUInt16LE(offset + 10);
36
+ const compressedSize = buffer.readUInt32LE(offset + 20);
37
+ const nameLength = buffer.readUInt16LE(offset + 28);
38
+ const extraLength = buffer.readUInt16LE(offset + 30);
39
+ const commentLength = buffer.readUInt16LE(offset + 32);
40
+ const localOffset = buffer.readUInt32LE(offset + 42);
41
+ const name = buffer.toString('utf8', offset + 46, offset + 46 + nameLength);
42
+ if (name === wanted) {
43
+ const localNameLength = buffer.readUInt16LE(localOffset + 26);
44
+ const localExtraLength = buffer.readUInt16LE(localOffset + 28);
45
+ const start = localOffset + 30 + localNameLength + localExtraLength;
46
+ const raw = buffer.subarray(start, start + compressedSize);
47
+ if (method === 0) return raw;
48
+ if (method === 8) return inflate(raw, true);
49
+ return null;
50
+ }
51
+ offset += 46 + nameLength + extraLength + commentLength;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ const DOCX_ENTITIES = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'" };
57
+
58
+ export function readDocxText(buffer) {
59
+ const document = unzipEntry(buffer, 'word/document.xml');
60
+ if (!document) throw new Error('This file is not a readable .docx (its word/document.xml is missing or compressed in an unsupported way)');
61
+ const xml = document.toString('utf8');
62
+ // Paragraph and line breaks become newlines, tabs become tabs, everything else is text.
63
+ const withBreaks = xml
64
+ .replace(/<w:(?:br|cr)\b[^>]*\/?>/g, '\n')
65
+ .replace(/<\/w:p>/g, '\n')
66
+ .replace(/<w:tab\b[^>]*\/?>/g, '\t');
67
+ const text = withBreaks.replace(/<[^>]+>/g, '');
68
+ return text
69
+ .replace(/&(amp|lt|gt|quot|apos);/g, match => DOCX_ENTITIES[match])
70
+ .replace(/\n{3,}/g, '\n\n')
71
+ .trim();
72
+ }
73
+
74
+ function decodePdfString(raw) {
75
+ // Literal strings arrive with backslash escapes; hex strings are pairs of hex digits.
76
+ return raw
77
+ .replace(/\\([nrtbf()\\])/g, (_match, character) => ({ n: '\n', r: '\r', t: '\t', b: '\b', f: '\f' }[character] ?? character))
78
+ .replace(/\\([0-7]{1,3})/g, (_match, octal) => String.fromCharCode(Number.parseInt(octal, 8)));
79
+ }
80
+
81
+ export function readPdfText(buffer) {
82
+ const raw = buffer.toString('latin1');
83
+ const chunks = [];
84
+ let index = 0;
85
+ while (index < raw.length) {
86
+ const streamStart = raw.indexOf('stream', index);
87
+ if (streamStart < 0) break;
88
+ let start = streamStart + 'stream'.length;
89
+ if (raw[start] === '\r') start += 1;
90
+ if (raw[start] === '\n') start += 1;
91
+ const end = raw.indexOf('endstream', start);
92
+ if (end < 0) break;
93
+ const body = Buffer.from(raw.slice(start, end), 'latin1');
94
+ const decoded = body.subarray(0, 5).toString('latin1') === '<?xml' ? body : (inflate(body) ?? inflate(body, true) ?? body);
95
+ chunks.push(decoded.toString('latin1'));
96
+ index = end + 'endstream'.length;
97
+ }
98
+ const content = chunks.join('\n');
99
+
100
+ const pieces = [];
101
+ const showText = /(?:\((?:\\.|[^\\()])*\)|<[0-9A-Fa-f\s]+>)\s*Tj|\[((?:[^\][]|\\.)*)\]\s*TJ|\((?:\\.|[^\\()])*\)\s*['"]|T\*|Td|TD|ET/g;
102
+ for (const match of content.matchAll(showText)) {
103
+ const token = match[0];
104
+ if (/^T\*|Td|TD|ET$/.test(token)) { pieces.push('\n'); continue; }
105
+ if (token.includes('TJ')) {
106
+ const array = match[1] ?? '';
107
+ for (const part of array.matchAll(/\((?:\\.|[^\\()])*\)|<[0-9A-Fa-f\s]+>/g)) {
108
+ const value = part[0];
109
+ if (value.startsWith('(')) pieces.push(decodePdfString(value.slice(1, -1)));
110
+ else pieces.push(Buffer.from(value.slice(1, -1).replace(/\s+/g, ''), 'hex').toString('latin1').replace(/\0/g, ''));
111
+ }
112
+ continue;
113
+ }
114
+ if (token.startsWith('(')) pieces.push(decodePdfString(token.slice(1, token.lastIndexOf(')'))));
115
+ else if (token.startsWith('<')) pieces.push(Buffer.from(token.slice(1, token.indexOf('>')).replace(/\s+/g, ''), 'hex').toString('latin1').replace(/\0/g, ''));
116
+ }
117
+ const text = pieces.join('').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
118
+ const printable = text.replace(/[^\p{L}\p{N}\p{P}\p{Zs}\n\t]/gu, '');
119
+ if (text.length < 8 || printable.length / Math.max(1, text.length) < 0.7) {
120
+ throw new Error('This PDF has no extractable text — it is a scan, or it uses embedded fonts the built-in reader cannot decode. Run a text extraction tool on that computer (for example pdftotext) and read the result instead.');
121
+ }
122
+ return text;
123
+ }
124
+
125
+ export function documentKind(filePath) {
126
+ const lower = String(filePath).toLowerCase();
127
+ if (lower.endsWith('.docx')) return 'docx';
128
+ if (lower.endsWith('.pdf')) return 'pdf';
129
+ return '';
130
+ }
package/src/sessions.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import process from 'node:process';
2
- import { runtimeConfig } from './config.mjs';
2
+ import { liveConfig, runtimeConfig } from './config.mjs';
3
3
 
4
4
  const processSessions = new Map();
5
5
  const searchSessions = new Map();
@@ -13,7 +13,7 @@ const MAX_PARTIAL_BYTES = 64 * 1024;
13
13
  const MAX_BUFFERED_CHARS = 8 * 1024 * 1024;
14
14
 
15
15
  function trimBuffer(session) {
16
- const overflow = session.lines.length - runtimeConfig.maxBufferedLines;
16
+ const overflow = session.lines.length - liveConfig('maxBufferedLines');
17
17
  if (overflow > 0) {
18
18
  const removed = session.lines.splice(0, overflow);
19
19
  for (const line of removed) session.bufferedChars -= line.length;
package/src/telemetry.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import process from 'node:process';
2
- import { runtimeConfig } from './config.mjs';
2
+ import { liveConfig, runtimeConfig } from './config.mjs';
3
3
  import { VERSION } from './version.mjs';
4
4
 
5
5
  // Event fields are whitelisted: an event can never carry a file path, a command
@@ -20,7 +20,7 @@ const FLUSH_INTERVAL_MS = 15_000;
20
20
  const FLUSH_THRESHOLD = 20;
21
21
 
22
22
  const state = {
23
- enabled: runtimeConfig.telemetryEnabled,
23
+ enabled: liveConfig('telemetryEnabled'),
24
24
  buffer: [],
25
25
  sink: null,
26
26
  timer: null,
@@ -39,8 +39,10 @@ const state = {
39
39
  toolCounts: new Map(),
40
40
  };
41
41
 
42
+ // Live: a runtime setting changed through set_config_value applies to the next call, not the next
43
+ // restart.
42
44
  export function telemetryEnabled() {
43
- return state.enabled;
45
+ return liveConfig('telemetryEnabled');
44
46
  }
45
47
 
46
48
  export function telemetryStatus() {
@@ -97,7 +99,7 @@ export function recordEvent(event, detail = {}) {
97
99
  if (name === 'policy_block') state.counters.policyBlocks += 1;
98
100
  if (name === 'session_started') state.counters.sessionsStarted += 1;
99
101
  if (name === 'write_denied') state.counters.writeDenials += 1;
100
- if (!state.enabled) return;
102
+ if (!telemetryEnabled()) return;
101
103
  if (state.buffer.length >= BUFFER_LIMIT) {
102
104
  state.dropped += 1;
103
105
  return;
@@ -0,0 +1,31 @@
1
+ import { applyLiveConfig, liveConfig, persistConfigValue, runtimeConfig, settableKeys, validateConfigValue } from '../config.mjs';
2
+ import { text } from '../util.mjs';
3
+
4
+ // The one write into this runtime's own configuration, and a deliberately narrow one.
5
+ //
6
+ // Desktop Commander exposes its whole config object to the model, including the directories it may
7
+ // touch and the commands it must refuse. Those two decide what this computer exposes, so they stay
8
+ // with the person at the computer: only the telemetry opt-out and the read/buffer/output limits can be
9
+ // changed here, and the change is written back to runtime.json so it survives a restart.
10
+ export async function setConfigValueTool(args = {}) {
11
+ const key = String(args.key || '').trim();
12
+ const value = validateConfigValue(key, args.value);
13
+ applyLiveConfig(key, value);
14
+ const file = persistConfigValue(key, value);
15
+ return text(JSON.stringify({
16
+ ok: true,
17
+ key,
18
+ value,
19
+ applied: 'immediately',
20
+ savedTo: file,
21
+ note: `Effective now and after a restart. Settable through MCP: ${settableKeys.join(', ')}. Access roots, blocked commands, the command guardrail, the shell and the write limit stay with the person at this computer.`,
22
+ effective: Object.fromEntries(settableKeys.map(name => [name, liveConfig(name)])),
23
+ }, null, 2));
24
+ }
25
+
26
+ export const configToolHandlers = {
27
+ set_config_value: args => setConfigValueTool(args),
28
+ };
29
+
30
+ // Kept so a future tool can report the settable surface without duplicating the list.
31
+ export const configSurface = Object.freeze({ settableKeys, roots: runtimeConfig.allowedRoots });
@@ -5,7 +5,8 @@ import { createHash } from 'node:crypto';
5
5
  import { constants, createReadStream } from 'node:fs';
6
6
  import { access, chmod, chown, copyFile, cp, lstat, mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
7
7
  import { pipeline } from 'node:stream/promises';
8
- import { runtimeConfig } from '../config.mjs';
8
+ import { liveConfig, runtimeConfig } from '../config.mjs';
9
+ import { documentKind, readDocxText, readPdfText } from '../documents.mjs';
9
10
  import { diffStats, unifiedDiff } from '../diff.mjs';
10
11
  import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
11
12
  import { countEvent, recordEvent } from '../telemetry.mjs';
@@ -94,10 +95,29 @@ async function readTextFile(absolute) {
94
95
 
95
96
  export async function readFileTool(args) {
96
97
  const absolute = await resolveSafePath(args.path);
98
+ // Documents first: a .docx or .pdf is not text, and the binary guard below would refuse it.
99
+ const kind = documentKind(absolute);
100
+ if (kind) {
101
+ const info = await stat(absolute);
102
+ assertRegularFile(info, absolute);
103
+ if (info.size > MAX_INLINE_FILE_BYTES) fail(`File is too large to read inline (${info.size} bytes)`);
104
+ const buffer = await readFile(absolute);
105
+ const extracted = kind === 'docx' ? readDocxText(buffer) : readPdfText(buffer);
106
+ const documentLines = splitLines(extracted);
107
+ const offset = Number.isFinite(Number(args.offset)) ? Math.trunc(Number(args.offset)) : 0;
108
+ const length = clampInteger(args.length, liveConfig('maxReadLines'), 1, 10000);
109
+ const page = pageLines(documentLines, offset, length);
110
+ const label = kind === 'docx' ? 'Word document' : 'PDF text';
111
+ const header = documentLines.length
112
+ ? `${displayPath(absolute)} (${label}, lines ${page.start + 1}-${page.end} of ${documentLines.length})`
113
+ : `${displayPath(absolute)} (${label}, no text)`;
114
+ return text(`${header}
115
+ ${page.slice.join('\n')}`);
116
+ }
97
117
  const { content, encoding, eol } = await readTextFile(absolute);
98
118
  const lines = splitLines(content);
99
119
  const offset = Number.isFinite(Number(args.offset)) ? Math.trunc(Number(args.offset)) : 0;
100
- const length = clampInteger(args.length, runtimeConfig.maxReadLines, 1, 10000);
120
+ const length = clampInteger(args.length, liveConfig('maxReadLines'), 1, 10000);
101
121
  const { start, end, slice } = pageLines(lines, offset, length);
102
122
  const notes = `${encoding === 'utf8' ? '' : ` ${encoding}`}${eol === '\r\n' ? ' CRLF' : ''}`;
103
123
  const header = lines.length
@@ -121,7 +141,7 @@ export async function readMultipleFilesTool(args) {
121
141
  try {
122
142
  const { content } = await readTextFile(absolute);
123
143
  const lines = splitLines(content);
124
- const limit = runtimeConfig.maxReadLines;
144
+ const limit = liveConfig('maxReadLines');
125
145
  const slice = lines.slice(0, limit);
126
146
  const suffix = lines.length > limit ? `\n… ${lines.length - limit} more lines truncated` : '';
127
147
  sections.push(`${displayPath(absolute)}:\n${slice.join('\n')}${suffix}`);
@@ -506,7 +526,7 @@ export async function readFilesTool(args) {
506
526
  const root = await resolveSafePath(args.path || '.');
507
527
  const pattern = typeof args.pattern === 'string' && args.pattern.trim() ? args.pattern.trim() : '**/*';
508
528
  const maxFiles = clampInteger(args.max_files, 100, 1, 500);
509
- const maxLinesPerFile = clampInteger(args.max_lines_per_file, runtimeConfig.maxReadLines, 1, 20000);
529
+ const maxLinesPerFile = clampInteger(args.max_lines_per_file, liveConfig('maxReadLines'), 1, 20000);
510
530
  const includeIgnored = args.include_ignored === true;
511
531
  const matcher = globToRegExp(pattern);
512
532
  const rootInfo = await stat(root).catch(() => null);
@@ -1,5 +1,5 @@
1
1
  import process from 'node:process';
2
- import { describeConfig, runtimeConfig } from '../config.mjs';
2
+ import { describeConfig, liveConfig, runtimeConfig } from '../config.mjs';
3
3
  import { dangerousPatternIds } from '../policy.mjs';
4
4
  import { listProcessSessions, listSearchSessions } from '../sessions.mjs';
5
5
  import { telemetryStatus } from '../telemetry.mjs';
@@ -29,9 +29,9 @@ export async function getRuntimeInfoTool() {
29
29
  remoteFeatureFlags: telemetry.remoteFeatureFlags,
30
30
  },
31
31
  limits: {
32
- maxOutputBytes: runtimeConfig.maxOutputBytes,
33
- maxReadLines: runtimeConfig.maxReadLines,
34
- maxBufferedLines: runtimeConfig.maxBufferedLines,
32
+ maxOutputBytes: liveConfig('maxOutputBytes'),
33
+ maxReadLines: liveConfig('maxReadLines'),
34
+ maxBufferedLines: liveConfig('maxBufferedLines'),
35
35
  maxWriteBytes: runtimeConfig.maxWriteBytes,
36
36
  maxConcurrentConnections: 1,
37
37
  },
package/src/util.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
3
  import { realpath } from 'node:fs/promises';
4
- import { runtimeConfig } from './config.mjs';
4
+ import { liveConfig, runtimeConfig } from './config.mjs';
5
5
 
6
6
  export class ToolError extends Error {}
7
7
 
@@ -111,7 +111,7 @@ function frameBytes(text) {
111
111
  }
112
112
 
113
113
  export function truncate(text, maxBytes) {
114
- const limit = maxBytes || runtimeConfig.maxOutputBytes;
114
+ const limit = maxBytes || liveConfig('maxOutputBytes');
115
115
  const value = String(text);
116
116
  if (Buffer.byteLength(value, 'utf8') <= limit && frameBytes(value) <= limit) return value;
117
117
  // Shrink until the escaped frame fits, so the escaped size is what the caller gets is bounded.
@@ -191,7 +191,7 @@ export function pageLines(lines, offset, length) {
191
191
  return { start: total - count, end: total, slice: lines.slice(total - count) };
192
192
  }
193
193
  const start = Math.min(requested, total);
194
- const end = Math.min(start + Math.max(1, Math.trunc(length || runtimeConfig.maxReadLines)), total);
194
+ const end = Math.min(start + Math.max(1, Math.trunc(length || liveConfig('maxReadLines'))), total);
195
195
  return { start, end, slice: lines.slice(start, end) };
196
196
  }
197
197