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,300 @@
|
|
|
1
|
+
import { cancellationError, throwIfTurnCancelled } from "../agent/turn-cancellation";
|
|
2
|
+
import { defineToolExecutor } from "./types";
|
|
3
|
+
import type {
|
|
4
|
+
ToolExecutionContext,
|
|
5
|
+
ToolExecutor,
|
|
6
|
+
WebSearchRawResult,
|
|
7
|
+
WebSearchResultItem,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
type WebSearchArgs = {
|
|
11
|
+
query: string;
|
|
12
|
+
allowed_domains?: string[];
|
|
13
|
+
blocked_domains?: string[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type WebSearchToolOptions = {
|
|
17
|
+
apiKey: string;
|
|
18
|
+
baseUrl?: string;
|
|
19
|
+
fetchImpl?: typeof fetch;
|
|
20
|
+
numResults?: number;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const WEB_SEARCH_DEFAULT_BASE_URL = "https://api.exa.ai";
|
|
25
|
+
export const WEB_SEARCH_DEFAULT_NUM_RESULTS = 10;
|
|
26
|
+
export const WEB_SEARCH_DEFAULT_TIMEOUT_MS = 30_000;
|
|
27
|
+
|
|
28
|
+
export function createWebSearchToolExecutor(
|
|
29
|
+
options: WebSearchToolOptions,
|
|
30
|
+
): ToolExecutor {
|
|
31
|
+
if (options.apiKey.trim() === "") {
|
|
32
|
+
throw new Error("WebSearch requires a non-empty Exa API key.");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const baseUrl = options.baseUrl ?? WEB_SEARCH_DEFAULT_BASE_URL;
|
|
36
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
37
|
+
const numResults = options.numResults ?? WEB_SEARCH_DEFAULT_NUM_RESULTS;
|
|
38
|
+
const timeoutMs = options.timeoutMs ?? WEB_SEARCH_DEFAULT_TIMEOUT_MS;
|
|
39
|
+
|
|
40
|
+
return defineToolExecutor("web_search", {
|
|
41
|
+
definition: {
|
|
42
|
+
name: "WebSearch",
|
|
43
|
+
description: [
|
|
44
|
+
"Search the web and use the results to inform responses.",
|
|
45
|
+
"- Provides up-to-date information for current events, recent releases, and documentation",
|
|
46
|
+
"- Returns result links with publication dates and query-relevant excerpts",
|
|
47
|
+
"- Use it when the local workspace cannot answer the question or the answer may be stale",
|
|
48
|
+
].join("\n"),
|
|
49
|
+
parameters: {
|
|
50
|
+
type: "object",
|
|
51
|
+
additionalProperties: false,
|
|
52
|
+
properties: {
|
|
53
|
+
query: {
|
|
54
|
+
type: "string",
|
|
55
|
+
minLength: 2,
|
|
56
|
+
description: "The search query to use",
|
|
57
|
+
},
|
|
58
|
+
allowed_domains: {
|
|
59
|
+
type: "array",
|
|
60
|
+
items: { type: "string" },
|
|
61
|
+
description: "Only include search results from these domains",
|
|
62
|
+
},
|
|
63
|
+
blocked_domains: {
|
|
64
|
+
type: "array",
|
|
65
|
+
items: { type: "string" },
|
|
66
|
+
description: "Never include search results from these domains",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
required: ["query"],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
async execute(
|
|
73
|
+
args,
|
|
74
|
+
_call,
|
|
75
|
+
context: ToolExecutionContext,
|
|
76
|
+
): Promise<WebSearchRawResult> {
|
|
77
|
+
throwIfTurnCancelled(context.signal);
|
|
78
|
+
const parsed = parseWebSearchArgs(args);
|
|
79
|
+
|
|
80
|
+
if (!parsed.ok) {
|
|
81
|
+
return { ok: false, query: "", error: parsed.error };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const input = parsed.value;
|
|
85
|
+
const requestBody: Record<string, unknown> = {
|
|
86
|
+
query: input.query,
|
|
87
|
+
type: "auto",
|
|
88
|
+
numResults,
|
|
89
|
+
contents: { highlights: true },
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (input.allowed_domains !== undefined) {
|
|
93
|
+
requestBody.includeDomains = input.allowed_domains;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (input.blocked_domains !== undefined) {
|
|
97
|
+
requestBody.excludeDomains = input.blocked_domains;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const startedAt = Date.now();
|
|
101
|
+
let response: Response;
|
|
102
|
+
const requestSignal = AbortSignal.any([
|
|
103
|
+
context.signal,
|
|
104
|
+
AbortSignal.timeout(timeoutMs),
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
response = await fetchImpl(`${baseUrl}/search`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: {
|
|
111
|
+
"content-type": "application/json",
|
|
112
|
+
"x-api-key": options.apiKey,
|
|
113
|
+
},
|
|
114
|
+
body: JSON.stringify(requestBody),
|
|
115
|
+
signal: requestSignal,
|
|
116
|
+
});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (context.signal.aborted) {
|
|
119
|
+
throw cancellationError(context.signal, error);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
query: input.query,
|
|
125
|
+
error: requestErrorMessage(error, timeoutMs),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let responseText: string;
|
|
130
|
+
try {
|
|
131
|
+
responseText = await response.text();
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (context.signal.aborted) {
|
|
134
|
+
throw cancellationError(context.signal, error);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
query: input.query,
|
|
140
|
+
error: requestErrorMessage(error, timeoutMs),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
throwIfTurnCancelled(context.signal);
|
|
145
|
+
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
query: input.query,
|
|
150
|
+
error: `Exa /search returned HTTP ${response.status}: ${extractApiError(responseText)}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let payload: unknown;
|
|
155
|
+
try {
|
|
156
|
+
payload = JSON.parse(responseText);
|
|
157
|
+
} catch {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
query: input.query,
|
|
161
|
+
error: "Exa /search returned a non-JSON response body.",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!isRecord(payload) || !Array.isArray(payload.results)) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
query: input.query,
|
|
169
|
+
error: "Exa /search response is missing a results array.",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const results = payload.results
|
|
174
|
+
.map(toResultItem)
|
|
175
|
+
.filter((item): item is WebSearchResultItem => item !== undefined);
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
query: input.query,
|
|
180
|
+
searchType:
|
|
181
|
+
typeof payload.searchType === "string" ? payload.searchType : undefined,
|
|
182
|
+
requestId:
|
|
183
|
+
typeof payload.requestId === "string" ? payload.requestId : undefined,
|
|
184
|
+
results,
|
|
185
|
+
resultCount: results.length,
|
|
186
|
+
allowedDomains: input.allowed_domains,
|
|
187
|
+
blockedDomains: input.blocked_domains,
|
|
188
|
+
costDollars: extractTotalCost(payload.costDollars),
|
|
189
|
+
durationMs: Date.now() - startedAt,
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function parseWebSearchArgs(
|
|
196
|
+
args: unknown,
|
|
197
|
+
): { ok: true; value: WebSearchArgs } | { ok: false; error: string } {
|
|
198
|
+
if (!isRecord(args)) {
|
|
199
|
+
return { ok: false, error: "WebSearch arguments must be an object." };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (typeof args.query !== "string" || args.query.trim().length < 2) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
error: "WebSearch.query must be a string with at least 2 characters.",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const allowedDomains = parseDomainList(args.allowed_domains, "allowed_domains");
|
|
210
|
+
if (!allowedDomains.ok) {
|
|
211
|
+
return allowedDomains;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const blockedDomains = parseDomainList(args.blocked_domains, "blocked_domains");
|
|
215
|
+
if (!blockedDomains.ok) {
|
|
216
|
+
return blockedDomains;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
ok: true,
|
|
221
|
+
value: {
|
|
222
|
+
query: args.query.trim(),
|
|
223
|
+
allowed_domains: allowedDomains.value,
|
|
224
|
+
blocked_domains: blockedDomains.value,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function parseDomainList(
|
|
230
|
+
value: unknown,
|
|
231
|
+
name: string,
|
|
232
|
+
): { ok: true; value: string[] | undefined } | { ok: false; error: string } {
|
|
233
|
+
if (value === undefined) {
|
|
234
|
+
return { ok: true, value: undefined };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const entries: unknown[] | undefined = Array.isArray(value) ? value : undefined;
|
|
238
|
+
|
|
239
|
+
if (
|
|
240
|
+
entries === undefined ||
|
|
241
|
+
!entries.every(
|
|
242
|
+
(entry): entry is string => typeof entry === "string" && entry.trim() !== "",
|
|
243
|
+
)
|
|
244
|
+
) {
|
|
245
|
+
return {
|
|
246
|
+
ok: false,
|
|
247
|
+
error: `WebSearch.${name} must be an array of non-empty strings.`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return entries.length === 0
|
|
252
|
+
? { ok: true, value: undefined }
|
|
253
|
+
: { ok: true, value: entries.map((entry) => entry.trim()) };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function toResultItem(value: unknown): WebSearchResultItem | undefined {
|
|
257
|
+
if (!isRecord(value) || typeof value.url !== "string" || value.url === "") {
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
title: typeof value.title === "string" ? value.title : "",
|
|
263
|
+
url: value.url,
|
|
264
|
+
publishedDate:
|
|
265
|
+
typeof value.publishedDate === "string" ? value.publishedDate : undefined,
|
|
266
|
+
author: typeof value.author === "string" ? value.author : undefined,
|
|
267
|
+
highlights: Array.isArray(value.highlights)
|
|
268
|
+
? value.highlights.filter((entry): entry is string => typeof entry === "string")
|
|
269
|
+
: undefined,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function extractTotalCost(value: unknown): number | undefined {
|
|
274
|
+
return isRecord(value) && typeof value.total === "number" ? value.total : undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function extractApiError(responseText: string): string {
|
|
278
|
+
try {
|
|
279
|
+
const payload: unknown = JSON.parse(responseText);
|
|
280
|
+
if (isRecord(payload) && typeof payload.error === "string") {
|
|
281
|
+
return payload.error;
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
// Fall through to the raw body.
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return responseText === "" ? "(empty response body)" : responseText;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function requestErrorMessage(error: unknown, timeoutMs: number): string {
|
|
291
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
292
|
+
return `Exa /search request timed out after ${timeoutMs}ms.`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return `Exa /search request failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
299
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
300
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
import { throwIfTurnCancelled } from "../agent/turn-cancellation";
|
|
5
|
+
import { computeFilePatch } from "./file-diff";
|
|
6
|
+
import { sha256Bytes, sha256Text } from "./hash";
|
|
7
|
+
import { resolveWorkspacePath } from "./path-safety";
|
|
8
|
+
import { defineToolExecutor } from "./types";
|
|
9
|
+
import type {
|
|
10
|
+
ReadSnapshotStore,
|
|
11
|
+
ToolExecutionContext,
|
|
12
|
+
ToolExecutor,
|
|
13
|
+
WriteFileRawResult,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
type WriteArgs = {
|
|
17
|
+
file_path: string;
|
|
18
|
+
content: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type WriteToolOptions = {
|
|
22
|
+
workspaceRoot: string;
|
|
23
|
+
snapshots: ReadSnapshotStore;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function createWriteToolExecutor(options: WriteToolOptions): ToolExecutor {
|
|
27
|
+
return defineToolExecutor("write", {
|
|
28
|
+
definition: {
|
|
29
|
+
name: "Write",
|
|
30
|
+
description:
|
|
31
|
+
"Write full file content in the local workspace. Existing files must be read first.",
|
|
32
|
+
parameters: {
|
|
33
|
+
type: "object",
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
properties: {
|
|
36
|
+
file_path: {
|
|
37
|
+
type: "string",
|
|
38
|
+
description: "Workspace-relative path or absolute path.",
|
|
39
|
+
},
|
|
40
|
+
content: {
|
|
41
|
+
type: "string",
|
|
42
|
+
description: "The full content to write to the file.",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
required: ["file_path", "content"],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
async execute(
|
|
49
|
+
args,
|
|
50
|
+
_call,
|
|
51
|
+
context: ToolExecutionContext,
|
|
52
|
+
): Promise<WriteFileRawResult> {
|
|
53
|
+
throwIfTurnCancelled(context.signal);
|
|
54
|
+
const parsed = parseWriteArgs(args);
|
|
55
|
+
|
|
56
|
+
if (!parsed.ok) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
filePath: "",
|
|
60
|
+
error: parsed.error,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const input = parsed.value;
|
|
65
|
+
let absolutePath: string;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
absolutePath = resolveWorkspacePath(options.workspaceRoot, input.file_path);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
filePath: input.file_path,
|
|
73
|
+
error: errorMessage(error),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const parentPath = path.dirname(absolutePath);
|
|
78
|
+
const parentCheck = await directoryExists(parentPath);
|
|
79
|
+
if (!parentCheck.ok) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
filePath: input.file_path,
|
|
83
|
+
absolutePath,
|
|
84
|
+
error: parentCheck.error,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const target = await targetFileState(absolutePath);
|
|
89
|
+
if (!target.ok) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
filePath: input.file_path,
|
|
93
|
+
absolutePath,
|
|
94
|
+
error: target.error,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let oldSha256: string | null = null;
|
|
99
|
+
let oldContent = "";
|
|
100
|
+
|
|
101
|
+
if (target.exists) {
|
|
102
|
+
const currentSha256 = target.sha256;
|
|
103
|
+
const lastSnapshot = options.snapshots.get(absolutePath);
|
|
104
|
+
|
|
105
|
+
if (lastSnapshot === undefined) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
filePath: input.file_path,
|
|
109
|
+
absolutePath,
|
|
110
|
+
requiredReadBeforeWrite: true,
|
|
111
|
+
currentSha256,
|
|
112
|
+
error: "Existing file must be read before Write.",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (lastSnapshot.sha256 !== currentSha256) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
filePath: input.file_path,
|
|
120
|
+
absolutePath,
|
|
121
|
+
currentSha256,
|
|
122
|
+
lastReadSha256: lastSnapshot.sha256,
|
|
123
|
+
error:
|
|
124
|
+
"File changed after the last successful Read. Read it again before Write.",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
oldSha256 = currentSha256;
|
|
129
|
+
oldContent = target.content;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
throwIfTurnCancelled(context.signal);
|
|
133
|
+
await writeFile(absolutePath, input.content, "utf8");
|
|
134
|
+
const newSha256 = sha256Text(input.content);
|
|
135
|
+
const writtenInfo = await stat(absolutePath);
|
|
136
|
+
options.snapshots.set(absolutePath, {
|
|
137
|
+
sha256: newSha256,
|
|
138
|
+
mtimeMs: writtenInfo.mtimeMs,
|
|
139
|
+
source: "write",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const patch = computeFilePatch({
|
|
143
|
+
filePath: input.file_path,
|
|
144
|
+
oldContent,
|
|
145
|
+
newContent: input.content,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
filePath: input.file_path,
|
|
151
|
+
absolutePath,
|
|
152
|
+
bytesWritten: Buffer.byteLength(input.content, "utf8"),
|
|
153
|
+
oldSha256,
|
|
154
|
+
newSha256,
|
|
155
|
+
created: !target.exists,
|
|
156
|
+
patch: patch.hunks,
|
|
157
|
+
patchTruncated: patch.truncated,
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseWriteArgs(
|
|
164
|
+
args: unknown,
|
|
165
|
+
): { ok: true; value: WriteArgs } | { ok: false; error: string } {
|
|
166
|
+
if (!isRecord(args)) {
|
|
167
|
+
return { ok: false, error: "Write arguments must be an object." };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (typeof args.file_path !== "string") {
|
|
171
|
+
return { ok: false, error: "Write.file_path must be a string." };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (typeof args.content !== "string") {
|
|
175
|
+
return { ok: false, error: "Write.content must be a string." };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
value: {
|
|
181
|
+
file_path: args.file_path,
|
|
182
|
+
content: args.content,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function directoryExists(
|
|
188
|
+
directoryPath: string,
|
|
189
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
190
|
+
try {
|
|
191
|
+
const info = await stat(directoryPath);
|
|
192
|
+
return info.isDirectory()
|
|
193
|
+
? { ok: true }
|
|
194
|
+
: { ok: false, error: "Parent path is not a directory." };
|
|
195
|
+
} catch {
|
|
196
|
+
return { ok: false, error: "Parent directory does not exist." };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function targetFileState(
|
|
201
|
+
absolutePath: string,
|
|
202
|
+
): Promise<
|
|
203
|
+
| { ok: true; exists: false }
|
|
204
|
+
| { ok: true; exists: true; sha256: string; content: string }
|
|
205
|
+
| { ok: false; error: string }
|
|
206
|
+
> {
|
|
207
|
+
try {
|
|
208
|
+
const info = await stat(absolutePath);
|
|
209
|
+
if (!info.isFile()) {
|
|
210
|
+
return { ok: false, error: "Path is not a file." };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const bytes = await readFile(absolutePath);
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
exists: true,
|
|
217
|
+
sha256: sha256Bytes(bytes),
|
|
218
|
+
content: bytes.toString("utf8"),
|
|
219
|
+
};
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (isNotFound(error)) {
|
|
222
|
+
return { ok: true, exists: false };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { ok: false, error: errorMessage(error) };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
230
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function isNotFound(error: unknown): boolean {
|
|
234
|
+
return (
|
|
235
|
+
typeof error === "object" &&
|
|
236
|
+
error !== null &&
|
|
237
|
+
"code" in error &&
|
|
238
|
+
error.code === "ENOENT"
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function errorMessage(error: unknown): string {
|
|
243
|
+
return error instanceof Error ? error.message : String(error);
|
|
244
|
+
}
|