@remcp/runtime 0.2.20 → 0.2.22

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
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
@@ -26,7 +26,7 @@ export const toolDefinitions = [
26
26
  {
27
27
  name: 'read_file',
28
28
  title: 'Read file',
29
- 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.',
30
30
  inputSchema: {
31
31
  type: 'object',
32
32
  properties: {
@@ -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
+ }
@@ -6,6 +6,7 @@ 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
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,6 +95,25 @@ 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;