@papercraneai/sandbox-agent 0.1.12 → 0.1.14-beta.1
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/dist/dashboard-fs/edit.d.ts +24 -0
- package/dist/dashboard-fs/edit.js +218 -0
- package/dist/dashboard-fs/file-index.d.ts +33 -0
- package/dist/dashboard-fs/file-index.js +102 -0
- package/dist/dashboard-fs/grep.d.ts +25 -0
- package/dist/dashboard-fs/grep.js +106 -0
- package/dist/dashboard-fs/index.d.ts +23 -0
- package/dist/dashboard-fs/index.js +57 -0
- package/dist/dashboard-fs/read.d.ts +28 -0
- package/dist/dashboard-fs/read.js +190 -0
- package/dist/dashboard-fs/write.d.ts +36 -0
- package/dist/dashboard-fs/write.js +179 -0
- package/dist/index.js +125 -7
- package/dist/papercrane-data/index.d.ts +23 -0
- package/dist/papercrane-data/index.js +98 -0
- package/package.json +4 -2
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { FileIndex } from "./file-index.js";
|
|
2
|
+
interface EditFileArgs {
|
|
3
|
+
fileId: unknown;
|
|
4
|
+
oldString: unknown;
|
|
5
|
+
newString: unknown;
|
|
6
|
+
replaceAll?: unknown;
|
|
7
|
+
}
|
|
8
|
+
interface ToolResult {
|
|
9
|
+
content: {
|
|
10
|
+
type: "text";
|
|
11
|
+
text: string;
|
|
12
|
+
}[];
|
|
13
|
+
isError?: boolean;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Edits a file by handle using a string-replace cascade. The fileId must reference
|
|
18
|
+
* a writable entry. Tries replacers in order (exact, line-trimmed, block-anchor)
|
|
19
|
+
* and returns a unified diff of the change.
|
|
20
|
+
*
|
|
21
|
+
* Not in shared-view's `allowedTools` for v1 — registered for future authoring use.
|
|
22
|
+
*/
|
|
23
|
+
export declare function executeEditFile(index: FileIndex, args: EditFileArgs): Promise<ToolResult>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { readFile, writeFile, stat } from "fs/promises";
|
|
2
|
+
import { createTwoFilesPatch } from "diff";
|
|
3
|
+
import { lookup } from "./file-index.js";
|
|
4
|
+
/**
|
|
5
|
+
* Returns the input unchanged. The first and most common case: exact match.
|
|
6
|
+
* Ported from opencode edit.ts (MIT).
|
|
7
|
+
*/
|
|
8
|
+
const SimpleReplacer = function* (_content, find) {
|
|
9
|
+
yield find;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Yields candidate substrings whose lines match the search term modulo
|
|
13
|
+
* leading/trailing whitespace per line. Useful when the model's `oldString`
|
|
14
|
+
* has slightly different indentation than the file. Ported from opencode (MIT).
|
|
15
|
+
*/
|
|
16
|
+
const LineTrimmedReplacer = function* (content, find) {
|
|
17
|
+
const originalLines = content.split("\n");
|
|
18
|
+
const searchLines = find.split("\n");
|
|
19
|
+
if (searchLines[searchLines.length - 1] === "") {
|
|
20
|
+
searchLines.pop();
|
|
21
|
+
}
|
|
22
|
+
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
|
23
|
+
let matches = true;
|
|
24
|
+
for (let j = 0; j < searchLines.length; j++) {
|
|
25
|
+
if (originalLines[i + j].trim() !== searchLines[j].trim()) {
|
|
26
|
+
matches = false;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!matches)
|
|
31
|
+
continue;
|
|
32
|
+
let matchStartIndex = 0;
|
|
33
|
+
for (let k = 0; k < i; k++)
|
|
34
|
+
matchStartIndex += originalLines[k].length + 1;
|
|
35
|
+
let matchEndIndex = matchStartIndex;
|
|
36
|
+
for (let k = 0; k < searchLines.length; k++) {
|
|
37
|
+
matchEndIndex += originalLines[i + k].length;
|
|
38
|
+
if (k < searchLines.length - 1)
|
|
39
|
+
matchEndIndex += 1;
|
|
40
|
+
}
|
|
41
|
+
yield content.substring(matchStartIndex, matchEndIndex);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* For multi-line blocks (>=3 lines), yields candidates anchored on the first
|
|
46
|
+
* and last lines (trimmed match). Useful when the middle of the block has drift
|
|
47
|
+
* but the boundaries are stable. Ported from opencode (MIT).
|
|
48
|
+
*/
|
|
49
|
+
const BlockAnchorReplacer = function* (content, find) {
|
|
50
|
+
const originalLines = content.split("\n");
|
|
51
|
+
const searchLines = find.split("\n");
|
|
52
|
+
if (searchLines.length < 3)
|
|
53
|
+
return;
|
|
54
|
+
if (searchLines[searchLines.length - 1] === "")
|
|
55
|
+
searchLines.pop();
|
|
56
|
+
const firstLineSearch = searchLines[0].trim();
|
|
57
|
+
const lastLineSearch = searchLines[searchLines.length - 1].trim();
|
|
58
|
+
for (let i = 0; i < originalLines.length; i++) {
|
|
59
|
+
if (originalLines[i].trim() !== firstLineSearch)
|
|
60
|
+
continue;
|
|
61
|
+
for (let j = i + 2; j < originalLines.length; j++) {
|
|
62
|
+
if (originalLines[j].trim() !== lastLineSearch)
|
|
63
|
+
continue;
|
|
64
|
+
let matchStartIndex = 0;
|
|
65
|
+
for (let k = 0; k < i; k++)
|
|
66
|
+
matchStartIndex += originalLines[k].length + 1;
|
|
67
|
+
let matchEndIndex = matchStartIndex;
|
|
68
|
+
for (let k = i; k <= j; k++) {
|
|
69
|
+
matchEndIndex += originalLines[k].length;
|
|
70
|
+
if (k < j)
|
|
71
|
+
matchEndIndex += 1;
|
|
72
|
+
}
|
|
73
|
+
yield content.substring(matchStartIndex, matchEndIndex);
|
|
74
|
+
break; // only first match for this anchor pair
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Avoids `$` sequence issues in the replacement string. JavaScript's
|
|
80
|
+
* `String.prototype.replaceAll` interprets `$&`, `$1`, etc., in the
|
|
81
|
+
* replacement; this routes through a function replacement which is treated
|
|
82
|
+
* as a literal. Ported from gemini-cli edit.ts (Apache 2.0).
|
|
83
|
+
*/
|
|
84
|
+
function safeLiteralReplace(content, search, replacement) {
|
|
85
|
+
// For non-pattern (string) inputs, replaceAll's first-arg-as-string mode
|
|
86
|
+
// does NOT interpret `$&` — only the replacement string does. Use the
|
|
87
|
+
// function form to bypass.
|
|
88
|
+
return content.split(search).join(replacement);
|
|
89
|
+
}
|
|
90
|
+
function replace(content, oldString, newString, replaceAll = false) {
|
|
91
|
+
if (oldString === newString) {
|
|
92
|
+
return { error: "No changes to apply: oldString and newString are identical." };
|
|
93
|
+
}
|
|
94
|
+
const replacers = [
|
|
95
|
+
{ name: "SimpleReplacer", fn: SimpleReplacer },
|
|
96
|
+
{ name: "LineTrimmedReplacer", fn: LineTrimmedReplacer },
|
|
97
|
+
{ name: "BlockAnchorReplacer", fn: BlockAnchorReplacer }
|
|
98
|
+
];
|
|
99
|
+
let foundButAmbiguous = false;
|
|
100
|
+
for (const { name, fn } of replacers) {
|
|
101
|
+
for (const candidate of fn(content, oldString)) {
|
|
102
|
+
const index = content.indexOf(candidate);
|
|
103
|
+
if (index === -1)
|
|
104
|
+
continue;
|
|
105
|
+
if (replaceAll) {
|
|
106
|
+
return {
|
|
107
|
+
content: safeLiteralReplace(content, candidate, newString),
|
|
108
|
+
replacer: name
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const lastIndex = content.lastIndexOf(candidate);
|
|
112
|
+
if (index !== lastIndex) {
|
|
113
|
+
// Multiple matches; this replacer can't make a unique replacement.
|
|
114
|
+
// Try the next replacer in case it yields a more specific candidate.
|
|
115
|
+
foundButAmbiguous = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
content: content.substring(0, index) + newString + content.substring(index + candidate.length),
|
|
120
|
+
replacer: name
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (foundButAmbiguous) {
|
|
125
|
+
return { error: "Found multiple matches for oldString. Provide more surrounding context to make the match unique, or set replaceAll: true." };
|
|
126
|
+
}
|
|
127
|
+
return { error: "Could not find oldString in the file. It must match exactly, including whitespace and indentation. Try replacing a smaller, more distinctive snippet." };
|
|
128
|
+
}
|
|
129
|
+
// Per-file lock so concurrent EditFile calls on the same file serialize.
|
|
130
|
+
// Ported in spirit from opencode's Semaphore-based locks (MIT).
|
|
131
|
+
const locks = new Map();
|
|
132
|
+
async function withFileLock(absolutePath, fn) {
|
|
133
|
+
const previous = locks.get(absolutePath) ?? Promise.resolve();
|
|
134
|
+
let release = () => { };
|
|
135
|
+
const next = new Promise((resolve) => { release = resolve; });
|
|
136
|
+
locks.set(absolutePath, previous.then(() => next));
|
|
137
|
+
await previous;
|
|
138
|
+
try {
|
|
139
|
+
return await fn();
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
release();
|
|
143
|
+
if (locks.get(absolutePath) === previous.then(() => next)) {
|
|
144
|
+
locks.delete(absolutePath);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Edits a file by handle using a string-replace cascade. The fileId must reference
|
|
150
|
+
* a writable entry. Tries replacers in order (exact, line-trimmed, block-anchor)
|
|
151
|
+
* and returns a unified diff of the change.
|
|
152
|
+
*
|
|
153
|
+
* Not in shared-view's `allowedTools` for v1 — registered for future authoring use.
|
|
154
|
+
*/
|
|
155
|
+
export async function executeEditFile(index, args) {
|
|
156
|
+
const entry = lookup(index, args.fileId);
|
|
157
|
+
if (!entry) {
|
|
158
|
+
return {
|
|
159
|
+
content: [{ type: "text", text: `Error: unknown fileId. Use ListFiles to discover available files.` }],
|
|
160
|
+
isError: true
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (!entry.writable) {
|
|
164
|
+
return {
|
|
165
|
+
content: [{ type: "text", text: `Error: file ${entry.relativePath} is not writable in this session.` }],
|
|
166
|
+
isError: true
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (typeof args.oldString !== "string" || typeof args.newString !== "string") {
|
|
170
|
+
return {
|
|
171
|
+
content: [{ type: "text", text: `Error: oldString and newString must be strings` }],
|
|
172
|
+
isError: true
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const replaceAll = args.replaceAll === true;
|
|
176
|
+
return withFileLock(entry.absolutePath, async () => {
|
|
177
|
+
let original;
|
|
178
|
+
try {
|
|
179
|
+
original = await readFile(entry.absolutePath, "utf-8");
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
return {
|
|
183
|
+
content: [{ type: "text", text: `Error reading file: ${err instanceof Error ? err.message : String(err)}` }],
|
|
184
|
+
isError: true
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const result = replace(original, args.oldString, args.newString, replaceAll);
|
|
188
|
+
if ("error" in result) {
|
|
189
|
+
return {
|
|
190
|
+
content: [{ type: "text", text: `Error: ${result.error}` }],
|
|
191
|
+
isError: true
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
await writeFile(entry.absolutePath, result.content, "utf-8");
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
return {
|
|
199
|
+
content: [{ type: "text", text: `Error writing file: ${err instanceof Error ? err.message : String(err)}` }],
|
|
200
|
+
isError: true
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const s = await stat(entry.absolutePath);
|
|
205
|
+
entry.size = s.size;
|
|
206
|
+
}
|
|
207
|
+
catch { /* non-fatal */ }
|
|
208
|
+
const diff = createTwoFilesPatch(entry.relativePath, entry.relativePath, original, result.content, "before", "after");
|
|
209
|
+
const trimmedDiff = diff.replace(/^Index: .*\n=+\n/m, "");
|
|
210
|
+
const matchedSuffix = result.replacer === "SimpleReplacer" ? "" : ` (matched via ${result.replacer})`;
|
|
211
|
+
return {
|
|
212
|
+
content: [{
|
|
213
|
+
type: "text",
|
|
214
|
+
text: `Edited ${entry.relativePath}${matchedSuffix}\n\n${trimmedDiff}`
|
|
215
|
+
}]
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface FileIndexEntry {
|
|
2
|
+
absolutePath: string;
|
|
3
|
+
relativePath: string;
|
|
4
|
+
writable: boolean;
|
|
5
|
+
size: number;
|
|
6
|
+
}
|
|
7
|
+
export type FileIndex = Map<string, FileIndexEntry>;
|
|
8
|
+
interface BuildOptions {
|
|
9
|
+
dashboardRoot: string;
|
|
10
|
+
queryFiles?: string[];
|
|
11
|
+
skip?: Set<string>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Builds the read/write index for a shared-view chat session.
|
|
15
|
+
*
|
|
16
|
+
* The index is the *only* way handle-based tools can address files. The agent
|
|
17
|
+
* never receives or constructs a path string. There is no `..`, no symlink
|
|
18
|
+
* resolution, no case-folding to get wrong — handles either resolve to an
|
|
19
|
+
* absolute path the index already chose, or they don't.
|
|
20
|
+
*
|
|
21
|
+
* @param options.dashboardRoot Absolute path to the dashboard folder. Walked recursively.
|
|
22
|
+
* @param options.queryFiles Optional list of absolute paths to additional files
|
|
23
|
+
* (e.g., shared query files referenced from the dashboard).
|
|
24
|
+
* Their relative path is resolved against dashboardRoot when
|
|
25
|
+
* they live underneath it; otherwise the absolute path is used.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildFileIndex(options: BuildOptions): Promise<FileIndex>;
|
|
28
|
+
/**
|
|
29
|
+
* Looks up a file id in the index. Returns the entry or undefined.
|
|
30
|
+
* Tools call this rather than ever taking a path argument from the caller.
|
|
31
|
+
*/
|
|
32
|
+
export declare function lookup(index: FileIndex, fileId: unknown): FileIndexEntry | undefined;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readdir, stat } from "fs/promises";
|
|
2
|
+
import { join, relative, isAbsolute } from "path";
|
|
3
|
+
import { randomBytes } from "crypto";
|
|
4
|
+
const DEFAULT_SKIP = new Set([
|
|
5
|
+
"node_modules",
|
|
6
|
+
".next",
|
|
7
|
+
".turbo",
|
|
8
|
+
".git",
|
|
9
|
+
".cache",
|
|
10
|
+
"dist",
|
|
11
|
+
"build",
|
|
12
|
+
".DS_Store"
|
|
13
|
+
]);
|
|
14
|
+
function newFileId() {
|
|
15
|
+
return `f_${randomBytes(8).toString("hex")}`;
|
|
16
|
+
}
|
|
17
|
+
async function walk(rootAbs, current, skip, out) {
|
|
18
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
if (skip.has(entry.name) || entry.name.startsWith("."))
|
|
21
|
+
continue;
|
|
22
|
+
const entryAbs = join(current, entry.name);
|
|
23
|
+
if (entry.isSymbolicLink()) {
|
|
24
|
+
// Symlinks are not followed. Anything we want in scope must be explicitly
|
|
25
|
+
// present as a regular file or directory under the dashboard root.
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
await walk(rootAbs, entryAbs, skip, out);
|
|
30
|
+
}
|
|
31
|
+
else if (entry.isFile()) {
|
|
32
|
+
const s = await stat(entryAbs);
|
|
33
|
+
out.set(newFileId(), {
|
|
34
|
+
absolutePath: entryAbs,
|
|
35
|
+
relativePath: relative(rootAbs, entryAbs),
|
|
36
|
+
writable: true,
|
|
37
|
+
size: s.size
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Builds the read/write index for a shared-view chat session.
|
|
44
|
+
*
|
|
45
|
+
* The index is the *only* way handle-based tools can address files. The agent
|
|
46
|
+
* never receives or constructs a path string. There is no `..`, no symlink
|
|
47
|
+
* resolution, no case-folding to get wrong — handles either resolve to an
|
|
48
|
+
* absolute path the index already chose, or they don't.
|
|
49
|
+
*
|
|
50
|
+
* @param options.dashboardRoot Absolute path to the dashboard folder. Walked recursively.
|
|
51
|
+
* @param options.queryFiles Optional list of absolute paths to additional files
|
|
52
|
+
* (e.g., shared query files referenced from the dashboard).
|
|
53
|
+
* Their relative path is resolved against dashboardRoot when
|
|
54
|
+
* they live underneath it; otherwise the absolute path is used.
|
|
55
|
+
*/
|
|
56
|
+
export async function buildFileIndex(options) {
|
|
57
|
+
const { dashboardRoot, queryFiles = [], skip = DEFAULT_SKIP } = options;
|
|
58
|
+
if (!isAbsolute(dashboardRoot)) {
|
|
59
|
+
throw new Error(`buildFileIndex: dashboardRoot must be absolute, got: ${dashboardRoot}`);
|
|
60
|
+
}
|
|
61
|
+
const rootStat = await stat(dashboardRoot);
|
|
62
|
+
if (!rootStat.isDirectory()) {
|
|
63
|
+
throw new Error(`buildFileIndex: dashboardRoot is not a directory: ${dashboardRoot}`);
|
|
64
|
+
}
|
|
65
|
+
const index = new Map();
|
|
66
|
+
await walk(dashboardRoot, dashboardRoot, skip, index);
|
|
67
|
+
for (const qf of queryFiles) {
|
|
68
|
+
if (!isAbsolute(qf)) {
|
|
69
|
+
throw new Error(`buildFileIndex: queryFiles entries must be absolute, got: ${qf}`);
|
|
70
|
+
}
|
|
71
|
+
// Skip if this query file is already inside the dashboard root (already walked).
|
|
72
|
+
const indexed = [...index.values()].some((e) => e.absolutePath === qf);
|
|
73
|
+
if (indexed)
|
|
74
|
+
continue;
|
|
75
|
+
let s;
|
|
76
|
+
try {
|
|
77
|
+
s = await stat(qf);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!s.isFile())
|
|
83
|
+
continue;
|
|
84
|
+
const rel = qf.startsWith(dashboardRoot) ? relative(dashboardRoot, qf) : qf;
|
|
85
|
+
index.set(newFileId(), {
|
|
86
|
+
absolutePath: qf,
|
|
87
|
+
relativePath: rel,
|
|
88
|
+
writable: true,
|
|
89
|
+
size: s.size
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return index;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Looks up a file id in the index. Returns the entry or undefined.
|
|
96
|
+
* Tools call this rather than ever taking a path argument from the caller.
|
|
97
|
+
*/
|
|
98
|
+
export function lookup(index, fileId) {
|
|
99
|
+
if (typeof fileId !== "string")
|
|
100
|
+
return undefined;
|
|
101
|
+
return index.get(fileId);
|
|
102
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { FileIndex } from "./file-index.js";
|
|
2
|
+
interface GrepArgs {
|
|
3
|
+
pattern: unknown;
|
|
4
|
+
}
|
|
5
|
+
interface ToolResult {
|
|
6
|
+
content: {
|
|
7
|
+
type: "text";
|
|
8
|
+
text: string;
|
|
9
|
+
}[];
|
|
10
|
+
isError?: boolean;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Runs ripgrep restricted to the absolute paths in the file index.
|
|
15
|
+
*
|
|
16
|
+
* The pattern is passed as an argv argument to ripgrep, not interpolated into
|
|
17
|
+
* a shell — so shell metacharacters in the pattern are safe. The file list is
|
|
18
|
+
* piped via stdin so very large indexes don't blow the command-line length.
|
|
19
|
+
*
|
|
20
|
+
* Output is capped at MAX_MATCHES results and lines longer than MAX_LINE_LENGTH
|
|
21
|
+
* are truncated. A 5s wall-clock timeout caps catastrophic-regex CPU per the
|
|
22
|
+
* implementation-time hardening checklist (M4 in the design doc).
|
|
23
|
+
*/
|
|
24
|
+
export declare function executeGrepFiles(index: FileIndex, args: GrepArgs): Promise<ToolResult>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
const MAX_MATCHES = 100;
|
|
3
|
+
const MAX_LINE_LENGTH = 2000;
|
|
4
|
+
const RIPGREP_TIMEOUT_MS = 5_000;
|
|
5
|
+
/**
|
|
6
|
+
* Runs ripgrep restricted to the absolute paths in the file index.
|
|
7
|
+
*
|
|
8
|
+
* The pattern is passed as an argv argument to ripgrep, not interpolated into
|
|
9
|
+
* a shell — so shell metacharacters in the pattern are safe. The file list is
|
|
10
|
+
* piped via stdin so very large indexes don't blow the command-line length.
|
|
11
|
+
*
|
|
12
|
+
* Output is capped at MAX_MATCHES results and lines longer than MAX_LINE_LENGTH
|
|
13
|
+
* are truncated. A 5s wall-clock timeout caps catastrophic-regex CPU per the
|
|
14
|
+
* implementation-time hardening checklist (M4 in the design doc).
|
|
15
|
+
*/
|
|
16
|
+
export async function executeGrepFiles(index, args) {
|
|
17
|
+
const pattern = args.pattern;
|
|
18
|
+
if (typeof pattern !== "string" || pattern.length === 0) {
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: "text", text: `Error: pattern must be a non-empty string` }],
|
|
21
|
+
isError: true
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const filePaths = [...index.values()].map((e) => e.absolutePath);
|
|
25
|
+
if (filePaths.length === 0) {
|
|
26
|
+
return {
|
|
27
|
+
content: [{ type: "text", text: `(no files in index to search)` }]
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const child = spawn("rg", [
|
|
31
|
+
"--no-config",
|
|
32
|
+
"--line-number",
|
|
33
|
+
"--no-heading",
|
|
34
|
+
"--color=never",
|
|
35
|
+
"--max-count", "10", // up to 10 matches per file before moving on
|
|
36
|
+
"--max-columns", String(MAX_LINE_LENGTH),
|
|
37
|
+
"--max-columns-preview",
|
|
38
|
+
"--files-from", "-",
|
|
39
|
+
"--regexp", pattern
|
|
40
|
+
], { stdio: ["pipe", "pipe", "pipe"] });
|
|
41
|
+
// Pipe the file list to ripgrep on stdin
|
|
42
|
+
child.stdin.write(filePaths.join("\n"));
|
|
43
|
+
child.stdin.end();
|
|
44
|
+
const stdoutChunks = [];
|
|
45
|
+
const stderrChunks = [];
|
|
46
|
+
child.stdout.on("data", (c) => stdoutChunks.push(c));
|
|
47
|
+
child.stderr.on("data", (c) => stderrChunks.push(c));
|
|
48
|
+
const timeout = setTimeout(() => {
|
|
49
|
+
child.kill("SIGKILL");
|
|
50
|
+
}, RIPGREP_TIMEOUT_MS);
|
|
51
|
+
let killed = false;
|
|
52
|
+
child.on("close", (_code, signal) => {
|
|
53
|
+
if (signal === "SIGKILL")
|
|
54
|
+
killed = true;
|
|
55
|
+
});
|
|
56
|
+
const exitCode = await new Promise((resolve) => {
|
|
57
|
+
child.on("close", (code) => resolve(code));
|
|
58
|
+
});
|
|
59
|
+
clearTimeout(timeout);
|
|
60
|
+
if (killed) {
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: "text", text: `Error: search timed out after ${RIPGREP_TIMEOUT_MS / 1000}s. Try a more specific pattern.` }],
|
|
63
|
+
isError: true
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// ripgrep exits 0 when matches found, 1 when no matches, 2+ on errors.
|
|
67
|
+
if (exitCode === 1) {
|
|
68
|
+
return {
|
|
69
|
+
content: [{ type: "text", text: `(no matches for ${JSON.stringify(pattern)})` }]
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (exitCode !== 0) {
|
|
73
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf-8");
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: "text", text: `Error running ripgrep (exit ${exitCode}): ${stderr.slice(0, 500)}` }],
|
|
76
|
+
isError: true
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
|
|
80
|
+
const lines = stdout.split("\n").filter((l) => l.length > 0);
|
|
81
|
+
const truncated = lines.length > MAX_MATCHES;
|
|
82
|
+
const shown = truncated ? lines.slice(0, MAX_MATCHES) : lines;
|
|
83
|
+
// ripgrep emits absolute paths since we passed absolute paths in. Rewrite
|
|
84
|
+
// them to relative paths via the index so the agent never sees the absolute
|
|
85
|
+
// form. (No security guarantee — just consistency with the read formatter.)
|
|
86
|
+
const absToRel = new Map();
|
|
87
|
+
for (const entry of index.values())
|
|
88
|
+
absToRel.set(entry.absolutePath, entry.relativePath);
|
|
89
|
+
const rewritten = shown.map((line) => {
|
|
90
|
+
const colon = line.indexOf(":");
|
|
91
|
+
if (colon === -1)
|
|
92
|
+
return line;
|
|
93
|
+
const path = line.slice(0, colon);
|
|
94
|
+
const rel = absToRel.get(path);
|
|
95
|
+
return rel ? `${rel}${line.slice(colon)}` : line;
|
|
96
|
+
});
|
|
97
|
+
const footer = truncated
|
|
98
|
+
? `\n\n(Showing ${MAX_MATCHES} of ${lines.length} matches. Refine the pattern to narrow results.)`
|
|
99
|
+
: `\n\n(${lines.length} match${lines.length === 1 ? "" : "es"})`;
|
|
100
|
+
return {
|
|
101
|
+
content: [{
|
|
102
|
+
type: "text",
|
|
103
|
+
text: rewritten.join("\n") + footer
|
|
104
|
+
}]
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { FileIndex } from "./file-index.js";
|
|
2
|
+
interface CreateOptions {
|
|
3
|
+
dashboardRoot: string;
|
|
4
|
+
queryFiles?: string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Builds the dashboard-fs MCP server for a chat session. Walks the dashboard
|
|
8
|
+
* folder once to construct a handle-based file index; all subsequent reads,
|
|
9
|
+
* writes, edits, and grep calls resolve `fileId` against that index. The agent
|
|
10
|
+
* never sees or constructs a path string — there is no `..`, no symlink to
|
|
11
|
+
* resolve, no case to fold.
|
|
12
|
+
*
|
|
13
|
+
* Registers all six tools (ListFiles, ReadFile, GrepFiles, WriteFile, EditFile,
|
|
14
|
+
* CreateFile). Per the design plan, shared-view chat sessions allowlist only the
|
|
15
|
+
* read-only subset (List, Read, Grep) via `allowedTools`. The write-set is
|
|
16
|
+
* available for future use cases.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createDashboardFsServer(options: CreateOptions): Promise<{
|
|
19
|
+
server: import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
|
|
20
|
+
index: FileIndex;
|
|
21
|
+
listFileIds: () => string[];
|
|
22
|
+
}>;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
+
import { buildFileIndex } from "./file-index.js";
|
|
4
|
+
import { executeListFiles, executeReadFile } from "./read.js";
|
|
5
|
+
import { executeGrepFiles } from "./grep.js";
|
|
6
|
+
import { executeWriteFile, executeCreateFile } from "./write.js";
|
|
7
|
+
import { executeEditFile } from "./edit.js";
|
|
8
|
+
/**
|
|
9
|
+
* Builds the dashboard-fs MCP server for a chat session. Walks the dashboard
|
|
10
|
+
* folder once to construct a handle-based file index; all subsequent reads,
|
|
11
|
+
* writes, edits, and grep calls resolve `fileId` against that index. The agent
|
|
12
|
+
* never sees or constructs a path string — there is no `..`, no symlink to
|
|
13
|
+
* resolve, no case to fold.
|
|
14
|
+
*
|
|
15
|
+
* Registers all six tools (ListFiles, ReadFile, GrepFiles, WriteFile, EditFile,
|
|
16
|
+
* CreateFile). Per the design plan, shared-view chat sessions allowlist only the
|
|
17
|
+
* read-only subset (List, Read, Grep) via `allowedTools`. The write-set is
|
|
18
|
+
* available for future use cases.
|
|
19
|
+
*/
|
|
20
|
+
export async function createDashboardFsServer(options) {
|
|
21
|
+
const index = await buildFileIndex({
|
|
22
|
+
dashboardRoot: options.dashboardRoot,
|
|
23
|
+
queryFiles: options.queryFiles
|
|
24
|
+
});
|
|
25
|
+
const listFiles = tool("ListFiles", "Lists every file the agent can read in this session, with a stable fileId for each. Call this first to discover what's available. Returns relativePath, size, writable, and the fileId you'll pass to ReadFile, WriteFile, EditFile, and GrepFiles.", {}, async () => executeListFiles(index));
|
|
26
|
+
const readFile = tool("ReadFile", "Reads a file by its fileId (from ListFiles). Returns line-numbered content. Use offset/limit to page through large files.", {
|
|
27
|
+
fileId: z.string().describe("The opaque file handle from ListFiles"),
|
|
28
|
+
offset: z.number().int().min(1).optional().describe("Line number to start reading from (1-indexed). Default: 1."),
|
|
29
|
+
limit: z.number().int().min(1).optional().describe("Maximum lines to read. Default: 2000.")
|
|
30
|
+
}, async (args) => executeReadFile(index, args));
|
|
31
|
+
const grepFiles = tool("GrepFiles", "Searches for a pattern across every file in this session's index using ripgrep. Returns matches as path:line:content. Pattern is a regex. Capped at 100 matches.", {
|
|
32
|
+
pattern: z.string().describe("The regex pattern to search for")
|
|
33
|
+
}, async (args) => executeGrepFiles(index, args));
|
|
34
|
+
const writeFile = tool("WriteFile", "Overwrites a file's content by fileId. Preserves the original line-ending style. Returns a unified diff of the change. The file must already exist in the index and be writable.", {
|
|
35
|
+
fileId: z.string().describe("The opaque file handle from ListFiles"),
|
|
36
|
+
content: z.string().describe("The new full content of the file")
|
|
37
|
+
}, async (args) => executeWriteFile(index, args));
|
|
38
|
+
const editFile = tool("EditFile", "Replaces a substring in a file by fileId. Tries exact match first, then line-trimmed and block-anchor matching to absorb minor whitespace drift. Set replaceAll: true to replace every occurrence.", {
|
|
39
|
+
fileId: z.string().describe("The opaque file handle from ListFiles"),
|
|
40
|
+
oldString: z.string().describe("The text to replace. Must match the file content (exact, line-trimmed, or block-anchored)."),
|
|
41
|
+
newString: z.string().describe("The text to replace it with"),
|
|
42
|
+
replaceAll: z.boolean().optional().describe("If true, replace every match. If false (default), require a unique match.")
|
|
43
|
+
}, async (args) => executeEditFile(index, args));
|
|
44
|
+
const createFile = tool("CreateFile", "Creates a new file under the dashboard root and adds it to the index. Returns the new fileId. The relativePath must not contain `.` or `..` segments and must not refer to an existing file.", {
|
|
45
|
+
relativePath: z.string().describe("Path relative to the dashboard root, e.g. 'components/Card.tsx'"),
|
|
46
|
+
content: z.string().describe("The content of the new file")
|
|
47
|
+
}, async (args) => executeCreateFile(index, options.dashboardRoot, args));
|
|
48
|
+
return {
|
|
49
|
+
server: createSdkMcpServer({
|
|
50
|
+
name: "dashboard-fs",
|
|
51
|
+
tools: [listFiles, readFile, grepFiles, writeFile, editFile, createFile]
|
|
52
|
+
}),
|
|
53
|
+
index,
|
|
54
|
+
// Useful for tests / inspection.
|
|
55
|
+
listFileIds: () => [...index.keys()]
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FileIndex } from "./file-index.js";
|
|
2
|
+
interface ReadFileArgs {
|
|
3
|
+
fileId: unknown;
|
|
4
|
+
offset?: unknown;
|
|
5
|
+
limit?: unknown;
|
|
6
|
+
}
|
|
7
|
+
interface ToolResult {
|
|
8
|
+
content: {
|
|
9
|
+
type: "text";
|
|
10
|
+
text: string;
|
|
11
|
+
}[];
|
|
12
|
+
isError?: boolean;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Reads a file by handle. The agent never sees or constructs a path; the
|
|
17
|
+
* `fileId` is opaque and resolves to an absolute path only via the
|
|
18
|
+
* server-built index. Output format mirrors what models trained on the
|
|
19
|
+
* SDK Read tool already expect: `<line>: <content>` with truncation
|
|
20
|
+
* markers and a footer explaining offset/total.
|
|
21
|
+
*/
|
|
22
|
+
export declare function executeReadFile(index: FileIndex, args: ReadFileArgs): Promise<ToolResult>;
|
|
23
|
+
/**
|
|
24
|
+
* Returns the index as a list of `{ fileId, relativePath, size, writable }`.
|
|
25
|
+
* The agent uses this to discover what's readable before issuing ReadFile.
|
|
26
|
+
*/
|
|
27
|
+
export declare function executeListFiles(index: FileIndex): ToolResult;
|
|
28
|
+
export {};
|