pi-hashline-edit-pro 0.2.0
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 -0
- package/README.md +143 -0
- package/index.ts +64 -0
- package/package.json +52 -0
- package/prompts/edit-snippet.md +1 -0
- package/prompts/edit.md +58 -0
- package/prompts/read-guidelines.md +3 -0
- package/prompts/read-snippet.md +1 -0
- package/prompts/read.md +28 -0
- package/src/edit-diff.ts +234 -0
- package/src/edit-normalize.ts +68 -0
- package/src/edit-render.ts +280 -0
- package/src/edit-response.ts +531 -0
- package/src/edit.ts +689 -0
- package/src/file-kind.ts +161 -0
- package/src/fs-write.ts +105 -0
- package/src/hashline/apply.ts +660 -0
- package/src/hashline/hash.ts +192 -0
- package/src/hashline/index.ts +70 -0
- package/src/hashline/parse.ts +116 -0
- package/src/hashline/resolve.ts +552 -0
- package/src/path-utils.ts +13 -0
- package/src/read.ts +256 -0
- package/src/runtime.ts +3 -0
- package/src/snapshot.ts +29 -0
- package/src/utils.ts +11 -0
package/src/file-kind.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { open as fsOpen, stat as fsStat } from "fs/promises";
|
|
2
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
3
|
+
|
|
4
|
+
const IMAGE_MIME_TYPES = new Set<string>([
|
|
5
|
+
"image/jpeg",
|
|
6
|
+
"image/png",
|
|
7
|
+
"image/gif",
|
|
8
|
+
"image/webp",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const TEXT_LIKE_MIME_TYPES = new Set<string>([
|
|
12
|
+
"application/rtf",
|
|
13
|
+
"application/xml",
|
|
14
|
+
"application/x-ms-regedit",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function isTextLikeMimeType(mimeType: string): boolean {
|
|
18
|
+
return mimeType.startsWith("text/") || TEXT_LIKE_MIME_TYPES.has(mimeType);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const FILE_TYPE_SNIFF_BYTES = 8192;
|
|
22
|
+
|
|
23
|
+
export type FileKind =
|
|
24
|
+
| { kind: "directory" }
|
|
25
|
+
| { kind: "image"; mimeType: string }
|
|
26
|
+
| { kind: "text" }
|
|
27
|
+
| { kind: "binary"; description: string };
|
|
28
|
+
|
|
29
|
+
export type LoadedFile =
|
|
30
|
+
| { kind: "directory" }
|
|
31
|
+
| { kind: "image"; mimeType: string }
|
|
32
|
+
| { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
|
|
33
|
+
| { kind: "binary"; description: string };
|
|
34
|
+
|
|
35
|
+
function hasNullByte(buffer: Uint8Array): boolean {
|
|
36
|
+
return buffer.includes(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function loadFileKindAndText(
|
|
40
|
+
filePath: string,
|
|
41
|
+
): Promise<LoadedFile> {
|
|
42
|
+
const pathStat = await fsStat(filePath);
|
|
43
|
+
if (pathStat.isDirectory()) {
|
|
44
|
+
return { kind: "directory" };
|
|
45
|
+
}
|
|
46
|
+
if (!pathStat.isFile()) {
|
|
47
|
+
return {
|
|
48
|
+
kind: "binary",
|
|
49
|
+
description: "unsupported file type",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fileHandle = await fsOpen(filePath, "r");
|
|
54
|
+
try {
|
|
55
|
+
const buffer = Buffer.alloc(FILE_TYPE_SNIFF_BYTES);
|
|
56
|
+
const { bytesRead } = await fileHandle.read(
|
|
57
|
+
buffer,
|
|
58
|
+
0,
|
|
59
|
+
FILE_TYPE_SNIFF_BYTES,
|
|
60
|
+
0,
|
|
61
|
+
);
|
|
62
|
+
if (bytesRead === 0) {
|
|
63
|
+
return { kind: "text", text: "" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const sample = buffer.subarray(0, bytesRead);
|
|
67
|
+
const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
|
|
68
|
+
if (
|
|
69
|
+
detectedMimeType !== undefined &&
|
|
70
|
+
!isTextLikeMimeType(detectedMimeType)
|
|
71
|
+
) {
|
|
72
|
+
if (IMAGE_MIME_TYPES.has(detectedMimeType)) {
|
|
73
|
+
return { kind: "image", mimeType: detectedMimeType };
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
kind: "binary",
|
|
77
|
+
description: detectedMimeType,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (hasNullByte(sample)) {
|
|
81
|
+
return {
|
|
82
|
+
kind: "binary",
|
|
83
|
+
description: "null bytes detected",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Non-fatal decode, matching pi's built-in tools: invalid UTF-8 becomes
|
|
88
|
+
// U+FFFD rather than rejecting the file. The null-byte guard above is the
|
|
89
|
+
// only signal we treat as binary, so non-UTF-8 text (CP1251, GBK, …) reads
|
|
90
|
+
// instead of forcing the model to bypass hashline with raw shell edits.
|
|
91
|
+
// Track fatal-decoder failures separately so a literal, valid U+FFFD in a
|
|
92
|
+
// UTF-8 file does not get mistaken for lossy decoding.
|
|
93
|
+
const decoder = new TextDecoder("utf-8");
|
|
94
|
+
const fatalDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
95
|
+
let hadUtf8DecodeErrors = false;
|
|
96
|
+
const noteUtf8DecodeErrors = (chunk?: Uint8Array): void => {
|
|
97
|
+
if (hadUtf8DecodeErrors) return;
|
|
98
|
+
try {
|
|
99
|
+
fatalDecoder.decode(chunk, { stream: chunk !== undefined });
|
|
100
|
+
} catch (error: unknown) {
|
|
101
|
+
if (error instanceof TypeError) {
|
|
102
|
+
hadUtf8DecodeErrors = true;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
noteUtf8DecodeErrors(sample);
|
|
110
|
+
const parts: string[] = [decoder.decode(sample, { stream: true })];
|
|
111
|
+
|
|
112
|
+
let position = bytesRead;
|
|
113
|
+
while (true) {
|
|
114
|
+
const { bytesRead: chunkBytesRead } = await fileHandle.read(
|
|
115
|
+
buffer,
|
|
116
|
+
0,
|
|
117
|
+
FILE_TYPE_SNIFF_BYTES,
|
|
118
|
+
position,
|
|
119
|
+
);
|
|
120
|
+
if (chunkBytesRead === 0) {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const chunk = buffer.subarray(0, chunkBytesRead);
|
|
125
|
+
if (hasNullByte(chunk)) {
|
|
126
|
+
return {
|
|
127
|
+
kind: "binary",
|
|
128
|
+
description: "null bytes detected",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
noteUtf8DecodeErrors(chunk);
|
|
132
|
+
parts.push(decoder.decode(chunk, { stream: true }));
|
|
133
|
+
position += chunkBytesRead;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
noteUtf8DecodeErrors();
|
|
137
|
+
parts.push(decoder.decode());
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
kind: "text",
|
|
141
|
+
text: parts.join(""),
|
|
142
|
+
...(hadUtf8DecodeErrors ? { hadUtf8DecodeErrors: true as const } : {}),
|
|
143
|
+
};
|
|
144
|
+
} finally {
|
|
145
|
+
await fileHandle.close();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function classifyFileKind(filePath: string): Promise<FileKind> {
|
|
150
|
+
const loaded = await loadFileKindAndText(filePath);
|
|
151
|
+
switch (loaded.kind) {
|
|
152
|
+
case "directory":
|
|
153
|
+
return loaded;
|
|
154
|
+
case "image":
|
|
155
|
+
return loaded;
|
|
156
|
+
case "binary":
|
|
157
|
+
return loaded;
|
|
158
|
+
case "text":
|
|
159
|
+
return { kind: "text" };
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/fs-write.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import {
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
readlink,
|
|
7
|
+
rename,
|
|
8
|
+
stat,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "fs/promises";
|
|
11
|
+
import { dirname, join, parse, resolve, sep } from "path";
|
|
12
|
+
|
|
13
|
+
export async function resolveMutationTargetPath(path: string): Promise<string> {
|
|
14
|
+
const absolutePath = resolve(path);
|
|
15
|
+
const { root } = parse(absolutePath);
|
|
16
|
+
const parts = absolutePath
|
|
17
|
+
.slice(root.length)
|
|
18
|
+
.split(sep)
|
|
19
|
+
.filter((part) => part.length > 0);
|
|
20
|
+
const visitedSymlinks = new Set<string>();
|
|
21
|
+
|
|
22
|
+
async function resolveFromParts(
|
|
23
|
+
currentPath: string,
|
|
24
|
+
remainingParts: string[],
|
|
25
|
+
): Promise<string> {
|
|
26
|
+
if (remainingParts.length === 0) {
|
|
27
|
+
return currentPath;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const [nextPart, ...tail] = remainingParts;
|
|
31
|
+
const candidatePath = join(currentPath, nextPart);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const candidateStats = await lstat(candidatePath);
|
|
35
|
+
if (!candidateStats.isSymbolicLink()) {
|
|
36
|
+
return resolveFromParts(candidatePath, tail);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (visitedSymlinks.has(candidatePath)) {
|
|
40
|
+
const error = new Error(
|
|
41
|
+
`Too many symbolic links while resolving ${path}`,
|
|
42
|
+
) as NodeJS.ErrnoException;
|
|
43
|
+
error.code = "ELOOP";
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
visitedSymlinks.add(candidatePath);
|
|
47
|
+
|
|
48
|
+
const linkTargetPath = resolve(
|
|
49
|
+
dirname(candidatePath),
|
|
50
|
+
await readlink(candidatePath),
|
|
51
|
+
);
|
|
52
|
+
const targetParts = linkTargetPath
|
|
53
|
+
.slice(parse(linkTargetPath).root.length)
|
|
54
|
+
.split(sep)
|
|
55
|
+
.filter((part) => part.length > 0);
|
|
56
|
+
return resolveFromParts(parse(linkTargetPath).root, [
|
|
57
|
+
...targetParts,
|
|
58
|
+
...tail,
|
|
59
|
+
]);
|
|
60
|
+
} catch (error: unknown) {
|
|
61
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
62
|
+
return join(candidatePath, ...tail);
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return resolveFromParts(root, parts);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function writeFileAtomically(
|
|
72
|
+
path: string,
|
|
73
|
+
content: string,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const targetPath = await resolveMutationTargetPath(path);
|
|
76
|
+
|
|
77
|
+
let existingStats: Awaited<ReturnType<typeof stat>> | null = null;
|
|
78
|
+
try {
|
|
79
|
+
existingStats = await stat(targetPath);
|
|
80
|
+
} catch (error: unknown) {
|
|
81
|
+
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (existingStats && existingStats.nlink > 1) {
|
|
87
|
+
await writeFile(targetPath, content, "utf-8");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const dir = dirname(targetPath);
|
|
92
|
+
const tempPath = join(dir, `.tmp-${randomUUID()}`);
|
|
93
|
+
await mkdir(dir, { recursive: true });
|
|
94
|
+
const tempHandle = await open(tempPath, "wx", 0o600);
|
|
95
|
+
try {
|
|
96
|
+
await tempHandle.writeFile(content, "utf-8");
|
|
97
|
+
if (existingStats) {
|
|
98
|
+
await tempHandle.chmod(existingStats.mode & 0o7777);
|
|
99
|
+
}
|
|
100
|
+
} finally {
|
|
101
|
+
await tempHandle.close();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await rename(tempPath, targetPath);
|
|
105
|
+
}
|