@jerryan/pi-hashline-edit 0.7.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +7 -5
- package/index.ts +6 -0
- package/package.json +4 -4
- package/src/edit-diff.ts +201 -390
- package/src/edit-response.ts +3 -0
- package/src/edit.ts +141 -78
- package/src/file-kind.ts +130 -167
- package/src/fs-write.ts +76 -76
- package/src/hashline.ts +699 -1071
- package/src/package-info.ts +4 -0
- package/src/path-utils.ts +13 -13
- package/src/read.ts +241 -230
- package/src/runtime.ts +3 -3
- package/src/snapshot.ts +29 -29
- package/src/undo.ts +212 -0
- package/{prompts → tool-descriptions}/edit.md +23 -23
- package/{prompts → tool-descriptions}/read-guidelines.md +1 -1
- package/{prompts → tool-descriptions}/read.md +5 -5
- package/tool-descriptions/undo.md +8 -0
- /package/{prompts → tool-descriptions}/edit-snippet.md +0 -0
- /package/{prompts → tool-descriptions}/read-snippet.md +0 -0
package/src/path-utils.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import * as os from "os";
|
|
2
|
-
import { isAbsolute, resolve as resolvePath } from "path";
|
|
3
|
-
|
|
4
|
-
function expandPath(filePath: string): string {
|
|
5
|
-
if (filePath === "~") return os.homedir();
|
|
6
|
-
if (filePath.startsWith("~/")) return os.homedir() + filePath.slice(1);
|
|
7
|
-
return filePath;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function resolveToCwd(filePath: string, cwd: string): string {
|
|
11
|
-
const expanded = expandPath(filePath);
|
|
12
|
-
return isAbsolute(expanded) ? expanded : resolvePath(cwd, expanded);
|
|
13
|
-
}
|
|
1
|
+
import * as os from "os";
|
|
2
|
+
import { isAbsolute, resolve as resolvePath } from "path";
|
|
3
|
+
|
|
4
|
+
function expandPath(filePath: string): string {
|
|
5
|
+
if (filePath === "~") return os.homedir();
|
|
6
|
+
if (filePath.startsWith("~/")) return os.homedir() + filePath.slice(1);
|
|
7
|
+
return filePath;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function resolveToCwd(filePath: string, cwd: string): string {
|
|
11
|
+
const expanded = expandPath(filePath);
|
|
12
|
+
return isAbsolute(expanded) ? expanded : resolvePath(cwd, expanded);
|
|
13
|
+
}
|
package/src/read.ts
CHANGED
|
@@ -1,230 +1,241 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
createReadTool,
|
|
4
|
-
formatSize,
|
|
5
|
-
DEFAULT_MAX_BYTES,
|
|
6
|
-
DEFAULT_MAX_LINES,
|
|
7
|
-
truncateHead,
|
|
8
|
-
type TruncationResult,
|
|
9
|
-
} from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import { Type } from "@sinclair/typebox";
|
|
11
|
-
import { readFileSync } from "fs";
|
|
12
|
-
import { access as fsAccess, readdir as fsReaddir } from "fs/promises";
|
|
13
|
-
import { constants } from "fs";
|
|
14
|
-
import { normalizeToLF, stripBom } from "./edit-diff";
|
|
15
|
-
import { loadFileKindAndText } from "./file-kind";
|
|
16
|
-
import { formatHashlineRegion } from "./hashline";
|
|
17
|
-
import { resolveToCwd } from "./path-utils";
|
|
18
|
-
import { throwIfAborted } from "./runtime";
|
|
19
|
-
import { getFileSnapshot } from "./snapshot";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.replaceAll("{{
|
|
27
|
-
.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
.
|
|
40
|
-
.
|
|
41
|
-
.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
let
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
.
|
|
187
|
-
.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
createReadTool,
|
|
4
|
+
formatSize,
|
|
5
|
+
DEFAULT_MAX_BYTES,
|
|
6
|
+
DEFAULT_MAX_LINES,
|
|
7
|
+
truncateHead,
|
|
8
|
+
type TruncationResult,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Type } from "@sinclair/typebox";
|
|
11
|
+
import { readFileSync } from "fs";
|
|
12
|
+
import { access as fsAccess, readdir as fsReaddir } from "fs/promises";
|
|
13
|
+
import { constants } from "fs";
|
|
14
|
+
import { normalizeToLF, stripBom } from "./edit-diff";
|
|
15
|
+
import { loadFileKindAndText } from "./file-kind";
|
|
16
|
+
import { formatHashlineRegion } from "./hashline";
|
|
17
|
+
import { resolveToCwd } from "./path-utils";
|
|
18
|
+
import { throwIfAborted } from "./runtime";
|
|
19
|
+
import { getFileSnapshot } from "./snapshot";
|
|
20
|
+
import { PACKAGE_INFO } from "./package-info";
|
|
21
|
+
|
|
22
|
+
const READ_DESC = readFileSync(
|
|
23
|
+
new URL("../tool-descriptions/read.md", import.meta.url),
|
|
24
|
+
"utf-8",
|
|
25
|
+
)
|
|
26
|
+
.replaceAll("{{DEFAULT_MAX_LINES}}", String(DEFAULT_MAX_LINES))
|
|
27
|
+
.replaceAll("{{DEFAULT_MAX_BYTES}}", formatSize(DEFAULT_MAX_BYTES))
|
|
28
|
+
.trim();
|
|
29
|
+
|
|
30
|
+
const READ_PROMPT_SNIPPET = readFileSync(
|
|
31
|
+
new URL("../tool-descriptions/read-snippet.md", import.meta.url),
|
|
32
|
+
"utf-8",
|
|
33
|
+
).trim();
|
|
34
|
+
|
|
35
|
+
const READ_PROMPT_GUIDELINES = readFileSync(
|
|
36
|
+
new URL("../tool-descriptions/read-guidelines.md", import.meta.url),
|
|
37
|
+
"utf-8",
|
|
38
|
+
)
|
|
39
|
+
.split("\n")
|
|
40
|
+
.map((line) => line.trim())
|
|
41
|
+
.filter((line) => line.startsWith("- "))
|
|
42
|
+
.map((line) => line.slice(2));
|
|
43
|
+
|
|
44
|
+
function normalizePositiveInteger(
|
|
45
|
+
value: number | undefined,
|
|
46
|
+
name: "offset" | "limit",
|
|
47
|
+
): number | undefined {
|
|
48
|
+
if (value === undefined) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
53
|
+
throw new Error(`Read request field "${name}" must be a positive integer.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getPreviewLines(text: string): string[] {
|
|
60
|
+
if (text.length === 0) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const lines = text.split("\n");
|
|
65
|
+
return text.endsWith("\n") ? lines.slice(0, -1) : lines;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatHashlineReadPreview(
|
|
69
|
+
text: string,
|
|
70
|
+
options: { offset?: number; limit?: number; raw?: boolean },
|
|
71
|
+
): { text: string; truncation?: TruncationResult; nextOffset?: number } {
|
|
72
|
+
const allLines = getPreviewLines(text);
|
|
73
|
+
const totalLines = allLines.length;
|
|
74
|
+
const startLine = normalizePositiveInteger(options.offset, "offset") ?? 1;
|
|
75
|
+
if (totalLines === 0) {
|
|
76
|
+
if (startLine === 1) {
|
|
77
|
+
return {
|
|
78
|
+
text: "File is empty. Use the write tool to create initial content."
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use the write tool to create initial content.`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (startLine > totalLines) {
|
|
88
|
+
return {
|
|
89
|
+
text: `Offset ${startLine} is beyond end of file (${totalLines} lines total). Use offset=1 to read from the start, or offset=${totalLines} to read the last line.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const limit = normalizePositiveInteger(options.limit, "limit");
|
|
94
|
+
const endIdx = limit
|
|
95
|
+
? Math.min(startLine - 1 + limit, totalLines)
|
|
96
|
+
: totalLines;
|
|
97
|
+
const selected = allLines.slice(startLine - 1, endIdx);
|
|
98
|
+
const formatted = options.raw ? selected.join("\n") : formatHashlineRegion(allLines, startLine, endIdx);
|
|
99
|
+
|
|
100
|
+
const truncation = truncateHead(formatted);
|
|
101
|
+
if (truncation.firstLineExceedsLimit) {
|
|
102
|
+
return {
|
|
103
|
+
text: `[Line ${startLine} exceeds ${formatSize(truncation.maxBytes)}.${options.raw ? "" : " Hashline output requires full lines; cannot compute hashes for a truncated preview."}]`,
|
|
104
|
+
|
|
105
|
+
truncation,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let preview = truncation.content;
|
|
110
|
+
let nextOffset: number | undefined;
|
|
111
|
+
if (truncation.truncated) {
|
|
112
|
+
const endLineDisplay = startLine + truncation.outputLines - 1;
|
|
113
|
+
nextOffset = endLineDisplay + 1;
|
|
114
|
+
if (truncation.truncatedBy === "lines") {
|
|
115
|
+
preview += `\n\n[Showing lines ${startLine}-${endLineDisplay} of ${totalLines}. Use offset=${nextOffset} to continue.]`;
|
|
116
|
+
} else {
|
|
117
|
+
preview += `\n\n[Showing lines ${startLine}-${endLineDisplay} of ${totalLines} (${formatSize(truncation.maxBytes)} limit). Use offset=${nextOffset} to continue.]`;
|
|
118
|
+
}
|
|
119
|
+
} else if (endIdx < totalLines) {
|
|
120
|
+
nextOffset = endIdx + 1;
|
|
121
|
+
preview += `\n\n[Showing lines ${startLine}-${endIdx} of ${totalLines}. Use offset=${nextOffset} to continue.]`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
text: preview,
|
|
126
|
+
truncation: truncation.truncated ? truncation : undefined,
|
|
127
|
+
...(nextOffset !== undefined ? { nextOffset } : {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function registerReadTool(pi: ExtensionAPI): void {
|
|
132
|
+
pi.registerTool({
|
|
133
|
+
name: "read",
|
|
134
|
+
label: "Read",
|
|
135
|
+
description: READ_DESC,
|
|
136
|
+
promptSnippet: READ_PROMPT_SNIPPET,
|
|
137
|
+
promptGuidelines: READ_PROMPT_GUIDELINES,
|
|
138
|
+
parameters: Type.Object({
|
|
139
|
+
path: Type.String({
|
|
140
|
+
description: "Path to the file to read (relative or absolute)",
|
|
141
|
+
}),
|
|
142
|
+
offset: Type.Optional(
|
|
143
|
+
Type.Integer({
|
|
144
|
+
minimum: 1,
|
|
145
|
+
description: "Line number to start reading from (1-indexed)",
|
|
146
|
+
}),
|
|
147
|
+
),
|
|
148
|
+
limit: Type.Optional(
|
|
149
|
+
Type.Integer({
|
|
150
|
+
minimum: 1,
|
|
151
|
+
description: "Maximum number of lines to read",
|
|
152
|
+
}),
|
|
153
|
+
),
|
|
154
|
+
raw: Type.Optional(
|
|
155
|
+
Type.Boolean({
|
|
156
|
+
description: "Return raw text without LINE#HASH anchors, saving tokens. Don't use if you plan to edit this file.",
|
|
157
|
+
}),
|
|
158
|
+
),
|
|
159
|
+
}),
|
|
160
|
+
|
|
161
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
162
|
+
const rawPath = params.path;
|
|
163
|
+
const absolutePath = resolveToCwd(rawPath, ctx.cwd);
|
|
164
|
+
|
|
165
|
+
throwIfAborted(signal);
|
|
166
|
+
try {
|
|
167
|
+
await fsAccess(absolutePath, constants.R_OK);
|
|
168
|
+
} catch (error: unknown) {
|
|
169
|
+
const code = error instanceof Error
|
|
170
|
+
? (error as NodeJS.ErrnoException).code
|
|
171
|
+
: undefined;
|
|
172
|
+
if (code === "ENOENT") {
|
|
173
|
+
throw new Error(`File not found: ${rawPath}`);
|
|
174
|
+
}
|
|
175
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
176
|
+
throw new Error(`File is not readable: ${rawPath}`);
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`Cannot access file: ${rawPath}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
throwIfAborted(signal);
|
|
182
|
+
const file = await loadFileKindAndText(absolutePath);
|
|
183
|
+
if (file.kind === "directory") {
|
|
184
|
+
const entries = await fsReaddir(absolutePath);
|
|
185
|
+
const listing = entries
|
|
186
|
+
.slice(0, 50)
|
|
187
|
+
.map((name) => ` ${name}`)
|
|
188
|
+
.join("\n");
|
|
189
|
+
const cap = entries.length > 50 ? `\n ... and ${entries.length - 50} more` : "";
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Path is a directory: ${rawPath}\n${listing}${cap}\n\nUse ls to explore further or read a specific file.`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (file.kind === "binary") {
|
|
196
|
+
throw new Error(`Path is a binary file: ${rawPath} (${file.description}). Read only supports text files and supported images.`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (file.kind === "image") {
|
|
200
|
+
const builtinRead = createReadTool(ctx.cwd);
|
|
201
|
+
return builtinRead.execute(_toolCallId, params, signal, _onUpdate, ctx);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
throwIfAborted(signal);
|
|
205
|
+
const normalized = normalizeToLF(stripBom(file.text).text);
|
|
206
|
+
const preview = formatHashlineReadPreview(normalized, {
|
|
207
|
+
offset: params.offset,
|
|
208
|
+
limit: params.limit,
|
|
209
|
+
raw: params.raw,
|
|
210
|
+
});
|
|
211
|
+
const snapshot = await getFileSnapshot(absolutePath);
|
|
212
|
+
|
|
213
|
+
// A U+FFFD anywhere in the decoded text means the file held bytes that
|
|
214
|
+
// are not valid UTF-8 (CP1251, GBK, …). Editing rewrites the whole file
|
|
215
|
+
// as UTF-8, so those bytes are lost. Warn once on read — the model can
|
|
216
|
+
// then iconv the file back afterwards. Detect on the full text, not the
|
|
217
|
+
// paged slice, so an out-of-view bad byte still surfaces.
|
|
218
|
+
const previewText = normalized.includes("\uFFFD")
|
|
219
|
+
? `${preview.text}\n\n[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]`
|
|
220
|
+
: preview.text;
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
content: [{ type: "text", text: previewText }],
|
|
224
|
+
details: {
|
|
225
|
+
truncation: preview.truncation,
|
|
226
|
+
// snapshotId remains in details for host UI (e.g. "file changed since
|
|
227
|
+
// last view"). It is NOT echoed in text — the LLM no longer needs it.
|
|
228
|
+
snapshotId: snapshot.snapshotId,
|
|
229
|
+
...(preview.nextOffset !== undefined ? { nextOffset: preview.nextOffset } : {}),
|
|
230
|
+
// Phase 2 C — host-only observability. Truncated reads usually mean
|
|
231
|
+
// a follow-up read with `offset = next_offset` is coming.
|
|
232
|
+
metrics: {
|
|
233
|
+
truncated: !!preview.truncation,
|
|
234
|
+
...(preview.nextOffset !== undefined ? { next_offset: preview.nextOffset } : {}),
|
|
235
|
+
},
|
|
236
|
+
package: PACKAGE_INFO,
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export function throwIfAborted(signal?: AbortSignal): void {
|
|
2
|
-
if (signal?.aborted) throw new Error("Operation aborted");
|
|
3
|
-
}
|
|
1
|
+
export function throwIfAborted(signal?: AbortSignal): void {
|
|
2
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
3
|
+
}
|
package/src/snapshot.ts
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
import { stat } from "fs/promises";
|
|
2
|
-
import { resolveMutationTargetPath } from "./fs-write";
|
|
3
|
-
|
|
4
|
-
export type SnapshotInfo = {
|
|
5
|
-
snapshotId: string;
|
|
6
|
-
mtimeMs: number;
|
|
7
|
-
size: number;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
function formatSnapshotId(canonicalPath: string, info: { mtimeMs: number; size: number }): string {
|
|
11
|
-
return `v1|${canonicalPath}|${info.mtimeMs}|${info.size}`;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Stat the file and return its current snapshot fingerprint.
|
|
16
|
-
*
|
|
17
|
-
* The snapshot is exposed only via `details.snapshotId` for host UIs (e.g.
|
|
18
|
-
* "file changed since last view"). It is no longer used to reject edits or
|
|
19
|
-
* surfaced in tool text — the LLM does not need to track it.
|
|
20
|
-
*/
|
|
21
|
-
export async function getFileSnapshot(absolutePath: string): Promise<SnapshotInfo> {
|
|
22
|
-
const canonicalPath = await resolveMutationTargetPath(absolutePath);
|
|
23
|
-
const stats = await stat(canonicalPath);
|
|
24
|
-
return {
|
|
25
|
-
snapshotId: formatSnapshotId(canonicalPath, stats),
|
|
26
|
-
mtimeMs: stats.mtimeMs,
|
|
27
|
-
size: stats.size,
|
|
28
|
-
};
|
|
29
|
-
}
|
|
1
|
+
import { stat } from "fs/promises";
|
|
2
|
+
import { resolveMutationTargetPath } from "./fs-write";
|
|
3
|
+
|
|
4
|
+
export type SnapshotInfo = {
|
|
5
|
+
snapshotId: string;
|
|
6
|
+
mtimeMs: number;
|
|
7
|
+
size: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function formatSnapshotId(canonicalPath: string, info: { mtimeMs: number; size: number }): string {
|
|
11
|
+
return `v1|${canonicalPath}|${info.mtimeMs}|${info.size}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Stat the file and return its current snapshot fingerprint.
|
|
16
|
+
*
|
|
17
|
+
* The snapshot is exposed only via `details.snapshotId` for host UIs (e.g.
|
|
18
|
+
* "file changed since last view"). It is no longer used to reject edits or
|
|
19
|
+
* surfaced in tool text — the LLM does not need to track it.
|
|
20
|
+
*/
|
|
21
|
+
export async function getFileSnapshot(absolutePath: string): Promise<SnapshotInfo> {
|
|
22
|
+
const canonicalPath = await resolveMutationTargetPath(absolutePath);
|
|
23
|
+
const stats = await stat(canonicalPath);
|
|
24
|
+
return {
|
|
25
|
+
snapshotId: formatSnapshotId(canonicalPath, stats),
|
|
26
|
+
mtimeMs: stats.mtimeMs,
|
|
27
|
+
size: stats.size,
|
|
28
|
+
};
|
|
29
|
+
}
|