starmux 0.11.0 → 0.13.0
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/CONTRIBUTING.md +1 -1
- package/README.md +169 -97
- package/SECURITY.md +3 -3
- package/dist/src/access-control.js +23 -20
- package/dist/src/access-control.js.map +1 -1
- package/dist/src/access-dashboard.js +8 -2
- package/dist/src/access-dashboard.js.map +1 -1
- package/dist/src/cli/instances.js +87 -14
- package/dist/src/cli/instances.js.map +1 -1
- package/dist/src/cli/mac-mcp-gateway.js +4 -3
- package/dist/src/cli/mac-mcp-gateway.js.map +1 -1
- package/dist/src/cli/starmux-status.js +122 -0
- package/dist/src/cli/starmux-status.js.map +1 -0
- package/dist/src/cli/starmux.js +222 -49
- package/dist/src/cli/starmux.js.map +1 -1
- package/dist/src/deny-dashboard.js +2 -2
- package/dist/src/deny-dashboard.js.map +1 -1
- package/dist/src/instance-runtime.js +11 -2
- package/dist/src/instance-runtime.js.map +1 -1
- package/dist/src/instance-store.js +3 -1
- package/dist/src/instance-store.js.map +1 -1
- package/dist/src/ops-dashboard.js +4 -4
- package/dist/src/ops-dashboard.js.map +1 -1
- package/dist/src/permissions-dashboard.js +2 -2
- package/dist/src/permissions-dashboard.js.map +1 -1
- package/dist/src/runtime-paths.js +3 -3
- package/dist/src/runtime-paths.js.map +1 -1
- package/dist/src/starmux-migration.js +1 -1
- package/dist/src/starmux-migration.js.map +1 -1
- package/dist/vendor/desktop-commander/LICENSE +21 -0
- package/dist/vendor/desktop-commander/dist/bootstrap.js +22 -0
- package/dist/vendor/desktop-commander/dist/command-manager.js +246 -0
- package/dist/vendor/desktop-commander/dist/config-field-definitions.js +37 -0
- package/dist/vendor/desktop-commander/dist/config-manager.js +634 -0
- package/dist/vendor/desktop-commander/dist/config-recovery.js +81 -0
- package/dist/vendor/desktop-commander/dist/config.js +19 -0
- package/dist/vendor/desktop-commander/dist/custom-stdio.js +358 -0
- package/dist/vendor/desktop-commander/dist/error-handlers.js +15 -0
- package/dist/vendor/desktop-commander/dist/handlers/edit-search-handlers.js +6 -0
- package/dist/vendor/desktop-commander/dist/handlers/filesystem-handlers.js +450 -0
- package/dist/vendor/desktop-commander/dist/handlers/history-handlers.js +67 -0
- package/dist/vendor/desktop-commander/dist/handlers/index.js +7 -0
- package/dist/vendor/desktop-commander/dist/handlers/process-handlers.js +15 -0
- package/dist/vendor/desktop-commander/dist/handlers/search-answers.js +172 -0
- package/dist/vendor/desktop-commander/dist/handlers/search-handlers.js +136 -0
- package/dist/vendor/desktop-commander/dist/handlers/terminal-handlers.js +35 -0
- package/dist/vendor/desktop-commander/dist/index.js +88 -0
- package/dist/vendor/desktop-commander/dist/search-manager.js +1262 -0
- package/dist/vendor/desktop-commander/dist/server.js +1414 -0
- package/dist/vendor/desktop-commander/dist/terminal-manager.js +757 -0
- package/dist/vendor/desktop-commander/dist/tools/config.js +211 -0
- package/dist/vendor/desktop-commander/dist/tools/edit.js +455 -0
- package/dist/vendor/desktop-commander/dist/tools/feedback.js +10 -0
- package/dist/vendor/desktop-commander/dist/tools/filesystem.js +1047 -0
- package/dist/vendor/desktop-commander/dist/tools/fuzzySearch.js +74 -0
- package/dist/vendor/desktop-commander/dist/tools/fuzzySearchCore.js +125 -0
- package/dist/vendor/desktop-commander/dist/tools/improved-process-tools.js +726 -0
- package/dist/vendor/desktop-commander/dist/tools/mime-types.js +28 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/extract-images.js +131 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/index.js +4 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/lib/pdf2md.js +78 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/manipulations.js +112 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/markdown.js +30 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/utils.js +45 -0
- package/dist/vendor/desktop-commander/dist/tools/process.js +90 -0
- package/dist/vendor/desktop-commander/dist/tools/prompts.js +259 -0
- package/dist/vendor/desktop-commander/dist/tools/schemas.js +242 -0
- package/dist/vendor/desktop-commander/dist/tools/usage.js +24 -0
- package/dist/vendor/desktop-commander/dist/types.js +1 -0
- package/dist/vendor/desktop-commander/dist/ui/config-editor/config-editor-runtime.js +150 -0
- package/dist/vendor/desktop-commander/dist/ui/config-editor/index.html +13 -0
- package/dist/vendor/desktop-commander/dist/ui/config-editor/styles.css +587 -0
- package/dist/vendor/desktop-commander/dist/ui/contracts.js +57 -0
- package/dist/vendor/desktop-commander/dist/ui/file-preview/index.html +16 -0
- package/dist/vendor/desktop-commander/dist/ui/file-preview/preview-runtime.js +471 -0
- package/dist/vendor/desktop-commander/dist/ui/file-preview/shared/preview-file-types.js +59 -0
- package/dist/vendor/desktop-commander/dist/ui/file-preview/styles.css +1631 -0
- package/dist/vendor/desktop-commander/dist/ui/resources.js +93 -0
- package/dist/vendor/desktop-commander/dist/utils/ab-test.js +122 -0
- package/dist/vendor/desktop-commander/dist/utils/atomic-write.js +80 -0
- package/dist/vendor/desktop-commander/dist/utils/capture.js +40 -0
- package/dist/vendor/desktop-commander/dist/utils/dockerPrompt.js +9 -0
- package/dist/vendor/desktop-commander/dist/utils/exit-process.js +31 -0
- package/dist/vendor/desktop-commander/dist/utils/feature-flags.js +18 -0
- package/dist/vendor/desktop-commander/dist/utils/files/base.js +5 -0
- package/dist/vendor/desktop-commander/dist/utils/files/binary.js +65 -0
- package/dist/vendor/desktop-commander/dist/utils/files/docx.js +694 -0
- package/dist/vendor/desktop-commander/dist/utils/files/excel.js +497 -0
- package/dist/vendor/desktop-commander/dist/utils/files/factory.js +168 -0
- package/dist/vendor/desktop-commander/dist/utils/files/image.js +89 -0
- package/dist/vendor/desktop-commander/dist/utils/files/index.js +14 -0
- package/dist/vendor/desktop-commander/dist/utils/files/pdf.js +124 -0
- package/dist/vendor/desktop-commander/dist/utils/files/text.js +423 -0
- package/dist/vendor/desktop-commander/dist/utils/fuzzySearchLogger.js +126 -0
- package/dist/vendor/desktop-commander/dist/utils/internal-facts.js +23 -0
- package/dist/vendor/desktop-commander/dist/utils/lazy-package.js +44 -0
- package/dist/vendor/desktop-commander/dist/utils/lineEndingHandler.js +77 -0
- package/dist/vendor/desktop-commander/dist/utils/logger.js +72 -0
- package/dist/vendor/desktop-commander/dist/utils/mcp-ui-ab-test.js +62 -0
- package/dist/vendor/desktop-commander/dist/utils/open-browser.js +2 -0
- package/dist/vendor/desktop-commander/dist/utils/process-detection.js +260 -0
- package/dist/vendor/desktop-commander/dist/utils/process-tree.js +223 -0
- package/dist/vendor/desktop-commander/dist/utils/rename.js +74 -0
- package/dist/vendor/desktop-commander/dist/utils/ripgrep-resolver.js +69 -0
- package/dist/vendor/desktop-commander/dist/utils/shell.js +187 -0
- package/dist/vendor/desktop-commander/dist/utils/system-info.js +727 -0
- package/dist/vendor/desktop-commander/dist/utils/terminal-timestamps.js +18 -0
- package/dist/vendor/desktop-commander/dist/utils/toolHistory.js +309 -0
- package/dist/vendor/desktop-commander/dist/utils/trackTools.js +55 -0
- package/dist/vendor/desktop-commander/dist/utils/unsupportedParams.js +66 -0
- package/dist/vendor/desktop-commander/dist/utils/usageTracker.js +225 -0
- package/dist/vendor/desktop-commander/dist/utils/welcome-onboarding.js +2 -0
- package/dist/vendor/desktop-commander/dist/utils/withTimeout.js +85 -0
- package/dist/vendor/desktop-commander/dist/version.js +1 -0
- package/dist/vendor/desktop-commander/package.json +15 -0
- package/docs/ACCESS_CONTROL.md +5 -5
- package/docs/CLIENTS.md +11 -19
- package/docs/CONNECT_CHATGPT.md +4 -6
- package/docs/INSTALL.md +9 -5
- package/docs/INSTANCES.md +5 -2
- package/docs/OAUTH.md +6 -14
- package/docs/PLATFORMS.md +4 -4
- package/docs/README.md +2 -11
- package/docs/REMOTE_SETUP.md +13 -13
- package/docs/SECURITY.md +5 -5
- package/docs/assets/starmux-architecture.svg +315 -0
- package/package.json +14 -2
- package/docs/ARCHITECTURE.md +0 -51
- package/docs/MIGRATION.md +0 -37
- package/docs/RELEASE_STATUS.md +0 -31
- package/docs/TYPESCRIPT_MIGRATION.md +0 -56
- package/docs/VALIDATION_OAUTH.md +0 -79
- package/docs/VALIDATION_TUNNEL.md +0 -39
- package/docs/VALIDATION_WORKBUDDY.md +0 -59
- package/docs/assets/mcp-connection-route.svg +0 -104
- package/docs/assets/mcp-gateway-simple.svg +0 -62
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image file handler
|
|
3
|
+
* Handles reading image files and converting to base64
|
|
4
|
+
*/
|
|
5
|
+
import fs from "fs/promises";
|
|
6
|
+
const SVG_MIME_TYPE = 'image/svg+xml';
|
|
7
|
+
/**
|
|
8
|
+
* Whether content of this MIME type is answered as an image: every image type
|
|
9
|
+
* but SVG, which is text (XML), read and written as text. Only the file preview
|
|
10
|
+
* widget draws an SVG as an image (svgAsImage). Files and URLs both decide here.
|
|
11
|
+
*/
|
|
12
|
+
export function isImageAnswer(mimeType, svgAsImage = false) {
|
|
13
|
+
const type = mimeType.toLowerCase().split(';')[0].trim();
|
|
14
|
+
return type.startsWith('image/') && (svgAsImage || type !== SVG_MIME_TYPE);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Image file handler implementation
|
|
18
|
+
* Supports: PNG, JPEG, GIF, WebP, BMP; an SVG only for the file preview widget
|
|
19
|
+
* (isImageAnswer), everyone else reads and writes it as text.
|
|
20
|
+
*/
|
|
21
|
+
export class ImageFileHandler {
|
|
22
|
+
canHandle(path, options) {
|
|
23
|
+
return isImageAnswer(this.getMimeType(path), options?.svgAsImage);
|
|
24
|
+
}
|
|
25
|
+
async read(path, options) {
|
|
26
|
+
// Images are always read in full, ignoring offset and length
|
|
27
|
+
const buffer = await fs.readFile(path, { signal: options?.signal });
|
|
28
|
+
const content = buffer.toString('base64');
|
|
29
|
+
const mimeType = this.getMimeType(path);
|
|
30
|
+
return {
|
|
31
|
+
content,
|
|
32
|
+
mimeType,
|
|
33
|
+
metadata: {
|
|
34
|
+
isImage: true
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async write(path, content, mode) {
|
|
39
|
+
// An image can't take text at its end: writing the content would replace the file
|
|
40
|
+
if (mode === 'append') {
|
|
41
|
+
throw new Error('Image append not supported.');
|
|
42
|
+
}
|
|
43
|
+
// If content is base64 string, convert to buffer
|
|
44
|
+
if (typeof content === 'string') {
|
|
45
|
+
const buffer = Buffer.from(content, 'base64');
|
|
46
|
+
await fs.writeFile(path, buffer);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
await fs.writeFile(path, content);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async getInfo(path) {
|
|
53
|
+
const stats = await fs.stat(path);
|
|
54
|
+
return {
|
|
55
|
+
size: stats.size,
|
|
56
|
+
created: stats.birthtime,
|
|
57
|
+
modified: stats.mtime,
|
|
58
|
+
accessed: stats.atime,
|
|
59
|
+
isDirectory: stats.isDirectory(),
|
|
60
|
+
isFile: stats.isFile(),
|
|
61
|
+
permissions: stats.mode.toString(8).slice(-3),
|
|
62
|
+
fileType: 'image',
|
|
63
|
+
metadata: {
|
|
64
|
+
isImage: true
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Get MIME type for image based on file extension
|
|
70
|
+
*/
|
|
71
|
+
getMimeType(path) {
|
|
72
|
+
const lowerPath = path.toLowerCase();
|
|
73
|
+
for (const [ext, mimeType] of Object.entries(ImageFileHandler.IMAGE_MIME_TYPES)) {
|
|
74
|
+
if (lowerPath.endsWith(ext)) {
|
|
75
|
+
return mimeType;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return 'application/octet-stream'; // Fallback
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
ImageFileHandler.IMAGE_MIME_TYPES = {
|
|
82
|
+
'.png': 'image/png',
|
|
83
|
+
'.jpg': 'image/jpeg',
|
|
84
|
+
'.jpeg': 'image/jpeg',
|
|
85
|
+
'.gif': 'image/gif',
|
|
86
|
+
'.webp': 'image/webp',
|
|
87
|
+
'.bmp': 'image/bmp',
|
|
88
|
+
'.svg': 'image/svg+xml'
|
|
89
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File handling system
|
|
3
|
+
* Exports all file handlers, interfaces, and utilities
|
|
4
|
+
*/
|
|
5
|
+
// Base interfaces and types
|
|
6
|
+
export * from './base.js';
|
|
7
|
+
// Factory function
|
|
8
|
+
export { getFileHandler, isExcelFile, isImageFile, preloadFileSupport, stillLoadingError } from './factory.js';
|
|
9
|
+
// File handlers
|
|
10
|
+
export { TextFileHandler } from './text.js';
|
|
11
|
+
export { ImageFileHandler, isImageAnswer } from './image.js';
|
|
12
|
+
export { BinaryFileHandler } from './binary.js';
|
|
13
|
+
export { ExcelFileHandler, exceljsPackage } from './excel.js';
|
|
14
|
+
export { pizzipPackage } from './docx.js';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF File Handler
|
|
3
|
+
* Implements FileHandler interface for PDF documents
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import { parsePdfToMarkdown, parseMarkdownToPdf, editPdf } from '../../tools/pdf/index.js';
|
|
7
|
+
/**
|
|
8
|
+
* File handler for PDF documents
|
|
9
|
+
* Extracts text and images, supports page-based pagination
|
|
10
|
+
*/
|
|
11
|
+
export class PdfFileHandler {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.extensions = ['.pdf'];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Check if this handler can handle the given file
|
|
17
|
+
*/
|
|
18
|
+
canHandle(path) {
|
|
19
|
+
const ext = path.toLowerCase();
|
|
20
|
+
return this.extensions.some(e => ext.endsWith(e));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Read PDF content - extracts text as markdown with images
|
|
24
|
+
*/
|
|
25
|
+
async read(path, options) {
|
|
26
|
+
const { offset = 0, length } = options ?? {};
|
|
27
|
+
try {
|
|
28
|
+
// Use existing PDF parser
|
|
29
|
+
// With no length, read from offset to the last page (all pages from offset 0)
|
|
30
|
+
// Note: offset defaults to 0 if undefined.
|
|
31
|
+
const pdfResult = await parsePdfToMarkdown(path, { offset, length: length ?? Infinity });
|
|
32
|
+
return {
|
|
33
|
+
content: '', // Main content is in metadata.pages
|
|
34
|
+
mimeType: 'application/pdf',
|
|
35
|
+
metadata: {
|
|
36
|
+
isPdf: true,
|
|
37
|
+
author: pdfResult.metadata.author,
|
|
38
|
+
title: pdfResult.metadata.title,
|
|
39
|
+
totalPages: pdfResult.metadata.totalPages,
|
|
40
|
+
pages: pdfResult.pages
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
46
|
+
return {
|
|
47
|
+
content: `Error reading PDF: ${errorMessage}`,
|
|
48
|
+
mimeType: 'text/plain',
|
|
49
|
+
metadata: {
|
|
50
|
+
error: true,
|
|
51
|
+
errorMessage
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Write PDF - creates from markdown or operations
|
|
58
|
+
*/
|
|
59
|
+
async write(path, content, mode) {
|
|
60
|
+
// A PDF can't take text at its end: rendering the content would replace the file
|
|
61
|
+
if (mode === 'append') {
|
|
62
|
+
throw new Error('PDF append not supported. Use write_pdf to modify existing PDF files.');
|
|
63
|
+
}
|
|
64
|
+
// If content is string, treat as markdown to convert
|
|
65
|
+
if (typeof content === 'string') {
|
|
66
|
+
const pdfBuffer = await parseMarkdownToPdf(content);
|
|
67
|
+
await fs.writeFile(path, pdfBuffer);
|
|
68
|
+
}
|
|
69
|
+
else if (Array.isArray(content)) {
|
|
70
|
+
// Array of operations - use editPdf
|
|
71
|
+
const resultBuffer = await editPdf(path, content);
|
|
72
|
+
await fs.writeFile(path, resultBuffer);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
throw new Error('PDF write requires markdown string or array of operations');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Edit PDF by range/operations
|
|
80
|
+
*/
|
|
81
|
+
async editRange(path, range, content, options) {
|
|
82
|
+
// For PDF, range editing isn't directly supported
|
|
83
|
+
// Could interpret range as page numbers in future
|
|
84
|
+
// The output path and inserted PDFs are checked as write_pdf checks them.
|
|
85
|
+
// Errors reach edit_block (as the Excel handler's do), which answers with them.
|
|
86
|
+
// Imported when used: tools/filesystem.js loads this handler through the file handler factory
|
|
87
|
+
const { validatePdfOperationPaths } = await import('../../tools/filesystem.js');
|
|
88
|
+
const targetPath = await validatePdfOperationPaths(path, content, options?.outputPath);
|
|
89
|
+
const resultBuffer = await editPdf(path, content);
|
|
90
|
+
await fs.writeFile(targetPath, resultBuffer);
|
|
91
|
+
return { success: true, editsApplied: 1 };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Get PDF file information
|
|
95
|
+
*/
|
|
96
|
+
async getInfo(path) {
|
|
97
|
+
const stats = await fs.stat(path);
|
|
98
|
+
// Get basic PDF metadata
|
|
99
|
+
let metadata = { isPdf: true };
|
|
100
|
+
try {
|
|
101
|
+
const pdfResult = await parsePdfToMarkdown(path, { offset: 0, length: 0 }); // Just metadata
|
|
102
|
+
metadata = {
|
|
103
|
+
isPdf: true,
|
|
104
|
+
title: pdfResult.metadata.title,
|
|
105
|
+
author: pdfResult.metadata.author,
|
|
106
|
+
totalPages: pdfResult.metadata.totalPages
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// If we can't parse, just return basic info
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
size: stats.size,
|
|
114
|
+
created: stats.birthtime,
|
|
115
|
+
modified: stats.mtime,
|
|
116
|
+
accessed: stats.atime,
|
|
117
|
+
isDirectory: false,
|
|
118
|
+
isFile: true,
|
|
119
|
+
permissions: (stats.mode & 0o777).toString(8),
|
|
120
|
+
fileType: 'binary',
|
|
121
|
+
metadata
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text file handler
|
|
3
|
+
* Handles reading, writing, and editing text files
|
|
4
|
+
*
|
|
5
|
+
* Binary detection is handled at the factory level (factory.ts) using isBinaryFile.
|
|
6
|
+
* This handler only receives files that have been confirmed as text.
|
|
7
|
+
*
|
|
8
|
+
* TECHNICAL DEBT:
|
|
9
|
+
* This handler is missing editRange() - text search/replace logic currently lives in
|
|
10
|
+
* src/tools/edit.ts (performSearchReplace function) instead of here.
|
|
11
|
+
*
|
|
12
|
+
* For architectural consistency with ExcelFileHandler.editRange(), the fuzzy
|
|
13
|
+
* search/replace logic should be moved here. See comment in src/tools/edit.ts.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "fs/promises";
|
|
16
|
+
import { once } from 'events';
|
|
17
|
+
import { createReadStream } from 'fs';
|
|
18
|
+
import { createInterface } from 'readline';
|
|
19
|
+
// TODO: Centralize these constants with filesystem.ts to avoid silent drift
|
|
20
|
+
// These duplicate concepts from filesystem.ts and should be moved to a shared
|
|
21
|
+
// constants module (e.g., src/utils/files/constants.ts) during reorganization
|
|
22
|
+
const FILE_SIZE_LIMITS = {
|
|
23
|
+
LARGE_FILE_THRESHOLD: 10 * 1024 * 1024, // 10MB
|
|
24
|
+
LINE_COUNT_LIMIT: 10 * 1024 * 1024, // 10MB for line counting
|
|
25
|
+
};
|
|
26
|
+
const READ_PERFORMANCE_THRESHOLDS = {
|
|
27
|
+
SMALL_READ_THRESHOLD: 100, // For very small reads
|
|
28
|
+
DEEP_OFFSET_THRESHOLD: 1000, // Past this line, find where it starts first
|
|
29
|
+
CHUNK_SIZE: 8192, // 8KB chunks for reverse reading
|
|
30
|
+
SCAN_CHUNK_SIZE: 64 * 1024, // 64KB chunks for counting line breaks forward
|
|
31
|
+
};
|
|
32
|
+
const LINE_FEED = 0x0a;
|
|
33
|
+
const CARRIAGE_RETURN = 0x0d;
|
|
34
|
+
/**
|
|
35
|
+
* The line breaks read_file reads by (readline's): LF, CRLF as one break, and
|
|
36
|
+
* a lone CR. True when a line ends between two adjacent code units: the bytes
|
|
37
|
+
* of UTF-8 text (an LF or CR byte is never part of another character) or a
|
|
38
|
+
* string's characters. `after` is -1 at the end of the text.
|
|
39
|
+
*/
|
|
40
|
+
function lineBreakBetween(before, after) {
|
|
41
|
+
return before === LINE_FEED || (before === CARRIAGE_RETURN && after !== LINE_FEED);
|
|
42
|
+
}
|
|
43
|
+
function throwIfAborted(signal) {
|
|
44
|
+
if (signal?.aborted) {
|
|
45
|
+
const err = new Error('Read aborted');
|
|
46
|
+
err.code = 'ABORT_ERR';
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Text file handler implementation
|
|
52
|
+
* Binary detection is done at the factory level - this handler assumes file is text
|
|
53
|
+
*/
|
|
54
|
+
export class TextFileHandler {
|
|
55
|
+
canHandle(_path) {
|
|
56
|
+
// Text handler accepts all files that pass the factory's binary check
|
|
57
|
+
// The factory routes binary files to BinaryFileHandler before reaching here
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
async detectTextEncoding(filePath) {
|
|
61
|
+
const fd = await fs.open(filePath, 'r');
|
|
62
|
+
try {
|
|
63
|
+
const bom = Buffer.alloc(2);
|
|
64
|
+
const { bytesRead } = await fd.read(bom, 0, bom.length, 0);
|
|
65
|
+
if (bytesRead === 2 && bom[0] === 0xff && bom[1] === 0xfe) {
|
|
66
|
+
return 'utf16le';
|
|
67
|
+
}
|
|
68
|
+
return 'utf8';
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
await fd.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async read(filePath, options) {
|
|
75
|
+
const offset = options?.offset ?? 0;
|
|
76
|
+
const length = options?.length ?? 1000; // Default from config
|
|
77
|
+
const includeStatusMessage = options?.includeStatusMessage ?? true;
|
|
78
|
+
// Binary detection is done at factory level - just read as text
|
|
79
|
+
return this.readFileWithSmartPositioning(filePath, offset, length, 'text/plain', includeStatusMessage, options?.signal);
|
|
80
|
+
}
|
|
81
|
+
async write(path, content, mode = 'rewrite') {
|
|
82
|
+
if (mode === 'append') {
|
|
83
|
+
await fs.appendFile(path, content);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
await fs.writeFile(path, content);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async getInfo(path) {
|
|
90
|
+
const stats = await fs.stat(path);
|
|
91
|
+
const info = {
|
|
92
|
+
size: stats.size,
|
|
93
|
+
created: stats.birthtime,
|
|
94
|
+
modified: stats.mtime,
|
|
95
|
+
accessed: stats.atime,
|
|
96
|
+
isDirectory: stats.isDirectory(),
|
|
97
|
+
isFile: stats.isFile(),
|
|
98
|
+
permissions: stats.mode.toString(8).slice(-3),
|
|
99
|
+
fileType: 'text',
|
|
100
|
+
metadata: {}
|
|
101
|
+
};
|
|
102
|
+
// For text files that aren't too large, count lines
|
|
103
|
+
if (stats.isFile() && stats.size < FILE_SIZE_LIMITS.LINE_COUNT_LIMIT) {
|
|
104
|
+
try {
|
|
105
|
+
const encoding = await this.detectTextEncoding(path);
|
|
106
|
+
let content = await fs.readFile(path, encoding);
|
|
107
|
+
if (encoding === 'utf16le' && content.charCodeAt(0) === 0xfeff) {
|
|
108
|
+
content = content.slice(1);
|
|
109
|
+
}
|
|
110
|
+
const lineCount = TextFileHandler.countLines(content);
|
|
111
|
+
info.metadata.lineCount = lineCount;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
// If reading fails, skip line count
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return info;
|
|
118
|
+
}
|
|
119
|
+
// ========================================================================
|
|
120
|
+
// Private Helper Methods (extracted from filesystem.ts)
|
|
121
|
+
// ========================================================================
|
|
122
|
+
/**
|
|
123
|
+
* Count lines in text content the way read_file reads them (lineBreakBetween).
|
|
124
|
+
* A line break at the very end ends the last line; it doesn't start another.
|
|
125
|
+
* Made static and public for use by other modules (e.g., writeFile telemetry in filesystem.ts)
|
|
126
|
+
*/
|
|
127
|
+
static countLines(content) {
|
|
128
|
+
if (content === '')
|
|
129
|
+
return 0;
|
|
130
|
+
let lines = 1;
|
|
131
|
+
for (let i = 1; i < content.length; i++) {
|
|
132
|
+
if (lineBreakBetween(content.charCodeAt(i - 1), content.charCodeAt(i)))
|
|
133
|
+
lines++;
|
|
134
|
+
}
|
|
135
|
+
return lines;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Get file line count (for files under size limit)
|
|
139
|
+
*/
|
|
140
|
+
async getFileLineCount(filePath, signal, encoding = 'utf8') {
|
|
141
|
+
try {
|
|
142
|
+
const stats = await fs.stat(filePath);
|
|
143
|
+
if (stats.size < FILE_SIZE_LIMITS.LINE_COUNT_LIMIT) {
|
|
144
|
+
let content = await fs.readFile(filePath, { encoding, signal });
|
|
145
|
+
if (encoding === 'utf16le' && content.charCodeAt(0) === 0xfeff) {
|
|
146
|
+
content = content.slice(1);
|
|
147
|
+
}
|
|
148
|
+
return TextFileHandler.countLines(content);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
// If we can't read the file, return undefined
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Generate enhanced status message
|
|
158
|
+
*/
|
|
159
|
+
generateEnhancedStatusMessage(readLines, offset, totalLines, isNegativeOffset = false) {
|
|
160
|
+
if (isNegativeOffset) {
|
|
161
|
+
if (totalLines !== undefined) {
|
|
162
|
+
return `[Reading last ${readLines} lines (total: ${totalLines} lines)]`;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
return `[Reading last ${readLines} lines]`;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
if (totalLines !== undefined) {
|
|
170
|
+
const endLine = offset + readLines;
|
|
171
|
+
const remainingLines = Math.max(0, totalLines - endLine);
|
|
172
|
+
if (offset === 0) {
|
|
173
|
+
return `[Reading ${readLines} lines from start (total: ${totalLines} lines, ${remainingLines} remaining)]`;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
return `[Reading ${readLines} lines from line ${offset} (total: ${totalLines} lines, ${remainingLines} remaining)]`;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
if (offset === 0) {
|
|
181
|
+
return `[Reading ${readLines} lines from start]`;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
return `[Reading ${readLines} lines from line ${offset}]`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Split text into lines while preserving line endings
|
|
191
|
+
* Made static and public for use by other modules (e.g., readFileInternal in filesystem.ts)
|
|
192
|
+
*/
|
|
193
|
+
static splitLinesPreservingEndings(content) {
|
|
194
|
+
if (!content)
|
|
195
|
+
return [''];
|
|
196
|
+
const lines = [];
|
|
197
|
+
let currentLine = '';
|
|
198
|
+
for (let i = 0; i < content.length; i++) {
|
|
199
|
+
const char = content[i];
|
|
200
|
+
currentLine += char;
|
|
201
|
+
if (char === '\n') {
|
|
202
|
+
lines.push(currentLine);
|
|
203
|
+
currentLine = '';
|
|
204
|
+
}
|
|
205
|
+
else if (char === '\r') {
|
|
206
|
+
if (i + 1 < content.length && content[i + 1] === '\n') {
|
|
207
|
+
currentLine += content[i + 1];
|
|
208
|
+
i++;
|
|
209
|
+
}
|
|
210
|
+
lines.push(currentLine);
|
|
211
|
+
currentLine = '';
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (currentLine) {
|
|
215
|
+
lines.push(currentLine);
|
|
216
|
+
}
|
|
217
|
+
return lines;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Read file with smart positioning for optimal performance
|
|
221
|
+
*/
|
|
222
|
+
async readFileWithSmartPositioning(filePath, offset, length, mimeType, includeStatusMessage = true, signal) {
|
|
223
|
+
const stats = await fs.stat(filePath);
|
|
224
|
+
const fileSize = stats.size;
|
|
225
|
+
const encoding = await this.detectTextEncoding(filePath);
|
|
226
|
+
const totalLines = await this.getFileLineCount(filePath, signal, encoding);
|
|
227
|
+
// UTF-16LE needs character-aware streaming. Skip the two-byte BOM and keep
|
|
228
|
+
// the same line pagination/status semantics as ordinary text reads.
|
|
229
|
+
if (encoding === 'utf16le') {
|
|
230
|
+
if (offset < 0) {
|
|
231
|
+
return await this.readFromEndWithReadline(filePath, Math.abs(offset), mimeType, includeStatusMessage, totalLines, signal, 'utf16le', 2);
|
|
232
|
+
}
|
|
233
|
+
return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, totalLines, signal, 'utf16le', 2);
|
|
234
|
+
}
|
|
235
|
+
// For negative offsets (tail behavior), use reverse reading
|
|
236
|
+
if (offset < 0) {
|
|
237
|
+
const requestedLines = Math.abs(offset);
|
|
238
|
+
if (fileSize > FILE_SIZE_LIMITS.LARGE_FILE_THRESHOLD &&
|
|
239
|
+
requestedLines <= READ_PERFORMANCE_THRESHOLDS.SMALL_READ_THRESHOLD) {
|
|
240
|
+
return await this.readLastNLinesReverse(filePath, requestedLines, mimeType, includeStatusMessage, totalLines, signal);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
return await this.readFromEndWithReadline(filePath, requestedLines, mimeType, includeStatusMessage, totalLines, signal);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// For positive offsets
|
|
247
|
+
else {
|
|
248
|
+
if (fileSize < FILE_SIZE_LIMITS.LARGE_FILE_THRESHOLD || offset === 0) {
|
|
249
|
+
return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, totalLines, signal);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
if (offset > READ_PERFORMANCE_THRESHOLDS.DEEP_OFFSET_THRESHOLD) {
|
|
253
|
+
return await this.readFromDeepOffset(filePath, offset, length, mimeType, includeStatusMessage, totalLines, signal);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, totalLines, signal);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Read last N lines efficiently: find where they start by reading the file
|
|
263
|
+
* backwards, then read them from there as readline does for smaller files
|
|
264
|
+
*/
|
|
265
|
+
async readLastNLinesReverse(filePath, n, mimeType, includeStatusMessage = true, fileTotalLines, signal) {
|
|
266
|
+
const start = await this.findLastLinesStart(filePath, n, signal);
|
|
267
|
+
return this.readFromEndWithReadline(filePath, n, mimeType, includeStatusMessage, fileTotalLines, signal, undefined, start);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Byte position where the last `n` lines start, found by reading 8 KB chunks
|
|
271
|
+
* backwards and finding line breaks by lineBreakBetween. A line break at the
|
|
272
|
+
* very end ends the last line instead of starting another.
|
|
273
|
+
*/
|
|
274
|
+
async findLastLinesStart(filePath, n, signal) {
|
|
275
|
+
const fd = await fs.open(filePath, 'r');
|
|
276
|
+
try {
|
|
277
|
+
const { size } = await fd.stat();
|
|
278
|
+
const buffer = Buffer.alloc(READ_PERFORMANCE_THRESHOLDS.CHUNK_SIZE);
|
|
279
|
+
let position = size;
|
|
280
|
+
let breaks = 0;
|
|
281
|
+
let after = -1; // the byte after buffer[i]; -1 at the end of the file
|
|
282
|
+
while (position > 0) {
|
|
283
|
+
throwIfAborted(signal);
|
|
284
|
+
const readSize = Math.min(buffer.length, position);
|
|
285
|
+
position -= readSize;
|
|
286
|
+
await fd.read(buffer, 0, readSize, position);
|
|
287
|
+
for (let i = readSize - 1; i >= 0; i--) {
|
|
288
|
+
const byte = buffer[i];
|
|
289
|
+
if (after !== -1 && lineBreakBetween(byte, after) && ++breaks === n) {
|
|
290
|
+
return position + i + 1;
|
|
291
|
+
}
|
|
292
|
+
after = byte;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
await fd.close();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Run `readLines` on a readline over `filePath`, then close the file before
|
|
303
|
+
* returning, whether the read finished, stopped early or failed. rl.close()
|
|
304
|
+
* alone closes the line reader, not the file stream under it, so a read
|
|
305
|
+
* that stopped early kept the file open until the process exited.
|
|
306
|
+
*/
|
|
307
|
+
async withLineReader(filePath, options, readLines) {
|
|
308
|
+
const input = createReadStream(filePath, options);
|
|
309
|
+
// Settles once the file is closed: on 'close', or on 'error', which a
|
|
310
|
+
// file stream emits after closing its file
|
|
311
|
+
const closed = once(input, 'close').catch(() => { });
|
|
312
|
+
const rl = createInterface({
|
|
313
|
+
input,
|
|
314
|
+
crlfDelay: Infinity
|
|
315
|
+
});
|
|
316
|
+
try {
|
|
317
|
+
await readLines(rl);
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
rl.close();
|
|
321
|
+
input.destroy();
|
|
322
|
+
await closed;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Read from end using readline with circular buffer
|
|
327
|
+
*/
|
|
328
|
+
async readFromEndWithReadline(filePath, requestedLines, mimeType, includeStatusMessage = true, fileTotalLines, signal, encoding, start) {
|
|
329
|
+
const buffer = new Array(requestedLines);
|
|
330
|
+
let bufferIndex = 0;
|
|
331
|
+
let totalLines = 0;
|
|
332
|
+
await this.withLineReader(filePath, { signal, encoding, start }, async (rl) => {
|
|
333
|
+
for await (const line of rl) {
|
|
334
|
+
buffer[bufferIndex] = line;
|
|
335
|
+
bufferIndex = (bufferIndex + 1) % requestedLines;
|
|
336
|
+
totalLines++;
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
let result;
|
|
340
|
+
if (totalLines >= requestedLines) {
|
|
341
|
+
result = [
|
|
342
|
+
...buffer.slice(bufferIndex),
|
|
343
|
+
...buffer.slice(0, bufferIndex)
|
|
344
|
+
].filter(line => line !== undefined);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
result = buffer.slice(0, totalLines);
|
|
348
|
+
}
|
|
349
|
+
const content = includeStatusMessage
|
|
350
|
+
? `${this.generateEnhancedStatusMessage(result.length, -requestedLines, fileTotalLines, true)}\n\n${result.join('\n')}`
|
|
351
|
+
: result.join('\n');
|
|
352
|
+
return { content, mimeType, metadata: {} };
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Read from start/middle using readline. With `start`, reading begins at
|
|
356
|
+
* that byte, where line `startLine` begins.
|
|
357
|
+
*/
|
|
358
|
+
async readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage = true, fileTotalLines, signal, encoding, start, startLine = 0) {
|
|
359
|
+
const result = [];
|
|
360
|
+
let lineNumber = startLine;
|
|
361
|
+
await this.withLineReader(filePath, { signal, encoding, start }, async (rl) => {
|
|
362
|
+
for await (const line of rl) {
|
|
363
|
+
if (lineNumber >= offset && result.length < length) {
|
|
364
|
+
result.push(line);
|
|
365
|
+
}
|
|
366
|
+
if (result.length >= length)
|
|
367
|
+
break;
|
|
368
|
+
lineNumber++;
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
if (includeStatusMessage) {
|
|
372
|
+
const statusMessage = this.generateEnhancedStatusMessage(result.length, offset, fileTotalLines, false);
|
|
373
|
+
const content = `${statusMessage}\n\n${result.join('\n')}`;
|
|
374
|
+
return { content, mimeType, metadata: {} };
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
const content = result.join('\n');
|
|
378
|
+
return { content, mimeType, metadata: {} };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Read from a deep offset in a very large file: find the byte where line
|
|
383
|
+
* `offset` starts, then read from there with readline
|
|
384
|
+
*/
|
|
385
|
+
async readFromDeepOffset(filePath, offset, length, mimeType, includeStatusMessage = true, fileTotalLines, signal) {
|
|
386
|
+
const start = await this.findLineStart(filePath, offset, signal);
|
|
387
|
+
return this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, fileTotalLines, signal, undefined, start, offset);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Byte position where line `line` (0-based) starts, or the file size when
|
|
391
|
+
* the file has fewer lines. Counts line breaks by lineBreakBetween in raw
|
|
392
|
+
* bytes, which is much faster than reading lines.
|
|
393
|
+
*/
|
|
394
|
+
async findLineStart(filePath, line, signal) {
|
|
395
|
+
if (line <= 0)
|
|
396
|
+
return 0;
|
|
397
|
+
const fd = await fs.open(filePath, 'r');
|
|
398
|
+
try {
|
|
399
|
+
const buffer = Buffer.alloc(READ_PERFORMANCE_THRESHOLDS.SCAN_CHUNK_SIZE);
|
|
400
|
+
let position = 0;
|
|
401
|
+
let breaks = 0;
|
|
402
|
+
let previous = -1;
|
|
403
|
+
for (;;) {
|
|
404
|
+
throwIfAborted(signal);
|
|
405
|
+
const { bytesRead } = await fd.read(buffer, 0, buffer.length, position);
|
|
406
|
+
if (bytesRead === 0)
|
|
407
|
+
return position;
|
|
408
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
409
|
+
const byte = buffer[i];
|
|
410
|
+
// A line break before this byte: the next line starts here
|
|
411
|
+
if (lineBreakBetween(previous, byte) && ++breaks === line) {
|
|
412
|
+
return position + i;
|
|
413
|
+
}
|
|
414
|
+
previous = byte;
|
|
415
|
+
}
|
|
416
|
+
position += bytesRead;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
await fd.close();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|