@ricsam/r5d-worker 0.0.11 → 0.0.12

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/cjs/main.cjs CHANGED
@@ -35,10 +35,9 @@ __export(main_exports, {
35
35
  });
36
36
  module.exports = __toCommonJS(main_exports);
37
37
  var import_node_fs = __toESM(require("node:fs"), 1);
38
- var import_node_os = __toESM(require("node:os"), 1);
39
38
  var import_node_path = __toESM(require("node:path"), 1);
40
39
  var import_node_crypto = require("node:crypto");
41
- var import_node_os2 = require("node:os");
40
+ var import_node_os = __toESM(require("node:os"), 1);
42
41
  var import_node_child_process = require("node:child_process");
43
42
  var import_git_identity = require("./git-identity.cjs");
44
43
  const import_meta = {};
@@ -47,6 +46,7 @@ const WORKER_PACKAGE_NAME = "@ricsam/r5d-worker";
47
46
  const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
48
47
  const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9]$|^[A-Za-z0-9]$/;
49
48
  const DEFAULT_READ_LIMIT = 2e3;
49
+ const DEFAULT_READ_MAX_BYTES = 5e4;
50
50
  const MAX_LINE_LENGTH = 2e3;
51
51
  const MAX_SYNC_DIFF_BYTES = 5 * 1024 * 1024;
52
52
  const R5D_REMOTE_NAME = "r5d";
@@ -921,16 +921,36 @@ function formatLineNumberedContent(content, offset, limit) {
921
921
  const endLine = Math.min(startLine + readLimit - 1, totalLines);
922
922
  const selectedLines = allLines.slice(startLine - 1, endLine);
923
923
  const padWidth = String(endLine).length;
924
- const formattedLines = selectedLines.map((line, index) => {
924
+ const formattedLines = [];
925
+ let actualEndLine = endLine;
926
+ let budgetTruncated = false;
927
+ for (let index = 0; index < selectedLines.length; index += 1) {
928
+ const line = selectedLines[index] ?? "";
925
929
  const lineNumber = startLine + index;
926
930
  const truncatedLine = line.length > MAX_LINE_LENGTH ? `${line.slice(0, MAX_LINE_LENGTH)}...` : line;
927
- return `${String(lineNumber).padStart(padWidth, " ")}\u2192${truncatedLine}`;
928
- });
931
+ const formattedLine = `${String(lineNumber).padStart(padWidth, " ")}\u2192${truncatedLine}`;
932
+ const candidateLength = formattedLines.length === 0 ? formattedLine.length : formattedLine.length + 1;
933
+ const currentLength = formattedLines.join("\n").length;
934
+ if (currentLength + candidateLength > DEFAULT_READ_MAX_BYTES) {
935
+ budgetTruncated = true;
936
+ break;
937
+ }
938
+ formattedLines.push(formattedLine);
939
+ actualEndLine = lineNumber;
940
+ }
941
+ let formattedContent = formattedLines.join("\n");
942
+ if (budgetTruncated) {
943
+ formattedContent = `${formattedContent}
944
+ ... (truncated by byte budget)`;
945
+ }
946
+ const truncated = budgetTruncated || actualEndLine < totalLines;
929
947
  return {
930
- content: formattedLines.join("\n"),
948
+ content: formattedContent,
931
949
  totalLines,
932
950
  startLine,
933
- endLine
951
+ endLine: actualEndLine,
952
+ truncated,
953
+ continuation: truncated ? `Call read with offset ${actualEndLine + 1} to continue.` : void 0
934
954
  };
935
955
  }
936
956
  function readPngDimensions(bytes) {
@@ -997,6 +1017,27 @@ function readImageDimensions(bytes, mediaType) {
997
1017
  }
998
1018
  return dimensions;
999
1019
  }
