@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/CHANGELOG.md +23 -0
- package/README.md +96 -41
- package/package.json +3 -2
- package/src/catalog.mjs +341 -11
- package/src/config.mjs +43 -3
- package/src/diff.mjs +86 -0
- package/src/index.mjs +39 -6
- package/src/invoke.mjs +5 -2
- package/src/policy.mjs +71 -20
- package/src/sessions.mjs +58 -10
- package/src/telemetry.mjs +16 -2
- package/src/tools/files.mjs +651 -32
- package/src/tools/search.mjs +12 -5
- package/src/tools/system.mjs +58 -1
- package/src/tools/terminal.mjs +85 -32
- package/src/util.mjs +34 -2
package/src/tools/search.mjs
CHANGED
|
@@ -21,6 +21,9 @@ const MAX_PATTERN_LENGTH = 400;
|
|
|
21
21
|
let ripgrepPath;
|
|
22
22
|
|
|
23
23
|
function ripgrep() {
|
|
24
|
+
// REMCP_RUNTIME_FORCE_FALLBACK=1 exercises the dependency-free scanner even where
|
|
25
|
+
// ripgrep is installed, which is how CI covers both code paths.
|
|
26
|
+
if (process.env.REMCP_RUNTIME_FORCE_FALLBACK === '1') return null;
|
|
24
27
|
if (ripgrepPath !== undefined) return ripgrepPath;
|
|
25
28
|
try {
|
|
26
29
|
const result = spawnSync('rg', ['--version'], { encoding: 'utf8' });
|
|
@@ -29,11 +32,13 @@ function ripgrep() {
|
|
|
29
32
|
return ripgrepPath;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
function normalizePattern(value, literal) {
|
|
35
|
+
function normalizePattern(value, literal, ignoreCase) {
|
|
33
36
|
const pattern = requireString(value, 'pattern');
|
|
34
37
|
if (pattern.length > MAX_PATTERN_LENGTH) fail(`pattern must be at most ${MAX_PATTERN_LENGTH} characters`);
|
|
35
38
|
if (literal) return { regex: null, literal: pattern, patternIsLiteral: true };
|
|
36
|
-
|
|
39
|
+
// The flag has to be baked into the expression: the fallback scanner has no separate
|
|
40
|
+
// case-folding step, so `ignoreCase` used to be silently ignored without ripgrep.
|
|
41
|
+
try { return { regex: new RegExp(pattern, ignoreCase ? 'gi' : 'g'), literal: null, patternIsLiteral: false }; } catch (error) {
|
|
37
42
|
// Silently downgrading an invalid regular expression to a substring search changes the
|
|
38
43
|
// meaning of the call without telling anyone.
|
|
39
44
|
fail(`pattern is not a valid regular expression (${error instanceof Error ? error.message : String(error)}). Pass literalSearch: true to search for this text literally.`);
|
|
@@ -92,7 +97,9 @@ function runRipgrep(session, { path: target, pattern, searchType, filePattern, i
|
|
|
92
97
|
for (const glob of splitGlobs(filePattern)) args.push('-g', glob);
|
|
93
98
|
if (searchType === 'files') {
|
|
94
99
|
args.push('--files');
|
|
95
|
-
|
|
100
|
+
// Globs are case-sensitive even under --ignore-case, so a case-insensitive file
|
|
101
|
+
// search needs --iglob.
|
|
102
|
+
args.push(ignoreCase ? '--iglob' : '-g', fileNameGlob(pattern));
|
|
96
103
|
args.push('--', target);
|
|
97
104
|
} else {
|
|
98
105
|
args.push('--line-number', '--with-filename');
|
|
@@ -180,7 +187,7 @@ export async function startSearchTool(args) {
|
|
|
180
187
|
const includeIgnored = args.includeIgnored === true;
|
|
181
188
|
const contextLines = clampInteger(args.contextLines, 0, 0, 10);
|
|
182
189
|
const maxResults = clampInteger(args.maxResults, 200, 1, 5000);
|
|
183
|
-
const matcher = searchType === 'content' ? normalizePattern(pattern, args.literalSearch === true) : { regex: null, literal: pattern, patternIsLiteral: false };
|
|
190
|
+
const matcher = searchType === 'content' ? normalizePattern(pattern, args.literalSearch === true, ignoreCase) : { regex: null, literal: pattern, patternIsLiteral: false };
|
|
184
191
|
const session = createSearchSession({ type: searchType, pattern, path: target, filePattern });
|
|
185
192
|
countEvent('searchesStarted');
|
|
186
193
|
recordEvent('session_started', { sessionKind: 'search', success: true });
|
|
@@ -189,7 +196,7 @@ export async function startSearchTool(args) {
|
|
|
189
196
|
// only runs when ripgrep is unavailable.
|
|
190
197
|
if (ripgrep()) runRipgrep(session, options);
|
|
191
198
|
else void runFallback(session, options).catch(error => finishSearchSession(session, 'failed', error instanceof Error ? error.message : String(error)));
|
|
192
|
-
await waitForSearchResults(session, 1,
|
|
199
|
+
await waitForSearchResults(session, 1, 800);
|
|
193
200
|
const initial = session.results.slice(0, 50);
|
|
194
201
|
const status = session.error ? `failed: ${session.error}` : session.status;
|
|
195
202
|
return text([
|
package/src/tools/system.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
1
2
|
import process from 'node:process';
|
|
2
3
|
import { execFile } from 'node:child_process';
|
|
3
4
|
import { promisify } from 'node:util';
|
|
5
|
+
import { runtimeConfig } from '../config.mjs';
|
|
4
6
|
import { clampInteger, fail, requireInteger, text } from '../util.mjs';
|
|
5
7
|
|
|
6
8
|
const run = promisify(execFile);
|
|
@@ -12,7 +14,61 @@ export function redactSecrets(command) {
|
|
|
12
14
|
return String(command).replace(SECRET_FLAG, '$1$2***');
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
async function diskUsage() {
|
|
18
|
+
if (process.platform === 'win32') return null;
|
|
19
|
+
try {
|
|
20
|
+
const { stdout } = await run('df', ['-kP', process.cwd()], { maxBuffer: 1024 * 1024 });
|
|
21
|
+
const line = stdout.trim().split('\n')[1];
|
|
22
|
+
if (!line) return null;
|
|
23
|
+
const parts = line.split(/\s+/);
|
|
24
|
+
const sizeKb = Number(parts[1]);
|
|
25
|
+
const usedKb = Number(parts[2]);
|
|
26
|
+
const availableKb = Number(parts[3]);
|
|
27
|
+
if (![sizeKb, usedKb, availableKb].every(Number.isFinite)) return null;
|
|
28
|
+
return {
|
|
29
|
+
mount: parts[5] || null,
|
|
30
|
+
totalBytes: sizeKb * 1024,
|
|
31
|
+
usedBytes: usedKb * 1024,
|
|
32
|
+
availableBytes: availableKb * 1024,
|
|
33
|
+
usedRatio: sizeKb ? Number((usedKb / sizeKb).toFixed(3)) : 0,
|
|
34
|
+
};
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function getSystemInfoTool() {
|
|
41
|
+
const cpus = os.cpus();
|
|
42
|
+
const totalMem = os.totalmem();
|
|
43
|
+
const freeMem = os.freemem();
|
|
44
|
+
const load = os.loadavg();
|
|
45
|
+
return text(JSON.stringify({
|
|
46
|
+
hostname: os.hostname(),
|
|
47
|
+
platform: process.platform,
|
|
48
|
+
release: os.release(),
|
|
49
|
+
arch: process.arch,
|
|
50
|
+
uptimeSeconds: Math.round(os.uptime()),
|
|
51
|
+
node: process.versions.node,
|
|
52
|
+
shell: runtimeConfig.defaultShell || (process.platform === 'win32' ? process.env.ComSpec : process.env.SHELL) || null,
|
|
53
|
+
cpu: {
|
|
54
|
+
model: cpus[0]?.model?.trim() || 'unknown',
|
|
55
|
+
count: cpus.length,
|
|
56
|
+
loadAverage: process.platform === 'win32' ? null : load.map(value => Number(value.toFixed(2))),
|
|
57
|
+
loadPerCore: process.platform === 'win32' || !cpus.length ? null : Number((load[0] / cpus.length).toFixed(2)),
|
|
58
|
+
},
|
|
59
|
+
memory: {
|
|
60
|
+
totalBytes: totalMem,
|
|
61
|
+
freeBytes: freeMem,
|
|
62
|
+
usedRatio: totalMem ? Number(((totalMem - freeMem) / totalMem).toFixed(3)) : 0,
|
|
63
|
+
},
|
|
64
|
+
disk: await diskUsage(),
|
|
65
|
+
home: os.homedir(),
|
|
66
|
+
tempDir: os.tmpdir(),
|
|
67
|
+
runtimeName: runtimeConfig.name,
|
|
68
|
+
}, null, 2));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function listProcessesTool(args) {
|
|
16
72
|
const limit = clampInteger(args.limit, 100, 1, 1000);
|
|
17
73
|
const rows = [];
|
|
18
74
|
const parsed = [];
|
|
@@ -59,6 +115,7 @@ export async function killProcessTool(args) {
|
|
|
59
115
|
}
|
|
60
116
|
|
|
61
117
|
export const systemToolHandlers = {
|
|
118
|
+
get_system_info: getSystemInfoTool,
|
|
62
119
|
list_processes: listProcessesTool,
|
|
63
120
|
kill_process: killProcessTool,
|
|
64
121
|
};
|
package/src/tools/terminal.mjs
CHANGED
|
@@ -2,11 +2,13 @@ import process from 'node:process';
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { runtimeConfig } from '../config.mjs';
|
|
4
4
|
import { assertAllowedCommand } from '../policy.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { recordEvent } from '../telemetry.mjs';
|
|
6
6
|
import {
|
|
7
7
|
appendProcessOutput,
|
|
8
8
|
createProcessSession,
|
|
9
9
|
getProcessSession,
|
|
10
|
+
hasNewOutput,
|
|
11
|
+
killSessionTree,
|
|
10
12
|
listProcessSessions,
|
|
11
13
|
markProcessExited,
|
|
12
14
|
readNewOutput,
|
|
@@ -33,41 +35,66 @@ function describeSession(session) {
|
|
|
33
35
|
return { pid: session.pid, status, runtimeMs: (session.finishedAt || Date.now()) - session.startedAt, lines: totalLines(session) };
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
function abortError() {
|
|
39
|
+
return new Error('Tool call was cancelled by the client');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function throwIfAborted(signal) {
|
|
43
|
+
if (signal?.aborted) throw abortError();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function startProcessTool(args, extra = {}) {
|
|
37
47
|
const command = requireString(args.command, 'command');
|
|
38
48
|
const verdict = assertAllowedCommand(command);
|
|
39
|
-
|
|
40
|
-
countEvent('policyBlocks');
|
|
41
|
-
recordEvent('policy_block', { reason: verdict.findings?.[0]?.id || 'builtin', success: false });
|
|
42
|
-
}
|
|
43
|
-
const timeoutMs = clampInteger(args.timeout_ms, 1000, 0, 120000);
|
|
49
|
+
const timeoutMs = clampInteger(args.timeout_ms, 500, 0, 120000);
|
|
44
50
|
const shell = shellCommand();
|
|
51
|
+
// `detached` gives the child its own process group so a session can be stopped as a
|
|
52
|
+
// tree; a pipeline such as `sleep 20 | cat` otherwise survives force_terminate.
|
|
45
53
|
const child = spawn(shell, shellArgs(command), {
|
|
46
54
|
cwd: process.cwd(),
|
|
47
55
|
env: process.env,
|
|
48
56
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
49
57
|
windowsHide: true,
|
|
58
|
+
detached: process.platform !== 'win32',
|
|
59
|
+
});
|
|
60
|
+
// The error listener has to exist before anything can throw: a bogus REMCP_RUNTIME_SHELL
|
|
61
|
+
// used to surface as an unhandled ENOENT that took the whole runtime down.
|
|
62
|
+
child.on('error', error => {
|
|
63
|
+
const session = child.remcpSession;
|
|
64
|
+
if (session) {
|
|
65
|
+
appendProcessOutput(session, `${error.message}\n`);
|
|
66
|
+
markProcessExited(session, null, null);
|
|
67
|
+
}
|
|
50
68
|
});
|
|
51
|
-
if (!child.pid) fail(
|
|
69
|
+
if (!child.pid) fail(`Could not start the command with ${shell}`);
|
|
52
70
|
const session = createProcessSession({ pid: child.pid, child, command, shell });
|
|
71
|
+
child.remcpSession = session;
|
|
53
72
|
recordEvent('session_started', { sessionKind: 'process', success: true });
|
|
73
|
+
|
|
74
|
+
if (child.stdin) {
|
|
75
|
+
child.stdin.on('error', () => {});
|
|
76
|
+
child.stdin.on('close', () => { session.stdinClosed = true; });
|
|
77
|
+
}
|
|
54
78
|
child.stdout?.on('data', chunk => appendProcessOutput(session, chunk.toString('utf8')));
|
|
55
79
|
child.stderr?.on('data', chunk => appendProcessOutput(session, chunk.toString('utf8')));
|
|
56
|
-
|
|
80
|
+
// `exit` carries the real status; `close` only follows once every stdio stream is done,
|
|
81
|
+
// which for a backgrounded child can be seconds later.
|
|
82
|
+
child.on('exit', (code, signal) => markProcessExited(session, code, signal));
|
|
57
83
|
child.on('close', (code, signal) => markProcessExited(session, code, signal));
|
|
84
|
+
|
|
58
85
|
await waitForProcessExit(session, timeoutMs);
|
|
59
86
|
const headline = session.exited
|
|
60
|
-
? `Process ${session.pid} finished${session.exitCode === null ? '' : ` with code ${session.exitCode}`}.`
|
|
87
|
+
? `Process ${session.pid} finished${session.exitCode === null ? '' : ` with code ${session.exitCode}`}${session.signal ? ` (${session.signal})` : ''}.`
|
|
61
88
|
: `Process ${session.pid} is running.`;
|
|
62
89
|
const output = session.lines.slice(-200).join('\n');
|
|
63
90
|
const partial = session.partial;
|
|
64
91
|
session.cursor = session.droppedLines + session.lines.length;
|
|
65
92
|
session.lastPartialRead = session.partial || null;
|
|
66
|
-
const warning = verdict.
|
|
93
|
+
const warning = verdict.note ? `${verdict.note}\n` : '';
|
|
67
94
|
return text(`${warning}${[headline, output, partial].filter(Boolean).join('\n')}`);
|
|
68
95
|
}
|
|
69
96
|
|
|
70
|
-
export async function readProcessOutputTool(args) {
|
|
97
|
+
export async function readProcessOutputTool(args, extra = {}) {
|
|
71
98
|
const pid = requireInteger(args.pid, 'pid');
|
|
72
99
|
const session = getProcessSession(pid);
|
|
73
100
|
if (!session) fail(`No ReMCP session with pid ${pid}`);
|
|
@@ -76,16 +103,21 @@ export async function readProcessOutputTool(args) {
|
|
|
76
103
|
let slice;
|
|
77
104
|
let range;
|
|
78
105
|
if (hasOffset) {
|
|
79
|
-
// An explicit offset
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
const requested = Number(args.offset);
|
|
84
|
-
const page = readOutputRange(session, requested, clampInteger(args.length, 200, 1, 5000));
|
|
106
|
+
// An explicit offset is a peek at a line range: it does not consume the new-output
|
|
107
|
+
// cursor, so a caller can look at the tail and still read everything afterwards.
|
|
108
|
+
// Zero-based from the first line the session produced; negative reads the last N.
|
|
109
|
+
const page = readOutputRange(session, Number(args.offset), clampInteger(args.length, 200, 1, 5000));
|
|
85
110
|
slice = page.slice;
|
|
86
111
|
range = `${page.start}-${page.end} of ${page.total}`;
|
|
87
112
|
} else {
|
|
88
|
-
|
|
113
|
+
// Never sleep over output that is already buffered.
|
|
114
|
+
if (timeoutMs && !hasNewOutput(session)) {
|
|
115
|
+
const deadline = Date.now() + timeoutMs;
|
|
116
|
+
while (!hasNewOutput(session) && !session.exited && Date.now() < deadline) {
|
|
117
|
+
throwIfAborted(extra.signal);
|
|
118
|
+
await waitForProcessActivity(session, Math.min(200, Math.max(20, deadline - Date.now())));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
89
121
|
slice = readNewOutput(session);
|
|
90
122
|
const first = Math.max(1, session.cursor - slice.length + 1);
|
|
91
123
|
range = slice.length ? `${first}-${session.cursor} of ${totalLines(session)}` : `no new output (${totalLines(session)} lines total)`;
|
|
@@ -105,31 +137,37 @@ function waiterMatches(lines, matcher) {
|
|
|
105
137
|
return lines.some(line => matcher.regex.test(line));
|
|
106
138
|
}
|
|
107
139
|
|
|
108
|
-
export async function waitForProcessOutputTool(args) {
|
|
140
|
+
export async function waitForProcessOutputTool(args, extra = {}) {
|
|
109
141
|
const pid = requireInteger(args.pid, 'pid');
|
|
110
142
|
const session = getProcessSession(pid);
|
|
111
143
|
if (!session) fail(`No ReMCP session with pid ${pid}`);
|
|
112
144
|
const pattern = requireString(args.pattern, 'pattern');
|
|
113
145
|
const matcher = compileWaiter(pattern);
|
|
114
146
|
const timeoutMs = clampInteger(args.timeout_ms, 10000, 0, 120000);
|
|
115
|
-
const
|
|
147
|
+
const cursorStart = Math.max(0, session.cursor - session.droppedLines);
|
|
148
|
+
// Output that was already buffered before the call still counts: a pattern printed
|
|
149
|
+
// earlier should answer immediately instead of spinning for the whole timeout.
|
|
150
|
+
let slice = session.lines.slice(cursorStart);
|
|
151
|
+
const bufferedMatch = waiterMatches(slice, matcher);
|
|
116
152
|
const deadline = Date.now() + timeoutMs;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
153
|
+
if (!bufferedMatch) {
|
|
154
|
+
while (!waiterMatches(session.lines.slice(cursorStart), matcher) && Date.now() < deadline && !session.exited) {
|
|
155
|
+
throwIfAborted(extra.signal);
|
|
156
|
+
await waitForProcessActivity(session, Math.min(250, Math.max(20, deadline - Date.now())));
|
|
157
|
+
}
|
|
158
|
+
slice = session.lines.slice(cursorStart);
|
|
121
159
|
}
|
|
122
160
|
const matched = waiterMatches(slice, matcher);
|
|
123
161
|
session.cursor = session.droppedLines + session.lines.length;
|
|
124
162
|
session.lastPartialRead = session.partial || null;
|
|
125
163
|
const status = describeSession(session);
|
|
126
164
|
const headline = matched
|
|
127
|
-
? `pid ${pid} ${status.status} · pattern matched`
|
|
165
|
+
? `pid ${pid} ${status.status} · pattern matched${bufferedMatch ? ' (already buffered)' : ''}`
|
|
128
166
|
: `pid ${pid} ${status.status} · pattern not matched within ${timeoutMs}ms`;
|
|
129
167
|
return text([headline, slice.join('\n')].filter(Boolean).join('\n'));
|
|
130
168
|
}
|
|
131
169
|
|
|
132
|
-
export async function interactWithProcessTool(args) {
|
|
170
|
+
export async function interactWithProcessTool(args, extra = {}) {
|
|
133
171
|
const pid = requireInteger(args.pid, 'pid');
|
|
134
172
|
const session = getProcessSession(pid);
|
|
135
173
|
if (!session) fail(`No ReMCP session with pid ${pid}`);
|
|
@@ -138,8 +176,23 @@ export async function interactWithProcessTool(args) {
|
|
|
138
176
|
const timeoutMs = clampInteger(args.timeout_ms, 1000, 0, 120000);
|
|
139
177
|
session.cursor = session.droppedLines + session.lines.length;
|
|
140
178
|
session.lastPartialRead = null;
|
|
141
|
-
|
|
142
|
-
|
|
179
|
+
throwIfAborted(extra.signal);
|
|
180
|
+
const stdin = session.child?.stdin;
|
|
181
|
+
if (!stdin || stdin.destroyed || session.stdinClosed) {
|
|
182
|
+
fail(`Process ${pid} no longer accepts input`);
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
stdin.write(`${input}\n`);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
// A closed read end used to raise an async EPIPE that killed the runtime.
|
|
188
|
+
session.stdinClosed = true;
|
|
189
|
+
fail(`Could not write to process ${pid}: ${error instanceof Error ? error.message : String(error)}`);
|
|
190
|
+
}
|
|
191
|
+
const deadline = Date.now() + timeoutMs;
|
|
192
|
+
while (!hasNewOutput(session) && !session.exited && Date.now() < deadline) {
|
|
193
|
+
throwIfAborted(extra.signal);
|
|
194
|
+
await waitForProcessActivity(session, Math.min(200, Math.max(20, deadline - Date.now())));
|
|
195
|
+
}
|
|
143
196
|
const slice = readNewOutput(session);
|
|
144
197
|
const status = describeSession(session);
|
|
145
198
|
return text([`pid ${pid} ${status.status}`, slice.join('\n')].filter(Boolean).join('\n'));
|
|
@@ -150,15 +203,15 @@ export async function forceTerminateTool(args) {
|
|
|
150
203
|
const session = getProcessSession(pid);
|
|
151
204
|
if (!session) fail(`No ReMCP session with pid ${pid}`);
|
|
152
205
|
if (session.exited) return text(`Process ${pid} already exited.`);
|
|
153
|
-
|
|
206
|
+
killSessionTree(session, 'SIGTERM');
|
|
154
207
|
const deadline = Date.now() + 2000;
|
|
155
208
|
while (!session.exited && Date.now() < deadline) await waitForProcessActivity(session, 100);
|
|
156
209
|
if (!session.exited) {
|
|
157
|
-
|
|
210
|
+
killSessionTree(session, 'SIGKILL');
|
|
158
211
|
await waitForProcessActivity(session, 1000);
|
|
159
212
|
}
|
|
160
213
|
const status = describeSession(session);
|
|
161
|
-
return text(`Terminated session ${pid}. Status: ${status.status}.`);
|
|
214
|
+
return text(`Terminated session ${pid}${status.status.startsWith('exited') ? '' : ' (still running)'}. Status: ${status.status}.`);
|
|
162
215
|
}
|
|
163
216
|
|
|
164
217
|
export async function listSessionsTool() {
|
package/src/util.mjs
CHANGED
|
@@ -44,7 +44,11 @@ export function resolveInputPath(value, field = 'path') {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export function isInsideRoot(candidate, root) {
|
|
47
|
-
|
|
47
|
+
if (candidate === root) return true;
|
|
48
|
+
// `allowedRoots: ["/"]` is a legitimate way to say "the whole filesystem"; a naive
|
|
49
|
+
// `root + sep` check turns it into "//" and rejects every path.
|
|
50
|
+
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
|
|
51
|
+
return candidate.startsWith(prefix);
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
function isInsideAnyRoot(candidate) {
|
|
@@ -113,6 +117,14 @@ export function text(value, isError = false) {
|
|
|
113
117
|
return { content: [{ type: 'text', text: truncate(body) }], ...(isError ? { isError: true } : {}) };
|
|
114
118
|
}
|
|
115
119
|
|
|
120
|
+
export function image(data, mimeType) {
|
|
121
|
+
return { type: 'image', data, mimeType };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function multi(parts) {
|
|
125
|
+
return { content: parts };
|
|
126
|
+
}
|
|
127
|
+
|
|
116
128
|
export function splitLines(value) {
|
|
117
129
|
const normalized = String(value).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
118
130
|
if (normalized === '') return [];
|
|
@@ -125,6 +137,23 @@ export function looksBinary(buffer) {
|
|
|
125
137
|
return buffer.subarray(0, 8000).includes(0);
|
|
126
138
|
}
|
|
127
139
|
|
|
140
|
+
// Text decoding that keeps Windows-authored files readable: UTF-16LE/BE with a BOM, and
|
|
141
|
+
// UTF-8 with a BOM, are decoded rather than reported as binary.
|
|
142
|
+
export function decodeText(buffer) {
|
|
143
|
+
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
|
|
144
|
+
return { text: buffer.subarray(2).toString('utf16le'), encoding: 'utf16le' };
|
|
145
|
+
}
|
|
146
|
+
if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
|
|
147
|
+
const swapped = Buffer.from(buffer.subarray(2));
|
|
148
|
+
swapped.swap16();
|
|
149
|
+
return { text: swapped.toString('utf16le'), encoding: 'utf16be' };
|
|
150
|
+
}
|
|
151
|
+
if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
|
|
152
|
+
return { text: buffer.subarray(3).toString('utf8'), encoding: 'utf8bom' };
|
|
153
|
+
}
|
|
154
|
+
return { text: buffer.toString('utf8'), encoding: 'utf8' };
|
|
155
|
+
}
|
|
156
|
+
|
|
128
157
|
export function pageLines(lines, offset, length) {
|
|
129
158
|
const total = lines.length;
|
|
130
159
|
const requested = Math.trunc(offset || 0);
|
|
@@ -138,10 +167,13 @@ export function pageLines(lines, offset, length) {
|
|
|
138
167
|
}
|
|
139
168
|
|
|
140
169
|
export function globToRegExp(pattern) {
|
|
170
|
+
// `**/` may match no directory at all, so `**/*` also matches a file in the root.
|
|
141
171
|
const escaped = String(pattern).replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
172
|
+
.replace(/\*\*\//g, '\u0001')
|
|
142
173
|
.replace(/\*\*/g, '\u0000')
|
|
143
174
|
.replace(/\*/g, '[^/]*')
|
|
144
175
|
.replace(/\?/g, '.')
|
|
145
|
-
.replace(/\u0000/g, '.*')
|
|
176
|
+
.replace(/\u0000/g, '.*')
|
|
177
|
+
.replace(/\u0001/g, '(?:.*/)?');
|
|
146
178
|
return new RegExp(`^${escaped}$`);
|
|
147
179
|
}
|