tinker-agent 1.0.65
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/README.md +173 -0
- package/package.json +78 -0
- package/patches/markdansi@0.3.2.patch +37 -0
- package/src/agent/context-builder.ts +43 -0
- package/src/agent/context-meter.ts +310 -0
- package/src/agent/loop.ts +525 -0
- package/src/agent/runtime-session.ts +1212 -0
- package/src/agent/session-ledger.ts +828 -0
- package/src/agent/turn-cancellation.ts +44 -0
- package/src/agent/types.ts +77 -0
- package/src/cli/config.ts +283 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/model-profiles.ts +289 -0
- package/src/cli/run-runner.ts +107 -0
- package/src/cli/tui-runner.tsx +290 -0
- package/src/context/compiled-context-hash.ts +138 -0
- package/src/context/compiled-context-validator.ts +209 -0
- package/src/context/context-manager.ts +362 -0
- package/src/context/context-policy.ts +8 -0
- package/src/context/context-protocol-validator.ts +463 -0
- package/src/context/context-revision-compiler.ts +281 -0
- package/src/context/context-revision.ts +111 -0
- package/src/context/context-source.ts +30 -0
- package/src/context/context-swap-renderer.ts +272 -0
- package/src/context/protocol-frame.ts +240 -0
- package/src/context/swap-planner.ts +725 -0
- package/src/events/append-private-file.ts +16 -0
- package/src/events/bash-result-detail.ts +70 -0
- package/src/events/composite-event-sink.ts +82 -0
- package/src/events/event-sink.ts +16 -0
- package/src/events/jsonl-event-log.ts +13 -0
- package/src/events/observation-text-log.ts +195 -0
- package/src/events/stdout-event-printer.ts +396 -0
- package/src/events/types.ts +263 -0
- package/src/ids/runtime-id.ts +68 -0
- package/src/ids/uuid-v7.ts +5 -0
- package/src/instructions/project-instructions.ts +242 -0
- package/src/mcp/mcp-config.ts +144 -0
- package/src/mcp/mcp-manager.ts +216 -0
- package/src/mcp/mcp-tool-executor.ts +178 -0
- package/src/model/committed-prefix-auditor.ts +68 -0
- package/src/model/fake-model-client.ts +280 -0
- package/src/model/model-client.ts +64 -0
- package/src/model/model-context-profile.ts +134 -0
- package/src/model/model-request-preflight.ts +120 -0
- package/src/model/openai-chat-mapping.ts +444 -0
- package/src/model/openai-chat-model-client.ts +190 -0
- package/src/model/prompt-prefix-hash.ts +47 -0
- package/src/model/token-estimator.ts +148 -0
- package/src/observation/observation-builder.ts +481 -0
- package/src/session/resume-projection.ts +616 -0
- package/src/session/session-catalog.ts +270 -0
- package/src/session/session-errors.ts +121 -0
- package/src/session/session-history-reader.ts +535 -0
- package/src/session/session-lock.ts +291 -0
- package/src/session/session-schema.ts +741 -0
- package/src/session/session-store.ts +3067 -0
- package/src/session/sqlite-session-ledger.ts +153 -0
- package/src/tools/bash-task.ts +617 -0
- package/src/tools/bash.ts +450 -0
- package/src/tools/cwd-state.ts +22 -0
- package/src/tools/edit.ts +428 -0
- package/src/tools/file-diff.ts +116 -0
- package/src/tools/glob.ts +202 -0
- package/src/tools/grep.ts +550 -0
- package/src/tools/hash.ts +9 -0
- package/src/tools/path-safety.ts +33 -0
- package/src/tools/read.ts +319 -0
- package/src/tools/recall.ts +400 -0
- package/src/tools/registry.ts +213 -0
- package/src/tools/ripgrep.ts +220 -0
- package/src/tools/task-list.ts +59 -0
- package/src/tools/task-output-snapshot.ts +47 -0
- package/src/tools/task-output-tool.ts +62 -0
- package/src/tools/task-output.ts +159 -0
- package/src/tools/task-stop.ts +59 -0
- package/src/tools/task-tool-args.ts +29 -0
- package/src/tools/types.ts +330 -0
- package/src/tools/web-fetch/backend.ts +27 -0
- package/src/tools/web-fetch/browser-backend.ts +126 -0
- package/src/tools/web-fetch/exa-backend.ts +172 -0
- package/src/tools/web-fetch/index.ts +298 -0
- package/src/tools/web-fetch/local-backend.ts +267 -0
- package/src/tools/web-fetch/refiner.ts +78 -0
- package/src/tools/web-fetch/route.ts +95 -0
- package/src/tools/web-search.ts +300 -0
- package/src/tools/write.ts +244 -0
- package/src/tui/app.tsx +497 -0
- package/src/tui/components/assistant-markdown.tsx +47 -0
- package/src/tui/components/background-tasks.tsx +92 -0
- package/src/tui/components/bash-result-view.tsx +47 -0
- package/src/tui/components/context-status.tsx +127 -0
- package/src/tui/components/diff-view.tsx +151 -0
- package/src/tui/components/file-viewer.tsx +212 -0
- package/src/tui/components/footer.tsx +60 -0
- package/src/tui/components/header.tsx +21 -0
- package/src/tui/components/model-picker.tsx +142 -0
- package/src/tui/components/prompt-input.tsx +432 -0
- package/src/tui/components/resume-session-picker.tsx +273 -0
- package/src/tui/components/timeline.tsx +121 -0
- package/src/tui/context-format.ts +24 -0
- package/src/tui/event-store.ts +865 -0
- package/src/tui/git-branch.ts +23 -0
- package/src/tui/line-editor.ts +157 -0
- package/src/tui/prompt-history.ts +94 -0
- package/src/tui/slash-commands.ts +126 -0
- package/src/tui/tui-projection-policy.ts +35 -0
- package/src/tui/tui-projection-store.ts +123 -0
- package/src/tui/tui-session-controller.ts +170 -0
- package/src/tui/view-file.ts +122 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { throwIfTurnCancelled } from "../agent/turn-cancellation";
|
|
5
|
+
import { computeFilePatch } from "./file-diff";
|
|
6
|
+
import { sha256Text } from "./hash";
|
|
7
|
+
import { resolveWorkspacePath } from "./path-safety";
|
|
8
|
+
import { defineToolExecutor } from "./types";
|
|
9
|
+
import type {
|
|
10
|
+
EditFileRawResult,
|
|
11
|
+
ReadSnapshotStore,
|
|
12
|
+
ToolExecutionContext,
|
|
13
|
+
ToolExecutor,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
type EditArgs = {
|
|
17
|
+
file_path: string;
|
|
18
|
+
old_string: string;
|
|
19
|
+
new_string: string;
|
|
20
|
+
replace_all: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type EditToolOptions = {
|
|
24
|
+
workspaceRoot: string;
|
|
25
|
+
snapshots: ReadSnapshotStore;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
|
|
29
|
+
return defineToolExecutor("edit", {
|
|
30
|
+
definition: {
|
|
31
|
+
name: "Edit",
|
|
32
|
+
description:
|
|
33
|
+
"Replace an exact string in a workspace file. The file must be read first; a successful paginated Read is sufficient.",
|
|
34
|
+
parameters: {
|
|
35
|
+
type: "object",
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
properties: {
|
|
38
|
+
file_path: {
|
|
39
|
+
type: "string",
|
|
40
|
+
description: "Workspace-relative path or absolute path.",
|
|
41
|
+
},
|
|
42
|
+
old_string: {
|
|
43
|
+
type: "string",
|
|
44
|
+
description: "Exact text to replace.",
|
|
45
|
+
},
|
|
46
|
+
new_string: {
|
|
47
|
+
type: "string",
|
|
48
|
+
description: "Replacement text.",
|
|
49
|
+
},
|
|
50
|
+
replace_all: {
|
|
51
|
+
type: "boolean",
|
|
52
|
+
description: "Replace every occurrence when true.",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
required: ["file_path", "old_string", "new_string"],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
async execute(
|
|
59
|
+
args,
|
|
60
|
+
_call,
|
|
61
|
+
context: ToolExecutionContext,
|
|
62
|
+
): Promise<EditFileRawResult> {
|
|
63
|
+
throwIfTurnCancelled(context.signal);
|
|
64
|
+
const parsed = parseEditArgs(args);
|
|
65
|
+
|
|
66
|
+
if (!parsed.ok) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
filePath: "",
|
|
70
|
+
error: parsed.error,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const input = parsed.value;
|
|
75
|
+
let absolutePath: string;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
absolutePath = resolveWorkspacePath(options.workspaceRoot, input.file_path);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
filePath: input.file_path,
|
|
83
|
+
error: errorMessage(error),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const parentPath = path.dirname(absolutePath);
|
|
88
|
+
const parentCheck = await directoryExists(parentPath);
|
|
89
|
+
if (!parentCheck.ok) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
filePath: input.file_path,
|
|
93
|
+
absolutePath,
|
|
94
|
+
error: parentCheck.error,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const target = await targetFileState(absolutePath);
|
|
99
|
+
if (!target.ok) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
filePath: input.file_path,
|
|
103
|
+
absolutePath,
|
|
104
|
+
error: target.error,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (input.old_string === "") {
|
|
109
|
+
return await writeEmptyTarget({
|
|
110
|
+
filePath: input.file_path,
|
|
111
|
+
absolutePath,
|
|
112
|
+
target,
|
|
113
|
+
newContent: input.new_string,
|
|
114
|
+
snapshots: options.snapshots,
|
|
115
|
+
signal: context.signal,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!target.exists) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
filePath: input.file_path,
|
|
123
|
+
absolutePath,
|
|
124
|
+
error: "File does not exist.",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const snapshot = options.snapshots.get(absolutePath);
|
|
129
|
+
if (snapshot === undefined || snapshot.source === "write") {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
filePath: input.file_path,
|
|
133
|
+
absolutePath,
|
|
134
|
+
requiredReadBeforeEdit: true,
|
|
135
|
+
error: "File must be read before Edit.",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (target.mtimeMs > snapshot.mtimeMs) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
filePath: input.file_path,
|
|
143
|
+
absolutePath,
|
|
144
|
+
currentMtimeMs: target.mtimeMs,
|
|
145
|
+
lastObservedMtimeMs: snapshot.mtimeMs,
|
|
146
|
+
error:
|
|
147
|
+
"File changed after it was last read or edited. Read it again before Edit.",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const replacementCount = countMatches(target.content, input.old_string);
|
|
152
|
+
if (replacementCount === 0) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
filePath: input.file_path,
|
|
156
|
+
absolutePath,
|
|
157
|
+
error: "old_string was not found.",
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (replacementCount > 1 && !input.replace_all) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
filePath: input.file_path,
|
|
165
|
+
absolutePath,
|
|
166
|
+
replacementCount,
|
|
167
|
+
error: "old_string matched multiple locations. Use replace_all=true.",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const nextContent = input.replace_all
|
|
172
|
+
? target.content.replaceAll(input.old_string, input.new_string)
|
|
173
|
+
: target.content.replace(input.old_string, input.new_string);
|
|
174
|
+
|
|
175
|
+
return await writeEditedContent({
|
|
176
|
+
filePath: input.file_path,
|
|
177
|
+
absolutePath,
|
|
178
|
+
oldSha256: target.sha256,
|
|
179
|
+
oldContent: target.content,
|
|
180
|
+
newContent: nextContent,
|
|
181
|
+
replacementCount: input.replace_all ? replacementCount : 1,
|
|
182
|
+
replaceAll: input.replace_all,
|
|
183
|
+
created: false,
|
|
184
|
+
expectedMtimeMs: target.mtimeMs,
|
|
185
|
+
readRequiredOnChange: true,
|
|
186
|
+
snapshots: options.snapshots,
|
|
187
|
+
signal: context.signal,
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseEditArgs(
|
|
194
|
+
args: unknown,
|
|
195
|
+
): { ok: true; value: EditArgs } | { ok: false; error: string } {
|
|
196
|
+
if (!isRecord(args)) {
|
|
197
|
+
return { ok: false, error: "Edit arguments must be an object." };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (typeof args.file_path !== "string") {
|
|
201
|
+
return { ok: false, error: "Edit.file_path must be a string." };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (typeof args.old_string !== "string") {
|
|
205
|
+
return { ok: false, error: "Edit.old_string must be a string." };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (typeof args.new_string !== "string") {
|
|
209
|
+
return { ok: false, error: "Edit.new_string must be a string." };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (args.replace_all !== undefined && typeof args.replace_all !== "boolean") {
|
|
213
|
+
return { ok: false, error: "Edit.replace_all must be a boolean." };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
ok: true,
|
|
218
|
+
value: {
|
|
219
|
+
file_path: args.file_path,
|
|
220
|
+
old_string: args.old_string,
|
|
221
|
+
new_string: args.new_string,
|
|
222
|
+
replace_all: args.replace_all ?? false,
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function writeEmptyTarget(input: {
|
|
228
|
+
filePath: string;
|
|
229
|
+
absolutePath: string;
|
|
230
|
+
target: TargetFileState;
|
|
231
|
+
newContent: string;
|
|
232
|
+
snapshots: ReadSnapshotStore;
|
|
233
|
+
signal: AbortSignal;
|
|
234
|
+
}): Promise<EditFileRawResult> {
|
|
235
|
+
if (input.target.exists && input.target.content.length > 0) {
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
filePath: input.filePath,
|
|
239
|
+
absolutePath: input.absolutePath,
|
|
240
|
+
error: "old_string='' can only create a file or write to an empty file.",
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return await writeEditedContent({
|
|
245
|
+
filePath: input.filePath,
|
|
246
|
+
absolutePath: input.absolutePath,
|
|
247
|
+
oldSha256: input.target.exists ? input.target.sha256 : null,
|
|
248
|
+
oldContent: "",
|
|
249
|
+
newContent: input.newContent,
|
|
250
|
+
replacementCount: 0,
|
|
251
|
+
replaceAll: false,
|
|
252
|
+
created: !input.target.exists,
|
|
253
|
+
expectedMtimeMs: input.target.exists ? input.target.mtimeMs : undefined,
|
|
254
|
+
readRequiredOnChange: false,
|
|
255
|
+
snapshots: input.snapshots,
|
|
256
|
+
signal: input.signal,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function writeEditedContent(input: {
|
|
261
|
+
filePath: string;
|
|
262
|
+
absolutePath: string;
|
|
263
|
+
oldSha256: string | null;
|
|
264
|
+
oldContent: string;
|
|
265
|
+
newContent: string;
|
|
266
|
+
replacementCount: number;
|
|
267
|
+
replaceAll: boolean;
|
|
268
|
+
created: boolean;
|
|
269
|
+
expectedMtimeMs?: number;
|
|
270
|
+
readRequiredOnChange: boolean;
|
|
271
|
+
snapshots: ReadSnapshotStore;
|
|
272
|
+
signal: AbortSignal;
|
|
273
|
+
}): Promise<EditFileRawResult> {
|
|
274
|
+
throwIfTurnCancelled(input.signal);
|
|
275
|
+
|
|
276
|
+
if (input.expectedMtimeMs !== undefined) {
|
|
277
|
+
const currentState = await fileMtime(input.absolutePath);
|
|
278
|
+
throwIfTurnCancelled(input.signal);
|
|
279
|
+
|
|
280
|
+
if (!currentState.ok) {
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
filePath: input.filePath,
|
|
284
|
+
absolutePath: input.absolutePath,
|
|
285
|
+
error: `File changed while Edit was being prepared: ${currentState.error}`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (currentState.mtimeMs > input.expectedMtimeMs) {
|
|
290
|
+
return {
|
|
291
|
+
ok: false,
|
|
292
|
+
filePath: input.filePath,
|
|
293
|
+
absolutePath: input.absolutePath,
|
|
294
|
+
currentMtimeMs: currentState.mtimeMs,
|
|
295
|
+
lastObservedMtimeMs: input.expectedMtimeMs,
|
|
296
|
+
error: input.readRequiredOnChange
|
|
297
|
+
? "File changed while Edit was being prepared. Read it again before Edit."
|
|
298
|
+
: "File changed while Edit was being prepared. Retry Edit with the current file state.",
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
await writeFile(input.absolutePath, input.newContent, "utf8");
|
|
304
|
+
const newSha256 = sha256Text(input.newContent);
|
|
305
|
+
const writtenInfo = await stat(input.absolutePath);
|
|
306
|
+
input.snapshots.set(input.absolutePath, {
|
|
307
|
+
sha256: newSha256,
|
|
308
|
+
mtimeMs: writtenInfo.mtimeMs,
|
|
309
|
+
source: "edit",
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const patch = computeFilePatch({
|
|
313
|
+
filePath: input.filePath,
|
|
314
|
+
oldContent: input.oldContent,
|
|
315
|
+
newContent: input.newContent,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
ok: true,
|
|
320
|
+
filePath: input.filePath,
|
|
321
|
+
absolutePath: input.absolutePath,
|
|
322
|
+
bytesWritten: Buffer.byteLength(input.newContent, "utf8"),
|
|
323
|
+
oldSha256: input.oldSha256,
|
|
324
|
+
newSha256,
|
|
325
|
+
replacementCount: input.replacementCount,
|
|
326
|
+
replaceAll: input.replaceAll,
|
|
327
|
+
created: input.created,
|
|
328
|
+
patch: patch.hunks,
|
|
329
|
+
patchTruncated: patch.truncated,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function directoryExists(
|
|
334
|
+
directoryPath: string,
|
|
335
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
336
|
+
try {
|
|
337
|
+
const info = await stat(directoryPath);
|
|
338
|
+
return info.isDirectory()
|
|
339
|
+
? { ok: true }
|
|
340
|
+
: { ok: false, error: "Parent path is not a directory." };
|
|
341
|
+
} catch {
|
|
342
|
+
return { ok: false, error: "Parent directory does not exist." };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function fileMtime(
|
|
347
|
+
filePath: string,
|
|
348
|
+
): Promise<{ ok: true; mtimeMs: number } | { ok: false; error: string }> {
|
|
349
|
+
try {
|
|
350
|
+
const info = await stat(filePath);
|
|
351
|
+
if (!info.isFile()) {
|
|
352
|
+
return { ok: false, error: "Path is not a file." };
|
|
353
|
+
}
|
|
354
|
+
return { ok: true, mtimeMs: info.mtimeMs };
|
|
355
|
+
} catch (error) {
|
|
356
|
+
return { ok: false, error: errorMessage(error) };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
type TargetFileState =
|
|
361
|
+
| { ok: true; exists: false }
|
|
362
|
+
| {
|
|
363
|
+
ok: true;
|
|
364
|
+
exists: true;
|
|
365
|
+
content: string;
|
|
366
|
+
sha256: string;
|
|
367
|
+
mtimeMs: number;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
async function targetFileState(
|
|
371
|
+
absolutePath: string,
|
|
372
|
+
): Promise<TargetFileState | { ok: false; error: string }> {
|
|
373
|
+
try {
|
|
374
|
+
const info = await stat(absolutePath);
|
|
375
|
+
if (!info.isFile()) {
|
|
376
|
+
return { ok: false, error: "Path is not a file." };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const content = await readFile(absolutePath, "utf8");
|
|
380
|
+
const currentInfo = await stat(absolutePath);
|
|
381
|
+
if (currentInfo.mtimeMs > info.mtimeMs) {
|
|
382
|
+
return { ok: false, error: "File changed while it was being read." };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
ok: true,
|
|
387
|
+
exists: true,
|
|
388
|
+
content,
|
|
389
|
+
sha256: sha256Text(content),
|
|
390
|
+
mtimeMs: currentInfo.mtimeMs,
|
|
391
|
+
};
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (isNotFound(error)) {
|
|
394
|
+
return { ok: true, exists: false };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return { ok: false, error: errorMessage(error) };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function countMatches(content: string, oldString: string): number {
|
|
402
|
+
let count = 0;
|
|
403
|
+
let index = content.indexOf(oldString);
|
|
404
|
+
|
|
405
|
+
while (index !== -1) {
|
|
406
|
+
count += 1;
|
|
407
|
+
index = content.indexOf(oldString, index + oldString.length);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return count;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
414
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function isNotFound(error: unknown): boolean {
|
|
418
|
+
return (
|
|
419
|
+
typeof error === "object" &&
|
|
420
|
+
error !== null &&
|
|
421
|
+
"code" in error &&
|
|
422
|
+
error.code === "ENOENT"
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function errorMessage(error: unknown): string {
|
|
427
|
+
return error instanceof Error ? error.message : String(error);
|
|
428
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { structuredPatch } from "diff";
|
|
2
|
+
import type { DiffHunk } from "./types";
|
|
3
|
+
|
|
4
|
+
export const MAX_PATCH_LINES = 400;
|
|
5
|
+
|
|
6
|
+
export type FilePatch = {
|
|
7
|
+
hunks: DiffHunk[];
|
|
8
|
+
truncated: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function computeFilePatch(input: {
|
|
12
|
+
filePath: string;
|
|
13
|
+
oldContent: string;
|
|
14
|
+
newContent: string;
|
|
15
|
+
}): FilePatch {
|
|
16
|
+
const patch = structuredPatch(
|
|
17
|
+
input.filePath,
|
|
18
|
+
input.filePath,
|
|
19
|
+
input.oldContent,
|
|
20
|
+
input.newContent,
|
|
21
|
+
undefined,
|
|
22
|
+
undefined,
|
|
23
|
+
{ context: 3 },
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const hunks: DiffHunk[] = [];
|
|
27
|
+
let remainingLines = MAX_PATCH_LINES;
|
|
28
|
+
let truncated = false;
|
|
29
|
+
|
|
30
|
+
for (const hunk of patch.hunks) {
|
|
31
|
+
if (remainingLines <= 0) {
|
|
32
|
+
truncated = true;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (hunk.lines.length <= remainingLines) {
|
|
37
|
+
hunks.push({
|
|
38
|
+
oldStart: hunk.oldStart,
|
|
39
|
+
oldLines: hunk.oldLines,
|
|
40
|
+
newStart: hunk.newStart,
|
|
41
|
+
newLines: hunk.newLines,
|
|
42
|
+
lines: hunk.lines,
|
|
43
|
+
});
|
|
44
|
+
remainingLines -= hunk.lines.length;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
hunks.push({
|
|
49
|
+
oldStart: hunk.oldStart,
|
|
50
|
+
oldLines: hunk.oldLines,
|
|
51
|
+
newStart: hunk.newStart,
|
|
52
|
+
newLines: hunk.newLines,
|
|
53
|
+
lines: hunk.lines.slice(0, remainingLines),
|
|
54
|
+
});
|
|
55
|
+
truncated = true;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { hunks, truncated };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseDiffHunks(value: unknown): DiffHunk[] | undefined {
|
|
63
|
+
if (!Array.isArray(value)) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const hunks: DiffHunk[] = [];
|
|
68
|
+
|
|
69
|
+
for (const entry of value) {
|
|
70
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hunk = entry as Record<string, unknown>;
|
|
75
|
+
const oldStart = hunk.oldStart;
|
|
76
|
+
const oldLines = hunk.oldLines;
|
|
77
|
+
const newStart = hunk.newStart;
|
|
78
|
+
const newLines = hunk.newLines;
|
|
79
|
+
const lines = hunk.lines;
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
typeof oldStart !== "number" ||
|
|
83
|
+
typeof oldLines !== "number" ||
|
|
84
|
+
typeof newStart !== "number" ||
|
|
85
|
+
typeof newLines !== "number" ||
|
|
86
|
+
!Array.isArray(lines) ||
|
|
87
|
+
!lines.every((line): line is string => typeof line === "string")
|
|
88
|
+
) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
hunks.push({ oldStart, oldLines, newStart, newLines, lines });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return hunks;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function countPatchChanges(hunks: DiffHunk[]): {
|
|
99
|
+
additions: number;
|
|
100
|
+
deletions: number;
|
|
101
|
+
} {
|
|
102
|
+
let additions = 0;
|
|
103
|
+
let deletions = 0;
|
|
104
|
+
|
|
105
|
+
for (const hunk of hunks) {
|
|
106
|
+
for (const line of hunk.lines) {
|
|
107
|
+
if (line.startsWith("+")) {
|
|
108
|
+
additions += 1;
|
|
109
|
+
} else if (line.startsWith("-")) {
|
|
110
|
+
deletions += 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { additions, deletions };
|
|
116
|
+
}
|