1020
+ const fileMutationQueues = /* @__PURE__ */ new Map();
1021
+ async function withFileMutationQueue(key, operation) {
1022
+ const previous = fileMutationQueues.get(key) ?? Promise.resolve();
1023
+ let release;
1024
+ const current = previous.catch(() => void 0).then(() => new Promise((resolve) => {
1025
+ release = resolve;
1026
+ }));
1027
+ fileMutationQueues.set(key, current);
1028
+ await previous.catch(() => void 0);
1029
+ try {
1030
+ return await operation();
1031
+ } finally {
1032
+ release();
1033
+ if (fileMutationQueues.get(key) === current) {
1034
+ fileMutationQueues.delete(key);
1035
+ }
1036
+ }
1037
+ }
1038
+ function mutationQueueKey(projectId, branchName, filePath) {
1039
+ return `${projectId}:${branchName}:${import_node_path.default.posix.normalize(filePath)}`;
1040
+ }
1000
1041
  async function executeReadFileOperation(input) {
1001
1042
  const artifact = await resolveArtifactEnvFilePath({
1002
1043
  filePath: input.message.filePath,
@@ -1014,7 +1055,8 @@ async function executeReadFileOperation(input) {
1014
1055
  const buffer2 = import_node_fs.default.readFileSync(artifact.absolutePath);
1015
1056
  const formatted2 = formatLineNumberedContent(buffer2.toString("utf8"), input.message.offset, input.message.limit);
1016
1057
  return {
1017
- type: "read_file",
1058
+ type: "read",
1059
+ kind: "text",
1018
1060
  file: artifact.virtualPath,
1019
1061
  gitBlobHash: getGitBlobHashForContent(buffer2),
1020
1062
  ...formatted2
@@ -1028,222 +1070,209 @@ async function executeReadFileOperation(input) {
1028
1070
  const buffer = import_node_fs.default.readFileSync(resolved.absolutePath);
1029
1071
  const formatted = formatLineNumberedContent(buffer.toString("utf8"), input.message.offset, input.message.limit);
1030
1072
  return {
1031
- type: "read_file",
1073
+ type: "read",
1074
+ kind: "text",
1032
1075
  file: resolved.virtualPath,
1033
1076
  gitBlobHash: getGitBlobHashForContent(buffer),
1034
1077
  ...formatted
1035
1078
  };
1036
1079
  }
1037
- async function executeCreateFileOperation(input) {
1080
+ async function executeWriteFileOperation(input) {
1038
1081
  rejectArtifactWritePath(input.message.filePath);
1039
- const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1040
- const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1041
- if (import_node_fs.default.existsSync(resolved.absolutePath)) {
1042
- throw new Error(`File "${resolved.virtualPath}" already exists. Use edit_file to modify existing files.`);
1043
- }
1044
- import_node_fs.default.mkdirSync(import_node_path.default.dirname(resolved.absolutePath), { recursive: true });
1045
- import_node_fs.default.writeFileSync(resolved.absolutePath, input.message.content, "utf8");
1046
- return {
1047
- type: "create_file",
1048
- file: resolved.virtualPath,
1049
- gitBlobHash: getGitBlobHashForContent(input.message.content)
1050
- };
1082
+ return withFileMutationQueue(mutationQueueKey(input.projectId, input.message.branchName, input.message.filePath), async () => {
1083
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1084
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1085
+ import_node_fs.default.mkdirSync(import_node_path.default.dirname(resolved.absolutePath), { recursive: true });
1086
+ import_node_fs.default.writeFileSync(resolved.absolutePath, input.message.content, "utf8");
1087
+ return {
1088
+ type: "write",
1089
+ file: resolved.virtualPath,
1090
+ gitBlobHash: getGitBlobHashForContent(input.message.content)
1091
+ };
1092
+ });
1051
1093
  }
1052
1094
  async function executeEditFileOperation(input) {
1053
1095
  rejectArtifactWritePath(input.message.filePath);
1054
- const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1055
- const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1056
- if (input.message.oldString === input.message.newString) {
1057
- throw new Error("old_string and new_string must be different");
1058
- }
1059
- if (!import_node_fs.default.existsSync(resolved.absolutePath) || !import_node_fs.default.statSync(resolved.absolutePath).isFile()) {
1060
- throw new Error(`File not found: ${resolved.virtualPath}`);
1061
- }
1062
- const content = import_node_fs.default.readFileSync(resolved.absolutePath, "utf8");
1063
- if (!content.includes(input.message.oldString)) {
1064
- throw new Error(
1065
- `Could not find old_string: "${input.message.oldString.slice(0, 100)}${input.message.oldString.length > 100 ? "..." : ""}"`
1066
- );
1067
- }
1068
- if (!input.message.replaceAll) {
1069
- const occurrences = content.split(input.message.oldString).length - 1;
1070
- if (occurrences > 1) {
1071
- throw new Error(
1072
- `old_string is not unique in the file (found ${occurrences} occurrences). Provide more context or use replace_all: true.`
1073
- );
1096
+ return withFileMutationQueue(mutationQueueKey(input.projectId, input.message.branchName, input.message.filePath), async () => {
1097
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1098
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1099
+ if (!Array.isArray(input.message.edits) || input.message.edits.length === 0) {
1100
+ throw new Error("edit requires at least one replacement");
1101
+ }
1102
+ if (!import_node_fs.default.existsSync(resolved.absolutePath) || !import_node_fs.default.statSync(resolved.absolutePath).isFile()) {
1103
+ throw new Error(`File not found: ${resolved.virtualPath}`);
1104
+ }
1105
+ const originalContent = import_node_fs.default.readFileSync(resolved.absolutePath, "utf8");
1106
+ const ranges = input.message.edits.map((edit, index) => {
1107
+ if (edit.oldText === edit.newText) {
1108
+ throw new Error(`edits[${index}].oldText and edits[${index}].newText must be different`);
1109
+ }
1110
+ if (edit.oldText.length === 0) {
1111
+ throw new Error(`edits[${index}].oldText cannot be empty`);
1112
+ }
1113
+ const occurrences = originalContent.split(edit.oldText).length - 1;
1114
+ if (occurrences === 0) {
1115
+ throw new Error(`Could not find edits[${index}].oldText: "${edit.oldText.slice(0, 100)}${edit.oldText.length > 100 ? "..." : ""}"`);
1116
+ }
1117
+ if (occurrences > 1) {
1118
+ throw new Error(`edits[${index}].oldText is not unique in the file (found ${occurrences} occurrences). Include more surrounding context.`);
1119
+ }
1120
+ const start = originalContent.indexOf(edit.oldText);
1121
+ return { index, start, end: start + edit.oldText.length, edit };
1122
+ });
1123
+ const sortedRanges = [...ranges].sort((a, b) => a.start - b.start);
1124
+ for (let index = 1; index < sortedRanges.length; index += 1) {
1125
+ const previous = sortedRanges[index - 1];
1126
+ const current = sortedRanges[index];
1127
+ if (current.start < previous.end) {
1128
+ throw new Error(`edits[${current.index}] overlaps edits[${previous.index}]. Merge nearby changes into one replacement.`);
1129
+ }
1074
1130
  }
1075
- }
1076
- const nextContent = input.message.replaceAll ? content.split(input.message.oldString).join(input.message.newString) : content.replace(input.message.oldString, input.message.newString);
1077
- import_node_fs.default.writeFileSync(resolved.absolutePath, nextContent, "utf8");
1078
- return {
1079
- type: "edit_file",
1080
- file: resolved.virtualPath
1081
- };
1131
+ let nextContent = originalContent;
1132
+ for (const range of [...sortedRanges].reverse()) {
1133
+ nextContent = nextContent.slice(0, range.start) + range.edit.newText + nextContent.slice(range.end);
1134
+ }
1135
+ import_node_fs.default.writeFileSync(resolved.absolutePath, nextContent, "utf8");
1136
+ return {
1137
+ type: "edit",
1138
+ file: resolved.virtualPath,
1139
+ edits: input.message.edits.length
1140
+ };
1141
+ });
1082
1142
  }
1083
- function decodePatchPath(rawPath) {
1084
- const trimmed = rawPath.trim();
1085
- if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
1086
- try {
1087
- return JSON.parse(trimmed);
1088
- } catch {
1089
- return trimmed.slice(1, -1);
1143
+ const IGNORED_ENTRY_NAMES = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", ".next", ".cache", "coverage", "target"]);
1144
+ function normalizeToolLimit(value, fallback, max) {
1145
+ if (!Number.isFinite(value) || value === void 0) return fallback;
1146
+ return Math.max(1, Math.min(max, Math.floor(value)));
1147
+ }
1148
+ function escapeRegex(value) {
1149
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1150
+ }
1151
+ function wildcardToRegex(pattern) {
1152
+ const escaped = escapeRegex(pattern).replace(/\\\*/g, ".*").replace(/\\\?/g, ".");
1153
+ return new RegExp(`^${escaped}$`);
1154
+ }
1155
+ function normalizeFindPattern(pattern) {
1156
+ return /[*?]/.test(pattern) ? pattern : `*${pattern}*`;
1157
+ }
1158
+ function toVirtualPath(branchPath, absolutePath) {
1159
+ const relative = import_node_path.default.relative(branchPath, absolutePath).split(import_node_path.default.sep).join(import_node_path.default.posix.sep);
1160
+ return relative ? `/${relative}` : "/";
1161
+ }
1162
+ function walkProjectEntries(branchPath, startPath) {
1163
+ const entries = [];
1164
+ const visit = (absolutePath) => {
1165
+ const stat = import_node_fs.default.statSync(absolutePath);
1166
+ const virtualPath = toVirtualPath(branchPath, absolutePath);
1167
+ entries.push({ absolutePath, virtualPath, stat });
1168
+ if (!stat.isDirectory()) return;
1169
+ const children = import_node_fs.default.readdirSync(absolutePath, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
1170
+ for (const child of children) {
1171
+ if (IGNORED_ENTRY_NAMES.has(child.name)) continue;
1172
+ visit(import_node_path.default.join(absolutePath, child.name));
1090
1173
  }
1091
- }
1092
- return trimmed;
1174
+ };
1175
+ visit(startPath);
1176
+ return entries;
1093
1177
  }
1094
- function normalizePatchPath(rawPath) {
1095
- const withoutMetadata = rawPath.split(" ")[0] ?? rawPath;
1096
- let patchPath = decodePatchPath(withoutMetadata);
1097
- if (patchPath === "/dev/null") {
1098
- return null;
1099
- }
1100
- if (patchPath.startsWith("a/") || patchPath.startsWith("b/")) {
1101
- patchPath = patchPath.slice(2);
1102
- }
1103
- rejectArtifactWritePath(patchPath);
1104
- if (!patchPath || import_node_path.default.isAbsolute(patchPath)) {
1105
- throw new Error(`Invalid patch path: ${rawPath}`);
1106
- }
1107
- const normalized = import_node_path.default.posix.normalize(patchPath);
1108
- if (normalized === "." || normalized.startsWith("../") || normalized === "..") {
1109
- throw new Error(`Invalid patch path: ${rawPath}`);
1110
- }
1111
- return normalized;
1178
+ function isProbablyText(bytes) {
1179
+ return !bytes.subarray(0, Math.min(bytes.length, 4096)).includes(0);
1112
1180
  }
1113
- function readDiffPathToken(value, startIndex) {
1114
- let index = startIndex;
1115
- while (index < value.length && /\s/.test(value[index] ?? "")) {
1116
- index += 1;
1117
- }
1118
- if (index >= value.length) {
1119
- throw new Error("Missing path in patch header");
1120
- }
1121
- if (value[index] !== '"') {
1122
- const tokenStart2 = index;
1123
- while (index < value.length && !/\s/.test(value[index] ?? "")) {
1124
- index += 1;
1125
- }
1126
- return { token: value.slice(tokenStart2, index), nextIndex: index };
1127
- }
1128
- const tokenStart = index;
1129
- index += 1;
1130
- let escaped = false;
1131
- while (index < value.length) {
1132
- const character = value[index];
1133
- if (escaped) {
1134
- escaped = false;
1135
- } else if (character === "\\") {
1136
- escaped = true;
1137
- } else if (character === '"') {
1138
- index += 1;
1139
- return { token: value.slice(tokenStart, index), nextIndex: index };
1181
+ async function executeGrepOperation(input) {
1182
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1183
+ const start = resolveProjectFilePath(workspace.branchPath, input.message.path ?? "/");
1184
+ if (!import_node_fs.default.existsSync(start.absolutePath)) {
1185
+ throw new Error(`Path not found: ${start.virtualPath}`);
1186
+ }
1187
+ const limit = normalizeToolLimit(input.message.limit, 100, 1e3);
1188
+ const flags = input.message.caseSensitive === false ? "i" : "";
1189
+ const regex = new RegExp(input.message.pattern, flags);
1190
+ const globRegex = input.message.glob ? wildcardToRegex(input.message.glob) : null;
1191
+ const lines = [];
1192
+ let matchCount = 0;
1193
+ let truncated = false;
1194
+ for (const entry of walkProjectEntries(workspace.branchPath, start.absolutePath)) {
1195
+ if (!entry.stat.isFile()) continue;
1196
+ const repoRelative = entry.virtualPath.slice(1);
1197
+ if (globRegex && !globRegex.test(repoRelative)) continue;
1198
+ const bytes = import_node_fs.default.readFileSync(entry.absolutePath);
1199
+ if (!isProbablyText(bytes)) continue;
1200
+ const fileLines = bytes.toString("utf8").split(/\r?\n/);
1201
+ for (let index = 0; index < fileLines.length; index += 1) {
1202
+ const line = fileLines[index] ?? "";
1203
+ regex.lastIndex = 0;
1204
+ const match = regex.exec(line);
1205
+ if (!match) continue;
1206
+ matchCount += 1;
1207
+ if (lines.length < limit) {
1208
+ const column = Math.max(1, (match.index ?? 0) + 1);
1209
+ lines.push(`${entry.virtualPath}:${index + 1}:${column}: ${line.slice(0, MAX_LINE_LENGTH)}`);
1210
+ } else {
1211
+ truncated = true;
1212
+ }
1140
1213
  }
1141
- index += 1;
1142
- }
1143
- throw new Error("Unterminated quoted path in patch header");
1144
- }
1145
- function parseDiffGitPaths(line) {
1146
- if (!line.startsWith("diff --git ")) {
1147
- throw new Error(`Invalid patch header: ${line}`);
1148
- }
1149
- const rest = line.slice("diff --git ".length);
1150
- const first = readDiffPathToken(rest, 0);
1151
- const second = readDiffPathToken(rest, first.nextIndex);
1152
- if (rest.slice(second.nextIndex).trim()) {
1153
- throw new Error(`Invalid patch header: ${line}`);
1154
1214
  }
1215
+ const hint = truncated ? `
1216
+ ... truncated. Narrow path/glob or increase limit to continue.` : "";
1155
1217
  return {
1156
- oldPath: normalizePatchPath(first.token),
1157
- newPath: normalizePatchPath(second.token)
1218
+ type: "grep",
1219
+ content: lines.length > 0 ? `${lines.join("\n")}${hint}` : "No matches.",
1220
+ matchCount,
1221
+ truncated
1158
1222
  };
1159
1223
  }
1160
- function summarizePatch(patch) {
1161
- if (!patch.trim()) {
1162
- throw new Error("Patch cannot be empty");
1163
- }
1164
- const files = [];
1165
- let current;
1166
- for (const line of patch.split(/\r?\n/)) {
1167
- if (line.startsWith("diff --git ")) {
1168
- const { oldPath, newPath } = parseDiffGitPaths(line);
1169
- if (!oldPath && !newPath) {
1170
- throw new Error(`Invalid patch header: ${line}`);
1171
- }
1172
- current = {
1173
- path: newPath ?? oldPath,
1174
- ...oldPath && newPath && oldPath !== newPath ? { oldPath } : {},
1175
- operation: oldPath && newPath && oldPath !== newPath ? "move" : "update",
1176
- additions: 0,
1177
- deletions: 0,
1178
- hunks: 0
1179
- };
1180
- files.push(current);
1181
- continue;
1182
- }
1183
- if (!current) {
1184
- continue;
1185
- }
1186
- if (line.startsWith("new file mode ")) {
1187
- current.operation = "add";
1188
- continue;
1189
- }
1190
- if (line.startsWith("deleted file mode ")) {
1191
- current.operation = "delete";
1192
- if (current.oldPath) {
1193
- current.path = current.oldPath;
1194
- delete current.oldPath;
1195
- }
1196
- continue;
1197
- }
1198
- if (line.startsWith("rename from ")) {
1199
- current.operation = "move";
1200
- current.oldPath = normalizePatchPath(line.slice("rename from ".length)) ?? void 0;
1201
- continue;
1202
- }
1203
- if (line.startsWith("rename to ")) {
1204
- current.operation = "move";
1205
- current.path = normalizePatchPath(line.slice("rename to ".length)) ?? current.path;
1206
- continue;
1207
- }
1208
- if (line.startsWith("@@")) {
1209
- current.hunks += 1;
1210
- continue;
1211
- }
1212
- if (line.startsWith("+") && !line.startsWith("+++")) {
1213
- current.additions += 1;
1214
- continue;
1215
- }
1216
- if (line.startsWith("-") && !line.startsWith("---")) {
1217
- current.deletions += 1;
1224
+ async function executeFindOperation(input) {
1225
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1226
+ const start = resolveProjectFilePath(workspace.branchPath, input.message.path ?? "/");
1227
+ if (!import_node_fs.default.existsSync(start.absolutePath) || !import_node_fs.default.statSync(start.absolutePath).isDirectory()) {
1228
+ throw new Error(`Directory not found: ${start.virtualPath}`);
1229
+ }
1230
+ const pattern = normalizeFindPattern(input.message.pattern ?? "*");
1231
+ const matcher = wildcardToRegex(pattern);
1232
+ const entryType = input.message.entryType ?? "file";
1233
+ const limit = normalizeToolLimit(input.message.limit, 200, 1e3);
1234
+ const matches = [];
1235
+ let count = 0;
1236
+ let truncated = false;
1237
+ for (const entry of walkProjectEntries(workspace.branchPath, start.absolutePath)) {
1238
+ if (entry.virtualPath === start.virtualPath) continue;
1239
+ const isDirectory = entry.stat.isDirectory();
1240
+ if (entryType === "file" && !entry.stat.isFile()) continue;
1241
+ if (entryType === "directory" && !isDirectory) continue;
1242
+ const label = isDirectory ? `${entry.virtualPath}/` : entry.virtualPath;
1243
+ if (!matcher.test(import_node_path.default.posix.basename(entry.virtualPath)) && !matcher.test(entry.virtualPath.slice(1))) continue;
1244
+ count += 1;
1245
+ if (matches.length < limit) {
1246
+ matches.push(label);
1247
+ } else {
1248
+ truncated = true;
1218
1249
  }
1219
1250
  }
1220
- if (files.length === 0) {
1221
- throw new Error("Patch must contain at least one diff --git file header");
1222
- }
1223
- return files;
1251
+ const hint = truncated ? "\n... truncated. Narrow path/pattern or increase limit to continue." : "";
1252
+ return {
1253
+ type: "find",
1254
+ content: matches.length > 0 ? `${matches.join("\n")}${hint}` : "No paths found.",
1255
+ count,
1256
+ truncated
1257
+ };
1224
1258
  }
1225
- async function executeApplyPatchOperation(input) {
1259
+ async function executeLsOperation(input) {
1226
1260
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1227
- const files = summarizePatch(input.message.patch);
1228
- const patchPath = import_node_path.default.join(import_node_os.default.tmpdir(), `r5d-apply-patch-${(0, import_node_crypto.randomUUID)()}.patch`);
1229
- try {
1230
- import_node_fs.default.writeFileSync(patchPath, input.message.patch, "utf8");
1231
- runInternalGit(workspace.internalGit, ["apply", "--check", "--whitespace=nowarn", patchPath]);
1232
- runInternalGit(workspace.internalGit, ["apply", "--whitespace=nowarn", patchPath]);
1233
- } catch (error) {
1234
- throw new Error(`Patch did not apply: ${error instanceof Error ? error.message : String(error)}`);
1235
- } finally {
1236
- import_node_fs.default.rmSync(patchPath, { force: true });
1237
- }
1261
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.path ?? "/");
1262
+ if (!import_node_fs.default.existsSync(resolved.absolutePath) || !import_node_fs.default.statSync(resolved.absolutePath).isDirectory()) {
1263
+ throw new Error(`Directory not found: ${resolved.virtualPath}`);
1264
+ }
1265
+ const limit = normalizeToolLimit(input.message.limit, 200, 1e3);
1266
+ const entries = import_node_fs.default.readdirSync(resolved.absolutePath, { withFileTypes: true }).filter((entry) => !IGNORED_ENTRY_NAMES.has(entry.name)).sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
1267
+ const displayed = entries.slice(0, limit).map((entry) => `${entry.name}${entry.isDirectory() ? "/" : ""}`);
1268
+ const truncated = entries.length > displayed.length;
1269
+ const hint = truncated ? "\n... truncated. Increase limit to continue." : "";
1238
1270
  return {
1239
- type: "apply_patch",
1240
- files,
1241
- summary: {
1242
- filesChanged: files.length,
1243
- additions: files.reduce((sum, file) => sum + file.additions, 0),
1244
- deletions: files.reduce((sum, file) => sum + file.deletions, 0),
1245
- hunks: files.reduce((sum, file) => sum + file.hunks, 0)
1246
- }
1271
+ type: "ls",
1272
+ path: resolved.virtualPath,
1273
+ content: displayed.length > 0 ? `${displayed.join("\n")}${hint}` : "(empty directory)",
1274
+ count: entries.length,
1275
+ truncated
1247
1276
  };
1248
1277
  }
1249
1278
  async function executeViewFileBytesOperation(input) {
@@ -1263,7 +1292,7 @@ async function executeViewFileBytesOperation(input) {
1263
1292
  const extension2 = import_node_path.default.extname(artifact.absolutePath).toLowerCase();
1264
1293
  const mediaType2 = extension2 === ".jpg" || extension2 === ".jpeg" ? "image/jpeg" : extension2 === ".png" ? "image/png" : null;
1265
1294
  if (!mediaType2) {
1266
- throw new Error("view_image supports PNG and JPEG files only");
1295
+ throw new Error("read supports PNG and JPEG image inputs only");
1267
1296
  }
1268
1297
  const bytes2 = import_node_fs.default.readFileSync(artifact.absolutePath);
1269
1298
  const dimensions2 = readImageDimensions(bytes2, mediaType2);
@@ -1285,7 +1314,7 @@ async function executeViewFileBytesOperation(input) {
1285
1314
  const extension = import_node_path.default.extname(resolved.absolutePath).toLowerCase();
1286
1315
  const mediaType = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg" : extension === ".png" ? "image/png" : null;
1287
1316
  if (!mediaType) {
1288
- throw new Error("view_image supports PNG and JPEG files only");
1317
+ throw new Error("read supports PNG and JPEG image inputs only");
1289
1318
  }
1290
1319
  const bytes = import_node_fs.default.readFileSync(resolved.absolutePath);
1291
1320
  const dimensions = readImageDimensions(bytes, mediaType);
@@ -1391,14 +1420,18 @@ function pullBranch(input) {
1391
1420
  }
1392
1421
  async function executeOperation(input) {
1393
1422
  switch (input.message.type) {
1394
- case "read_file":
1423
+ case "read":
1395
1424
  return executeReadFileOperation({ ...input, message: input.message });
1396
- case "create_file":
1397
- return executeCreateFileOperation({ ...input, message: input.message });
1398
- case "edit_file":
1425
+ case "write":
1426
+ return executeWriteFileOperation({ ...input, message: input.message });
1427
+ case "edit":
1399
1428
  return executeEditFileOperation({ ...input, message: input.message });
1400
- case "apply_patch":
1401
- return executeApplyPatchOperation({ ...input, message: input.message });
1429
+ case "grep":
1430
+ return executeGrepOperation({ ...input, message: input.message });
1431
+ case "find":
1432
+ return executeFindOperation({ ...input, message: input.message });
1433
+ case "ls":
1434
+ return executeLsOperation({ ...input, message: input.message });
1402
1435
  case "view_file_bytes":
1403
1436
  return executeViewFileBytesOperation({ ...input, message: input.message });
1404
1437
  case "sync":
@@ -1958,7 +1991,7 @@ async function startWorker(options) {
1958
1991
  const hello = {
1959
1992
  type: "hello",
1960
1993
  hostInfo: {
1961
- hostname: (0, import_node_os2.hostname)(),
1994
+ hostname: (0, import_node_os.hostname)(),
1962
1995
  platform: process.platform,
1963
1996
  arch: process.arch,
1964
1997
  pid: process.pid,
@@ -2099,7 +2132,7 @@ async function startWorker(options) {
2099
2132
  }
2100
2133
  return;
2101
2134
  }
2102
- if (message.type === "read_file" || message.type === "create_file" || message.type === "edit_file" || message.type === "apply_patch" || message.type === "view_file_bytes" || message.type === "sync" || message.type === "confirm_large_diff" || message.type === "pull_branch") {
2135
+ if (message.type === "read" || message.type === "write" || message.type === "edit" || message.type === "grep" || message.type === "find" || message.type === "ls" || message.type === "view_file_bytes" || message.type === "sync" || message.type === "confirm_large_diff" || message.type === "pull_branch") {
2103
2136
  try {
2104
2137
  const manifest = manifestByProjectId.get(message.projectId);
2105
2138
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "type": "commonjs"
5
5
  }
package/dist/mjs/main.mjs CHANGED
@@ -1,9 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import fs from "node:fs";
3
- import os from "node:os";
4
3
  import path from "node:path";
5
- import { createHash, randomUUID } from "node:crypto";
6
- import { hostname } from "node:os";
4
+ import { createHash } from "node:crypto";
5
+ import os, { hostname } from "node:os";
7
6
  import { spawn as spawnChildProcess } from "node:child_process";
8
7
  import { configureVisibleGitIdentity } from "./git-identity.mjs";
9
8
  const DEFAULT_BASE_URL = "https://r5d.dev";
@@ -11,6 +10,7 @@ const WORKER_PACKAGE_NAME = "@ricsam/r5d-worker";
11
10
  const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
12
11
  const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9]$|^[A-Za-z0-9]$/;
13
12
  const DEFAULT_READ_LIMIT = 2e3;
13
+ const DEFAULT_READ_MAX_BYTES = 5e4;
14
14
  const MAX_LINE_LENGTH = 2e3;
15
15
  const MAX_SYNC_DIFF_BYTES = 5 * 1024 * 1024;
16
16
  const R5D_REMOTE_NAME = "r5d";
@@ -885,16 +885,36 @@ function formatLineNumberedContent(content, offset, limit) {
885
885
  const endLine = Math.min(startLine + readLimit - 1, totalLines);
886
886
  const selectedLines = allLines.slice(startLine - 1, endLine);
887
887
  const padWidth = String(endLine).length;
888
- const formattedLines = selectedLines.map((line, index) => {
888
+ const formattedLines = [];
889
+ let actualEndLine = endLine;
890
+ let budgetTruncated = false;
891
+ for (let index = 0; index < selectedLines.length; index += 1) {
892
+ const line = selectedLines[index] ?? "";
889
893
  const lineNumber = startLine + index;
890
894
  const truncatedLine = line.length > MAX_LINE_LENGTH ? `${line.slice(0, MAX_LINE_LENGTH)}...` : line;
891
- return `${String(lineNumber).padStart(padWidth, " ")}\u2192${truncatedLine}`;
892
- });
895
+ const formattedLine = `${String(lineNumber).padStart(padWidth, " ")}\u2192${truncatedLine}`;
896
+ const candidateLength = formattedLines.length === 0 ? formattedLine.length : formattedLine.length + 1;
897
+ const currentLength = formattedLines.join("\n").length;
898
+ if (currentLength + candidateLength > DEFAULT_READ_MAX_BYTES) {
899
+ budgetTruncated = true;
900
+ break;
901
+ }
902
+ formattedLines.push(formattedLine);
903
+ actualEndLine = lineNumber;
904
+ }
905
+ let formattedContent = formattedLines.join("\n");
906
+ if (budgetTruncated) {
907
+ formattedContent = `${formattedContent}
908
+ ... (truncated by byte budget)`;
909
+ }
910
+ const truncated = budgetTruncated || actualEndLine < totalLines;
893
911
  return {
894
- content: formattedLines.join("\n"),
912
+ content: formattedContent,
895
913
  totalLines,
896
914
  startLine,
897
- endLine
915
+ endLine: actualEndLine,
916
+ truncated,
917
+ continuation: truncated ? `Call read with offset ${actualEndLine + 1} to continue.` : void 0
898
918
  };
899
919
  }
900
920
  function readPngDimensions(bytes) {
@@ -961,6 +981,27 @@ function readImageDimensions(bytes, mediaType) {
961
981
  }
962
982
  return dimensions;
963
983
  }
984
+ const fileMutationQueues = /* @__PURE__ */ new Map();
985
+ async function withFileMutationQueue(key, operation) {
986
+ const previous = fileMutationQueues.get(key) ?? Promise.resolve();
987
+ let release;
988
+ const current = previous.catch(() => void 0).then(() => new Promise((resolve) => {
989
+ release = resolve;
990
+ }));
991
+ fileMutationQueues.set(key, current);
992
+ await previous.catch(() => void 0);
993
+ try {
994
+ return await operation();
995
+ } finally {
996
+ release();
997
+ if (fileMutationQueues.get(key) === current) {
998
+ fileMutationQueues.delete(key);
999
+ }
1000
+ }
1001
+ }
1002
+ function mutationQueueKey(projectId, branchName, filePath) {
1003
+ return `${projectId}:${branchName}:${path.posix.normalize(filePath)}`;
1004
+ }
964
1005
  async function executeReadFileOperation(input) {
965
1006
  const artifact = await resolveArtifactEnvFilePath({
966
1007
  filePath: input.message.filePath,
@@ -978,7 +1019,8 @@ async function executeReadFileOperation(input) {
978
1019
  const buffer2 = fs.readFileSync(artifact.absolutePath);
979
1020
  const formatted2 = formatLineNumberedContent(buffer2.toString("utf8"), input.message.offset, input.message.limit);
980
1021
  return {
981
- type: "read_file",
1022
+ type: "read",
1023
+ kind: "text",
982
1024
  file: artifact.virtualPath,
983
1025
  gitBlobHash: getGitBlobHashForContent(buffer2),
984
1026
  ...formatted2
@@ -992,222 +1034,209 @@ async function executeReadFileOperation(input) {
992
1034
  const buffer = fs.readFileSync(resolved.absolutePath);
993
1035
  const formatted = formatLineNumberedContent(buffer.toString("utf8"), input.message.offset, input.message.limit);
994
1036
  return {
995
- type: "read_file",
1037
+ type: "read",
1038
+ kind: "text",
996
1039
  file: resolved.virtualPath,
997
1040
  gitBlobHash: getGitBlobHashForContent(buffer),
998
1041
  ...formatted
999
1042
  };
1000
1043
  }
1001
- async function executeCreateFileOperation(input) {
1044
+ async function executeWriteFileOperation(input) {
1002
1045
  rejectArtifactWritePath(input.message.filePath);
1003
- const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1004
- const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1005
- if (fs.existsSync(resolved.absolutePath)) {
1006
- throw new Error(`File "${resolved.virtualPath}" already exists. Use edit_file to modify existing files.`);
1007
- }
1008
- fs.mkdirSync(path.dirname(resolved.absolutePath), { recursive: true });
1009
- fs.writeFileSync(resolved.absolutePath, input.message.content, "utf8");
1010
- return {
1011
- type: "create_file",
1012
- file: resolved.virtualPath,
1013
- gitBlobHash: getGitBlobHashForContent(input.message.content)
1014
- };
1046
+ return withFileMutationQueue(mutationQueueKey(input.projectId, input.message.branchName, input.message.filePath), async () => {
1047
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1048
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1049
+ fs.mkdirSync(path.dirname(resolved.absolutePath), { recursive: true });
1050
+ fs.writeFileSync(resolved.absolutePath, input.message.content, "utf8");
1051
+ return {
1052
+ type: "write",
1053
+ file: resolved.virtualPath,
1054
+ gitBlobHash: getGitBlobHashForContent(input.message.content)
1055
+ };
1056
+ });
1015
1057
  }
1016
1058
  async function executeEditFileOperation(input) {
1017
1059
  rejectArtifactWritePath(input.message.filePath);
1018
- const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1019
- const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1020
- if (input.message.oldString === input.message.newString) {
1021
- throw new Error("old_string and new_string must be different");
1022
- }
1023
- if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
1024
- throw new Error(`File not found: ${resolved.virtualPath}`);
1025
- }
1026
- const content = fs.readFileSync(resolved.absolutePath, "utf8");
1027
- if (!content.includes(input.message.oldString)) {
1028
- throw new Error(
1029
- `Could not find old_string: "${input.message.oldString.slice(0, 100)}${input.message.oldString.length > 100 ? "..." : ""}"`
1030
- );
1031
- }
1032
- if (!input.message.replaceAll) {
1033
- const occurrences = content.split(input.message.oldString).length - 1;
1034
- if (occurrences > 1) {
1035
- throw new Error(
1036
- `old_string is not unique in the file (found ${occurrences} occurrences). Provide more context or use replace_all: true.`
1037
- );
1060
+ return withFileMutationQueue(mutationQueueKey(input.projectId, input.message.branchName, input.message.filePath), async () => {
1061
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1062
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1063
+ if (!Array.isArray(input.message.edits) || input.message.edits.length === 0) {
1064
+ throw new Error("edit requires at least one replacement");
1065
+ }
1066
+ if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
1067
+ throw new Error(`File not found: ${resolved.virtualPath}`);
1068
+ }
1069
+ const originalContent = fs.readFileSync(resolved.absolutePath, "utf8");
1070
+ const ranges = input.message.edits.map((edit, index) => {
1071
+ if (edit.oldText === edit.newText) {
1072
+ throw new Error(`edits[${index}].oldText and edits[${index}].newText must be different`);
1073
+ }
1074
+ if (edit.oldText.length === 0) {
1075
+ throw new Error(`edits[${index}].oldText cannot be empty`);
1076
+ }
1077
+ const occurrences = originalContent.split(edit.oldText).length - 1;
1078
+ if (occurrences === 0) {
1079
+ throw new Error(`Could not find edits[${index}].oldText: "${edit.oldText.slice(0, 100)}${edit.oldText.length > 100 ? "..." : ""}"`);
1080
+ }
1081
+ if (occurrences > 1) {
1082
+ throw new Error(`edits[${index}].oldText is not unique in the file (found ${occurrences} occurrences). Include more surrounding context.`);
1083
+ }
1084
+ const start = originalContent.indexOf(edit.oldText);
1085
+ return { index, start, end: start + edit.oldText.length, edit };
1086
+ });
1087
+ const sortedRanges = [...ranges].sort((a, b) => a.start - b.start);
1088
+ for (let index = 1; index < sortedRanges.length; index += 1) {
1089
+ const previous = sortedRanges[index - 1];
1090
+ const current = sortedRanges[index];
1091
+ if (current.start < previous.end) {
1092
+ throw new Error(`edits[${current.index}] overlaps edits[${previous.index}]. Merge nearby changes into one replacement.`);
1093
+ }
1038
1094
  }
1039
- }
1040
- const nextContent = input.message.replaceAll ? content.split(input.message.oldString).join(input.message.newString) : content.replace(input.message.oldString, input.message.newString);
1041
- fs.writeFileSync(resolved.absolutePath, nextContent, "utf8");
1042
- return {
1043
- type: "edit_file",
1044
- file: resolved.virtualPath
1045
- };
1095
+ let nextContent = originalContent;
1096
+ for (const range of [...sortedRanges].reverse()) {
1097
+ nextContent = nextContent.slice(0, range.start) + range.edit.newText + nextContent.slice(range.end);
1098
+ }
1099
+ fs.writeFileSync(resolved.absolutePath, nextContent, "utf8");
1100
+ return {
1101
+ type: "edit",
1102
+ file: resolved.virtualPath,
1103
+ edits: input.message.edits.length
1104
+ };
1105
+ });
1046
1106
  }
1047
- function decodePatchPath(rawPath) {
1048
- const trimmed = rawPath.trim();
1049
- if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
1050
- try {
1051
- return JSON.parse(trimmed);
1052
- } catch {
1053
- return trimmed.slice(1, -1);
1107
+ const IGNORED_ENTRY_NAMES = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", ".next", ".cache", "coverage", "target"]);
1108
+ function normalizeToolLimit(value, fallback, max) {
1109
+ if (!Number.isFinite(value) || value === void 0) return fallback;
1110
+ return Math.max(1, Math.min(max, Math.floor(value)));
1111
+ }
1112
+ function escapeRegex(value) {
1113
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1114
+ }
1115
+ function wildcardToRegex(pattern) {
1116
+ const escaped = escapeRegex(pattern).replace(/\\\*/g, ".*").replace(/\\\?/g, ".");
1117
+ return new RegExp(`^${escaped}$`);
1118
+ }
1119
+ function normalizeFindPattern(pattern) {
1120
+ return /[*?]/.test(pattern) ? pattern : `*${pattern}*`;
1121
+ }
1122
+ function toVirtualPath(branchPath, absolutePath) {
1123
+ const relative = path.relative(branchPath, absolutePath).split(path.sep).join(path.posix.sep);
1124
+ return relative ? `/${relative}` : "/";
1125
+ }
1126
+ function walkProjectEntries(branchPath, startPath) {
1127
+ const entries = [];
1128
+ const visit = (absolutePath) => {
1129
+ const stat = fs.statSync(absolutePath);
1130
+ const virtualPath = toVirtualPath(branchPath, absolutePath);
1131
+ entries.push({ absolutePath, virtualPath, stat });
1132
+ if (!stat.isDirectory()) return;
1133
+ const children = fs.readdirSync(absolutePath, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
1134
+ for (const child of children) {
1135
+ if (IGNORED_ENTRY_NAMES.has(child.name)) continue;
1136
+ visit(path.join(absolutePath, child.name));
1054
1137
  }
1055
- }
1056
- return trimmed;
1138
+ };
1139
+ visit(startPath);
1140
+ return entries;
1057
1141
  }
1058
- function normalizePatchPath(rawPath) {
1059
- const withoutMetadata = rawPath.split(" ")[0] ?? rawPath;
1060
- let patchPath = decodePatchPath(withoutMetadata);
1061
- if (patchPath === "/dev/null") {
1062
- return null;
1063
- }
1064
- if (patchPath.startsWith("a/") || patchPath.startsWith("b/")) {
1065
- patchPath = patchPath.slice(2);
1066
- }
1067
- rejectArtifactWritePath(patchPath);
1068
- if (!patchPath || path.isAbsolute(patchPath)) {
1069
- throw new Error(`Invalid patch path: ${rawPath}`);
1070
- }
1071
- const normalized = path.posix.normalize(patchPath);
1072
- if (normalized === "." || normalized.startsWith("../") || normalized === "..") {
1073
- throw new Error(`Invalid patch path: ${rawPath}`);
1074
- }
1075
- return normalized;
1142
+ function isProbablyText(bytes) {
1143
+ return !bytes.subarray(0, Math.min(bytes.length, 4096)).includes(0);
1076
1144
  }
1077
- function readDiffPathToken(value, startIndex) {
1078
- let index = startIndex;
1079
- while (index < value.length && /\s/.test(value[index] ?? "")) {
1080
- index += 1;
1081
- }
1082
- if (index >= value.length) {
1083
- throw new Error("Missing path in patch header");
1084
- }
1085
- if (value[index] !== '"') {
1086
- const tokenStart2 = index;
1087
- while (index < value.length && !/\s/.test(value[index] ?? "")) {
1088
- index += 1;
1089
- }
1090
- return { token: value.slice(tokenStart2, index), nextIndex: index };
1091
- }
1092
- const tokenStart = index;
1093
- index += 1;
1094
- let escaped = false;
1095
- while (index < value.length) {
1096
- const character = value[index];
1097
- if (escaped) {
1098
- escaped = false;
1099
- } else if (character === "\\") {
1100
- escaped = true;
1101
- } else if (character === '"') {
1102
- index += 1;
1103
- return { token: value.slice(tokenStart, index), nextIndex: index };
1145
+ async function executeGrepOperation(input) {
1146
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1147
+ const start = resolveProjectFilePath(workspace.branchPath, input.message.path ?? "/");
1148
+ if (!fs.existsSync(start.absolutePath)) {
1149
+ throw new Error(`Path not found: ${start.virtualPath}`);
1150
+ }
1151
+ const limit = normalizeToolLimit(input.message.limit, 100, 1e3);
1152
+ const flags = input.message.caseSensitive === false ? "i" : "";
1153
+ const regex = new RegExp(input.message.pattern, flags);
1154
+ const globRegex = input.message.glob ? wildcardToRegex(input.message.glob) : null;
1155
+ const lines = [];
1156
+ let matchCount = 0;
1157
+ let truncated = false;
1158
+ for (const entry of walkProjectEntries(workspace.branchPath, start.absolutePath)) {
1159
+ if (!entry.stat.isFile()) continue;
1160
+ const repoRelative = entry.virtualPath.slice(1);
1161
+ if (globRegex && !globRegex.test(repoRelative)) continue;
1162
+ const bytes = fs.readFileSync(entry.absolutePath);
1163
+ if (!isProbablyText(bytes)) continue;
1164
+ const fileLines = bytes.toString("utf8").split(/\r?\n/);
1165
+ for (let index = 0; index < fileLines.length; index += 1) {
1166
+ const line = fileLines[index] ?? "";
1167
+ regex.lastIndex = 0;
1168
+ const match = regex.exec(line);
1169
+ if (!match) continue;
1170
+ matchCount += 1;
1171
+ if (lines.length < limit) {
1172
+ const column = Math.max(1, (match.index ?? 0) + 1);
1173
+ lines.push(`${entry.virtualPath}:${index + 1}:${column}: ${line.slice(0, MAX_LINE_LENGTH)}`);
1174
+ } else {
1175
+ truncated = true;
1176
+ }
1104
1177
  }
1105
- index += 1;
1106
- }
1107
- throw new Error("Unterminated quoted path in patch header");
1108
- }
1109
- function parseDiffGitPaths(line) {
1110
- if (!line.startsWith("diff --git ")) {
1111
- throw new Error(`Invalid patch header: ${line}`);
1112
- }
1113
- const rest = line.slice("diff --git ".length);
1114
- const first = readDiffPathToken(rest, 0);
1115
- const second = readDiffPathToken(rest, first.nextIndex);
1116
- if (rest.slice(second.nextIndex).trim()) {
1117
- throw new Error(`Invalid patch header: ${line}`);
1118
1178
  }
1179
+ const hint = truncated ? `
1180
+ ... truncated. Narrow path/glob or increase limit to continue.` : "";
1119
1181
  return {
1120
- oldPath: normalizePatchPath(first.token),
1121
- newPath: normalizePatchPath(second.token)
1182
+ type: "grep",
1183
+ content: lines.length > 0 ? `${lines.join("\n")}${hint}` : "No matches.",
1184
+ matchCount,
1185
+ truncated
1122
1186
  };
1123
1187
  }
1124
- function summarizePatch(patch) {
1125
- if (!patch.trim()) {
1126
- throw new Error("Patch cannot be empty");
1127
- }
1128
- const files = [];
1129
- let current;
1130
- for (const line of patch.split(/\r?\n/)) {
1131
- if (line.startsWith("diff --git ")) {
1132
- const { oldPath, newPath } = parseDiffGitPaths(line);
1133
- if (!oldPath && !newPath) {
1134
- throw new Error(`Invalid patch header: ${line}`);
1135
- }
1136
- current = {
1137
- path: newPath ?? oldPath,
1138
- ...oldPath && newPath && oldPath !== newPath ? { oldPath } : {},
1139
- operation: oldPath && newPath && oldPath !== newPath ? "move" : "update",
1140
- additions: 0,
1141
- deletions: 0,
1142
- hunks: 0
1143
- };
1144
- files.push(current);
1145
- continue;
1146
- }
1147
- if (!current) {
1148
- continue;
1149
- }
1150
- if (line.startsWith("new file mode ")) {
1151
- current.operation = "add";
1152
- continue;
1153
- }
1154
- if (line.startsWith("deleted file mode ")) {
1155
- current.operation = "delete";
1156
- if (current.oldPath) {
1157
- current.path = current.oldPath;
1158
- delete current.oldPath;
1159
- }
1160
- continue;
1161
- }
1162
- if (line.startsWith("rename from ")) {
1163
- current.operation = "move";
1164
- current.oldPath = normalizePatchPath(line.slice("rename from ".length)) ?? void 0;
1165
- continue;
1166
- }
1167
- if (line.startsWith("rename to ")) {
1168
- current.operation = "move";
1169
- current.path = normalizePatchPath(line.slice("rename to ".length)) ?? current.path;
1170
- continue;
1171
- }
1172
- if (line.startsWith("@@")) {
1173
- current.hunks += 1;
1174
- continue;
1175
- }
1176
- if (line.startsWith("+") && !line.startsWith("+++")) {
1177
- current.additions += 1;
1178
- continue;
1179
- }
1180
- if (line.startsWith("-") && !line.startsWith("---")) {
1181
- current.deletions += 1;
1188
+ async function executeFindOperation(input) {
1189
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1190
+ const start = resolveProjectFilePath(workspace.branchPath, input.message.path ?? "/");
1191
+ if (!fs.existsSync(start.absolutePath) || !fs.statSync(start.absolutePath).isDirectory()) {
1192
+ throw new Error(`Directory not found: ${start.virtualPath}`);
1193
+ }
1194
+ const pattern = normalizeFindPattern(input.message.pattern ?? "*");
1195
+ const matcher = wildcardToRegex(pattern);
1196
+ const entryType = input.message.entryType ?? "file";
1197
+ const limit = normalizeToolLimit(input.message.limit, 200, 1e3);
1198
+ const matches = [];
1199
+ let count = 0;
1200
+ let truncated = false;
1201
+ for (const entry of walkProjectEntries(workspace.branchPath, start.absolutePath)) {
1202
+ if (entry.virtualPath === start.virtualPath) continue;
1203
+ const isDirectory = entry.stat.isDirectory();
1204
+ if (entryType === "file" && !entry.stat.isFile()) continue;
1205
+ if (entryType === "directory" && !isDirectory) continue;
1206
+ const label = isDirectory ? `${entry.virtualPath}/` : entry.virtualPath;
1207
+ if (!matcher.test(path.posix.basename(entry.virtualPath)) && !matcher.test(entry.virtualPath.slice(1))) continue;
1208
+ count += 1;
1209
+ if (matches.length < limit) {
1210
+ matches.push(label);
1211
+ } else {
1212
+ truncated = true;
1182
1213
  }
1183
1214
  }
1184
- if (files.length === 0) {
1185
- throw new Error("Patch must contain at least one diff --git file header");
1186
- }
1187
- return files;
1215
+ const hint = truncated ? "\n... truncated. Narrow path/pattern or increase limit to continue." : "";
1216
+ return {
1217
+ type: "find",
1218
+ content: matches.length > 0 ? `${matches.join("\n")}${hint}` : "No paths found.",
1219
+ count,
1220
+ truncated
1221
+ };
1188
1222
  }
1189
- async function executeApplyPatchOperation(input) {
1223
+ async function executeLsOperation(input) {
1190
1224
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1191
- const files = summarizePatch(input.message.patch);
1192
- const patchPath = path.join(os.tmpdir(), `r5d-apply-patch-${randomUUID()}.patch`);
1193
- try {
1194
- fs.writeFileSync(patchPath, input.message.patch, "utf8");
1195
- runInternalGit(workspace.internalGit, ["apply", "--check", "--whitespace=nowarn", patchPath]);
1196
- runInternalGit(workspace.internalGit, ["apply", "--whitespace=nowarn", patchPath]);
1197
- } catch (error) {
1198
- throw new Error(`Patch did not apply: ${error instanceof Error ? error.message : String(error)}`);
1199
- } finally {
1200
- fs.rmSync(patchPath, { force: true });
1201
- }
1225
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.path ?? "/");
1226
+ if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isDirectory()) {
1227
+ throw new Error(`Directory not found: ${resolved.virtualPath}`);
1228
+ }
1229
+ const limit = normalizeToolLimit(input.message.limit, 200, 1e3);
1230
+ const entries = fs.readdirSync(resolved.absolutePath, { withFileTypes: true }).filter((entry) => !IGNORED_ENTRY_NAMES.has(entry.name)).sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
1231
+ const displayed = entries.slice(0, limit).map((entry) => `${entry.name}${entry.isDirectory() ? "/" : ""}`);
1232
+ const truncated = entries.length > displayed.length;
1233
+ const hint = truncated ? "\n... truncated. Increase limit to continue." : "";
1202
1234
  return {
1203
- type: "apply_patch",
1204
- files,
1205
- summary: {
1206
- filesChanged: files.length,
1207
- additions: files.reduce((sum, file) => sum + file.additions, 0),
1208
- deletions: files.reduce((sum, file) => sum + file.deletions, 0),
1209
- hunks: files.reduce((sum, file) => sum + file.hunks, 0)
1210
- }
1235
+ type: "ls",
1236
+ path: resolved.virtualPath,
1237
+ content: displayed.length > 0 ? `${displayed.join("\n")}${hint}` : "(empty directory)",
1238
+ count: entries.length,
1239
+ truncated
1211
1240
  };
1212
1241
  }
1213
1242
  async function executeViewFileBytesOperation(input) {
@@ -1227,7 +1256,7 @@ async function executeViewFileBytesOperation(input) {
1227
1256
  const extension2 = path.extname(artifact.absolutePath).toLowerCase();
1228
1257
  const mediaType2 = extension2 === ".jpg" || extension2 === ".jpeg" ? "image/jpeg" : extension2 === ".png" ? "image/png" : null;
1229
1258
  if (!mediaType2) {
1230
- throw new Error("view_image supports PNG and JPEG files only");
1259
+ throw new Error("read supports PNG and JPEG image inputs only");
1231
1260
  }
1232
1261
  const bytes2 = fs.readFileSync(artifact.absolutePath);
1233
1262
  const dimensions2 = readImageDimensions(bytes2, mediaType2);
@@ -1249,7 +1278,7 @@ async function executeViewFileBytesOperation(input) {
1249
1278
  const extension = path.extname(resolved.absolutePath).toLowerCase();
1250
1279
  const mediaType = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg" : extension === ".png" ? "image/png" : null;
1251
1280
  if (!mediaType) {
1252
- throw new Error("view_image supports PNG and JPEG files only");
1281
+ throw new Error("read supports PNG and JPEG image inputs only");
1253
1282
  }
1254
1283
  const bytes = fs.readFileSync(resolved.absolutePath);
1255
1284
  const dimensions = readImageDimensions(bytes, mediaType);
@@ -1355,14 +1384,18 @@ function pullBranch(input) {
1355
1384
  }
1356
1385
  async function executeOperation(input) {
1357
1386
  switch (input.message.type) {
1358
- case "read_file":
1387
+ case "read":
1359
1388
  return executeReadFileOperation({ ...input, message: input.message });
1360
- case "create_file":
1361
- return executeCreateFileOperation({ ...input, message: input.message });
1362
- case "edit_file":
1389
+ case "write":
1390
+ return executeWriteFileOperation({ ...input, message: input.message });
1391
+ case "edit":
1363
1392
  return executeEditFileOperation({ ...input, message: input.message });
1364
- case "apply_patch":
1365
- return executeApplyPatchOperation({ ...input, message: input.message });
1393
+ case "grep":
1394
+ return executeGrepOperation({ ...input, message: input.message });
1395
+ case "find":
1396
+ return executeFindOperation({ ...input, message: input.message });
1397
+ case "ls":
1398
+ return executeLsOperation({ ...input, message: input.message });
1366
1399
  case "view_file_bytes":
1367
1400
  return executeViewFileBytesOperation({ ...input, message: input.message });
1368
1401
  case "sync":
@@ -2063,7 +2096,7 @@ async function startWorker(options) {
2063
2096
  }
2064
2097
  return;
2065
2098
  }
2066
- if (message.type === "read_file" || message.type === "create_file" || message.type === "edit_file" || message.type === "apply_patch" || message.type === "view_file_bytes" || message.type === "sync" || message.type === "confirm_large_diff" || message.type === "pull_branch") {
2099
+ if (message.type === "read" || message.type === "write" || message.type === "edit" || message.type === "grep" || message.type === "find" || message.type === "ls" || message.type === "view_file_bytes" || message.type === "sync" || message.type === "confirm_large_diff" || message.type === "pull_branch") {
2067
2100
  try {
2068
2101
  const manifest = manifestByProjectId.get(message.projectId);
2069
2102
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "type": "module"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",