@oh-my-pi/pi-coding-agent 15.5.3 → 15.5.4
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/CHANGELOG.md +29 -0
- package/dist/types/config/settings-schema.d.ts +27 -0
- package/dist/types/config.d.ts +31 -5
- package/dist/types/edit/file-snapshot-store.d.ts +18 -0
- package/dist/types/edit/hashline/diff.d.ts +30 -0
- package/dist/types/edit/hashline/execute.d.ts +29 -0
- package/dist/types/edit/hashline/filesystem.d.ts +57 -0
- package/dist/types/edit/hashline/index.d.ts +4 -0
- package/dist/types/edit/hashline/params.d.ts +12 -0
- package/dist/types/edit/index.d.ts +4 -3
- package/dist/types/edit/normalize.d.ts +4 -16
- package/dist/types/index.d.ts +0 -1
- package/dist/types/tools/index.d.ts +6 -5
- package/dist/types/tools/path-utils.d.ts +18 -0
- package/dist/types/utils/changelog.d.ts +8 -3
- package/package.json +8 -15
- package/src/config/settings-schema.ts +32 -0
- package/src/config.ts +42 -15
- package/src/edit/file-snapshot-store.ts +22 -0
- package/src/edit/hashline/diff.ts +88 -0
- package/src/edit/hashline/execute.ts +188 -0
- package/src/edit/hashline/filesystem.ts +129 -0
- package/src/edit/hashline/index.ts +4 -0
- package/src/edit/hashline/params.ts +11 -0
- package/src/edit/index.ts +7 -15
- package/src/edit/normalize.ts +11 -41
- package/src/edit/renderer.ts +1 -1
- package/src/edit/streaming.ts +8 -9
- package/src/index.ts +0 -1
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/sdk.ts +8 -1
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/ast-grep.ts +3 -3
- package/src/tools/index.ts +6 -5
- package/src/tools/path-utils.ts +81 -0
- package/src/tools/read.ts +14 -72
- package/src/tools/search.ts +136 -17
- package/src/tools/write.ts +3 -3
- package/src/utils/changelog.ts +11 -3
- package/src/utils/file-mentions.ts +1 -1
- package/dist/types/edit/file-read-cache.d.ts +0 -36
- package/dist/types/hashline/anchors.d.ts +0 -26
- package/dist/types/hashline/apply.d.ts +0 -14
- package/dist/types/hashline/constants.d.ts +0 -48
- package/dist/types/hashline/diff-preview.d.ts +0 -2
- package/dist/types/hashline/diff.d.ts +0 -16
- package/dist/types/hashline/execute.d.ts +0 -4
- package/dist/types/hashline/executor.d.ts +0 -56
- package/dist/types/hashline/hash.d.ts +0 -76
- package/dist/types/hashline/index.d.ts +0 -14
- package/dist/types/hashline/input.d.ts +0 -4
- package/dist/types/hashline/prefixes.d.ts +0 -7
- package/dist/types/hashline/recovery.d.ts +0 -21
- package/dist/types/hashline/stream.d.ts +0 -2
- package/dist/types/hashline/tokenizer.d.ts +0 -94
- package/dist/types/hashline/types.d.ts +0 -75
- package/src/edit/file-read-cache.ts +0 -138
- package/src/hashline/anchors.ts +0 -104
- package/src/hashline/apply.ts +0 -790
- package/src/hashline/bigrams.json +0 -649
- package/src/hashline/constants.ts +0 -60
- package/src/hashline/diff-preview.ts +0 -42
- package/src/hashline/diff.ts +0 -82
- package/src/hashline/execute.ts +0 -334
- package/src/hashline/executor.ts +0 -347
- package/src/hashline/grammar.lark +0 -22
- package/src/hashline/hash.ts +0 -131
- package/src/hashline/index.ts +0 -14
- package/src/hashline/input.ts +0 -137
- package/src/hashline/prefixes.ts +0 -111
- package/src/hashline/recovery.ts +0 -139
- package/src/hashline/stream.ts +0 -123
- package/src/hashline/tokenizer.ts +0 -473
- package/src/hashline/types.ts +0 -66
- package/src/prompts/tools/hashline.md +0 -83
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import type { CompactHashlineDiffOptions, CompactHashlineDiffPreview } from "./types";
|
|
2
|
-
|
|
3
|
-
export function buildCompactHashlineDiffPreview(
|
|
4
|
-
diff: string,
|
|
5
|
-
_options: CompactHashlineDiffOptions = {},
|
|
6
|
-
): CompactHashlineDiffPreview {
|
|
7
|
-
const lines = diff.length === 0 ? [] : diff.split("\n");
|
|
8
|
-
let addedLines = 0;
|
|
9
|
-
let removedLines = 0;
|
|
10
|
-
|
|
11
|
-
// `generateDiffString` numbers `+` lines with the post-edit line number,
|
|
12
|
-
// `-` lines with the pre-edit line number, and context lines with the
|
|
13
|
-
// pre-edit line number. To emit fresh line numbers usable for follow-up edits,
|
|
14
|
-
// we convert context-line numbers to post-edit positions by tracking the
|
|
15
|
-
// running offset (added so far - removed so far) as we walk the diff.
|
|
16
|
-
const formatted = lines.map(line => {
|
|
17
|
-
const kind = line[0];
|
|
18
|
-
if (kind !== "+" && kind !== "-" && kind !== " ") return line;
|
|
19
|
-
|
|
20
|
-
const body = line.slice(1);
|
|
21
|
-
const sep = body.indexOf("|");
|
|
22
|
-
if (sep === -1) return line;
|
|
23
|
-
|
|
24
|
-
const lineNumber = Number.parseInt(body.slice(0, sep), 10);
|
|
25
|
-
const content = body.slice(sep + 1);
|
|
26
|
-
|
|
27
|
-
switch (kind) {
|
|
28
|
-
case "+":
|
|
29
|
-
addedLines++;
|
|
30
|
-
return `+${lineNumber}:${content}`;
|
|
31
|
-
case "-":
|
|
32
|
-
removedLines++;
|
|
33
|
-
return `-${lineNumber}:${content}`;
|
|
34
|
-
default: {
|
|
35
|
-
const newLineNumber = lineNumber + addedLines - removedLines;
|
|
36
|
-
return ` ${newLineNumber}:${content}`;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
return { preview: formatted.join("\n"), addedLines, removedLines };
|
|
42
|
-
}
|
package/src/hashline/diff.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { generateDiffString } from "../edit/diff";
|
|
2
|
-
import { normalizeToLF, stripBom } from "../edit/normalize";
|
|
3
|
-
import { readEditFileText } from "../edit/read-file";
|
|
4
|
-
import { resolveToCwd } from "../tools/path-utils";
|
|
5
|
-
import { applyHashlineEdits } from "./apply";
|
|
6
|
-
import { parseHashline } from "./executor";
|
|
7
|
-
import { computeFileHash } from "./hash";
|
|
8
|
-
import { splitHashlineInputs } from "./input";
|
|
9
|
-
import type { HashlineApplyOptions, HashlineEdit, HashlineInputSection } from "./types";
|
|
10
|
-
|
|
11
|
-
async function readHashlineFileText(
|
|
12
|
-
_file: { text(): Promise<string> },
|
|
13
|
-
absolutePath: string,
|
|
14
|
-
pathText: string,
|
|
15
|
-
): Promise<string> {
|
|
16
|
-
try {
|
|
17
|
-
return await readEditFileText(absolutePath, pathText);
|
|
18
|
-
} catch (error) {
|
|
19
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
20
|
-
throw new Error(message || `Unable to read ${pathText}`);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function hasAnchorScopedEdit(edits: readonly HashlineEdit[]): boolean {
|
|
25
|
-
return edits.some(edit => {
|
|
26
|
-
if (edit.kind === "delete") return true;
|
|
27
|
-
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function validateSectionHash(
|
|
32
|
-
section: HashlineInputSection,
|
|
33
|
-
text: string,
|
|
34
|
-
edits: readonly HashlineEdit[],
|
|
35
|
-
): string | null {
|
|
36
|
-
if (section.fileHash === undefined) {
|
|
37
|
-
return hasAnchorScopedEdit(edits)
|
|
38
|
-
? `Missing hashline file hash for anchored edit to ${section.path}; use \`¶${section.path}#hash\` from your latest read.`
|
|
39
|
-
: null;
|
|
40
|
-
}
|
|
41
|
-
const currentHash = computeFileHash(text);
|
|
42
|
-
if (currentHash === section.fileHash) return null;
|
|
43
|
-
return `Hashline file hash mismatch for ${section.path}: section is bound to #${section.fileHash}, but current file hashes to #${currentHash}; re-read and try again.`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export async function computeHashlineSectionDiff(
|
|
47
|
-
section: HashlineInputSection,
|
|
48
|
-
cwd: string,
|
|
49
|
-
options: HashlineApplyOptions = {},
|
|
50
|
-
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
|
51
|
-
try {
|
|
52
|
-
const absolutePath = resolveToCwd(section.path, cwd);
|
|
53
|
-
const rawContent = await readHashlineFileText(Bun.file(absolutePath), absolutePath, section.path);
|
|
54
|
-
const { text: content } = stripBom(rawContent);
|
|
55
|
-
const normalized = normalizeToLF(content);
|
|
56
|
-
const { edits } = parseHashline(section.diff);
|
|
57
|
-
const hashError = validateSectionHash(section, normalized, edits);
|
|
58
|
-
if (hashError) return { error: hashError };
|
|
59
|
-
const result = applyHashlineEdits(normalized, edits, options);
|
|
60
|
-
if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
|
|
61
|
-
return generateDiffString(normalized, result.lines);
|
|
62
|
-
} catch (err) {
|
|
63
|
-
return { error: err instanceof Error ? err.message : String(err) };
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export async function computeHashlineDiff(
|
|
68
|
-
input: { input: string; path?: string },
|
|
69
|
-
cwd: string,
|
|
70
|
-
options: HashlineApplyOptions = {},
|
|
71
|
-
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
|
72
|
-
let sections: HashlineInputSection[];
|
|
73
|
-
try {
|
|
74
|
-
sections = splitHashlineInputs(input.input, { cwd, path: input.path });
|
|
75
|
-
} catch (err) {
|
|
76
|
-
return { error: err instanceof Error ? err.message : String(err) };
|
|
77
|
-
}
|
|
78
|
-
if (sections.length !== 1) {
|
|
79
|
-
return { error: "Streaming diff preview supports exactly one hashline section." };
|
|
80
|
-
}
|
|
81
|
-
return computeHashlineSectionDiff(sections[0], cwd, options);
|
|
82
|
-
}
|
package/src/hashline/execute.ts
DELETED
|
@@ -1,334 +0,0 @@
|
|
|
1
|
-
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
2
|
-
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
3
|
-
import { generateDiffString } from "../edit/diff";
|
|
4
|
-
import { getFileReadCache } from "../edit/file-read-cache";
|
|
5
|
-
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../edit/normalize";
|
|
6
|
-
import { readEditFileText, serializeEditFileText } from "../edit/read-file";
|
|
7
|
-
import type { EditToolDetails } from "../edit/renderer";
|
|
8
|
-
import type { ToolSession } from "../tools";
|
|
9
|
-
import { assertEditableFileContent } from "../tools/auto-generated-guard";
|
|
10
|
-
import { invalidateFsScanAfterWrite } from "../tools/fs-cache-invalidation";
|
|
11
|
-
import { outputMeta } from "../tools/output-meta";
|
|
12
|
-
import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard";
|
|
13
|
-
import { HashlineMismatchError } from "./anchors";
|
|
14
|
-
import { applyHashlineEdits, type HashlineApplyResult } from "./apply";
|
|
15
|
-
import { buildCompactHashlineDiffPreview } from "./diff-preview";
|
|
16
|
-
import { parseHashline } from "./executor";
|
|
17
|
-
import { computeFileHash, formatHashlineHeader } from "./hash";
|
|
18
|
-
import { splitHashlineInputs } from "./input";
|
|
19
|
-
import { tryRecoverHashlineWithCache } from "./recovery";
|
|
20
|
-
import type {
|
|
21
|
-
ExecuteHashlineSingleOptions,
|
|
22
|
-
HashlineApplyOptions,
|
|
23
|
-
HashlineEdit,
|
|
24
|
-
HashlineInputSection,
|
|
25
|
-
hashlineEditParamsSchema,
|
|
26
|
-
} from "./types";
|
|
27
|
-
|
|
28
|
-
interface ReadHashlineFileResult {
|
|
29
|
-
exists: boolean;
|
|
30
|
-
rawContent: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async function readHashlineFile(absolutePath: string, pathText: string): Promise<ReadHashlineFileResult> {
|
|
34
|
-
try {
|
|
35
|
-
return { exists: true, rawContent: await readEditFileText(absolutePath, pathText) };
|
|
36
|
-
} catch (error) {
|
|
37
|
-
if (isEnoent(error)) return { exists: false, rawContent: "" };
|
|
38
|
-
if (error instanceof Error && error.message === `File not found: ${pathText}`)
|
|
39
|
-
return { exists: false, rawContent: "" };
|
|
40
|
-
throw error;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function hasAnchorScopedEdit(edits: HashlineEdit[]): boolean {
|
|
45
|
-
return edits.some(edit => {
|
|
46
|
-
if (edit.kind === "delete") return true;
|
|
47
|
-
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function collectAnchorLines(edits: HashlineEdit[]): number[] {
|
|
52
|
-
const lines = new Set<number>();
|
|
53
|
-
for (const edit of edits) {
|
|
54
|
-
if (edit.kind === "delete") {
|
|
55
|
-
lines.add(edit.anchor.line);
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
|
|
59
|
-
lines.add(edit.cursor.anchor.line);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return [...lines].sort((a, b) => a - b);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function assertSectionHashAllowed(sectionPath: string, fileHash: string | undefined, edits: HashlineEdit[]): void {
|
|
66
|
-
if (fileHash !== undefined || !hasAnchorScopedEdit(edits)) return;
|
|
67
|
-
throw new Error(
|
|
68
|
-
`Missing hashline file hash for anchored edit to ${sectionPath}; use \`¶${sectionPath}#hash\` from your latest read.`,
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function formatNoChangeDiagnostic(pathText: string): string {
|
|
73
|
-
return `Edits to ${pathText} resulted in no changes being made.`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function getHashlineApplyOptions(session: ToolSession): HashlineApplyOptions {
|
|
77
|
-
return {
|
|
78
|
-
autoDropPureInsertDuplicates: session.settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function getTextContent(result: AgentToolResult<EditToolDetails>): string {
|
|
83
|
-
return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function getEditDetails(result: AgentToolResult<EditToolDetails>): EditToolDetails {
|
|
87
|
-
return result.details ?? { diff: "" };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Apply hashline edits with file-hash stale recovery. The section hash gates
|
|
92
|
-
* line-number edits against the version shown to the model; if the live file
|
|
93
|
-
* drifted, snapshot recovery attempts a strict 3-way merge.
|
|
94
|
-
*/
|
|
95
|
-
function applyHashlineEditsWithRecovery(
|
|
96
|
-
session: ToolSession,
|
|
97
|
-
absolutePath: string,
|
|
98
|
-
pathText: string,
|
|
99
|
-
text: string,
|
|
100
|
-
fileHash: string | undefined,
|
|
101
|
-
edits: HashlineEdit[],
|
|
102
|
-
options: HashlineApplyOptions,
|
|
103
|
-
): HashlineApplyResult {
|
|
104
|
-
if (fileHash === undefined) return applyHashlineEdits(text, edits, options);
|
|
105
|
-
|
|
106
|
-
const currentHash = computeFileHash(text);
|
|
107
|
-
if (currentHash === fileHash) return applyHashlineEdits(text, edits, options);
|
|
108
|
-
|
|
109
|
-
const cache = getFileReadCache(session);
|
|
110
|
-
const recovered = tryRecoverHashlineWithCache({
|
|
111
|
-
cache,
|
|
112
|
-
absolutePath,
|
|
113
|
-
currentText: text,
|
|
114
|
-
fileHash,
|
|
115
|
-
edits,
|
|
116
|
-
options,
|
|
117
|
-
});
|
|
118
|
-
if (recovered) {
|
|
119
|
-
return {
|
|
120
|
-
lines: recovered.lines,
|
|
121
|
-
firstChangedLine: recovered.firstChangedLine,
|
|
122
|
-
warnings: recovered.warnings,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
throw new HashlineMismatchError({
|
|
127
|
-
path: pathText,
|
|
128
|
-
expectedFileHash: fileHash,
|
|
129
|
-
actualFileHash: currentHash,
|
|
130
|
-
fileLines: text.split("\n"),
|
|
131
|
-
anchorLines: collectAnchorLines(edits),
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Run all the front-end checks (notebook guard, parse, plan-mode check, file
|
|
137
|
-
* load, edit application) without writing. Used to fail fast before applying
|
|
138
|
-
* any changes in a multi-section batch.
|
|
139
|
-
*/
|
|
140
|
-
async function preflightHashlineSection(options: ExecuteHashlineSingleOptions & HashlineInputSection): Promise<void> {
|
|
141
|
-
const { session, path: sectionPath, fileHash, diff } = options;
|
|
142
|
-
|
|
143
|
-
const absolutePath = resolvePlanPath(session, sectionPath);
|
|
144
|
-
const { edits } = parseHashline(diff);
|
|
145
|
-
assertSectionHashAllowed(sectionPath, fileHash, edits);
|
|
146
|
-
enforcePlanModeWrite(session, sectionPath, { op: "update" });
|
|
147
|
-
|
|
148
|
-
const source = await readHashlineFile(absolutePath, sectionPath);
|
|
149
|
-
if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sectionPath}`);
|
|
150
|
-
if (source.exists) assertEditableFileContent(source.rawContent, sectionPath);
|
|
151
|
-
|
|
152
|
-
const { text } = stripBom(source.rawContent);
|
|
153
|
-
const normalized = normalizeToLF(text);
|
|
154
|
-
const result = applyHashlineEditsWithRecovery(
|
|
155
|
-
session,
|
|
156
|
-
absolutePath,
|
|
157
|
-
sectionPath,
|
|
158
|
-
normalized,
|
|
159
|
-
source.exists ? fileHash : undefined,
|
|
160
|
-
edits,
|
|
161
|
-
getHashlineApplyOptions(session),
|
|
162
|
-
);
|
|
163
|
-
if (normalized === result.lines) throw new Error(formatNoChangeDiagnostic(sectionPath));
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async function executeHashlineSection(
|
|
167
|
-
options: ExecuteHashlineSingleOptions & HashlineInputSection,
|
|
168
|
-
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
|
|
169
|
-
const {
|
|
170
|
-
session,
|
|
171
|
-
path: sourcePath,
|
|
172
|
-
fileHash,
|
|
173
|
-
diff,
|
|
174
|
-
signal,
|
|
175
|
-
batchRequest,
|
|
176
|
-
writethrough,
|
|
177
|
-
beginDeferredDiagnosticsForPath,
|
|
178
|
-
} = options;
|
|
179
|
-
|
|
180
|
-
const absolutePath = resolvePlanPath(session, sourcePath);
|
|
181
|
-
const { edits, warnings: parseWarnings } = parseHashline(diff);
|
|
182
|
-
assertSectionHashAllowed(sourcePath, fileHash, edits);
|
|
183
|
-
enforcePlanModeWrite(session, sourcePath, { op: "update" });
|
|
184
|
-
|
|
185
|
-
const source = await readHashlineFile(absolutePath, sourcePath);
|
|
186
|
-
if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sourcePath}`);
|
|
187
|
-
if (source.exists) assertEditableFileContent(source.rawContent, sourcePath);
|
|
188
|
-
|
|
189
|
-
const { bom, text } = stripBom(source.rawContent);
|
|
190
|
-
const originalEnding = detectLineEnding(text);
|
|
191
|
-
const originalNormalized = normalizeToLF(text);
|
|
192
|
-
const result = applyHashlineEditsWithRecovery(
|
|
193
|
-
session,
|
|
194
|
-
absolutePath,
|
|
195
|
-
sourcePath,
|
|
196
|
-
originalNormalized,
|
|
197
|
-
source.exists ? fileHash : undefined,
|
|
198
|
-
edits,
|
|
199
|
-
getHashlineApplyOptions(session),
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
if (originalNormalized === result.lines) {
|
|
203
|
-
return {
|
|
204
|
-
content: [{ type: "text", text: formatNoChangeDiagnostic(sourcePath) }],
|
|
205
|
-
details: { diff: "", op: "update", meta: outputMeta().get() },
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const finalContent = await serializeEditFileText(
|
|
210
|
-
absolutePath,
|
|
211
|
-
sourcePath,
|
|
212
|
-
bom + restoreLineEndings(result.lines, originalEnding),
|
|
213
|
-
);
|
|
214
|
-
const diagnostics = await writethrough(
|
|
215
|
-
absolutePath,
|
|
216
|
-
finalContent,
|
|
217
|
-
signal,
|
|
218
|
-
Bun.file(absolutePath),
|
|
219
|
-
batchRequest,
|
|
220
|
-
dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
|
|
221
|
-
);
|
|
222
|
-
invalidateFsScanAfterWrite(absolutePath);
|
|
223
|
-
// The post-edit content is the freshest, most authoritative "model view"
|
|
224
|
-
// of the file: the model just received it back as the diff/preview. Cache
|
|
225
|
-
// it so a follow-up edit anchored against this state can still recover
|
|
226
|
-
// if the file is touched out-of-band before the next edit lands.
|
|
227
|
-
const newFileHash = computeFileHash(result.lines);
|
|
228
|
-
getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n"), {
|
|
229
|
-
fullText: result.lines,
|
|
230
|
-
fileHash: newFileHash,
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
const diffResult = generateDiffString(originalNormalized, result.lines);
|
|
234
|
-
const meta = outputMeta()
|
|
235
|
-
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
|
236
|
-
.get();
|
|
237
|
-
const preview = buildCompactHashlineDiffPreview(diffResult.diff);
|
|
238
|
-
|
|
239
|
-
const warnings = [...parseWarnings, ...(result.warnings ?? [])];
|
|
240
|
-
const warningsBlock = warnings.length > 0 ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
|
|
241
|
-
const previewBlock = preview.preview ? `\n${preview.preview}` : "";
|
|
242
|
-
const newHashLine = `\n${formatHashlineHeader(sourcePath, newFileHash)}`;
|
|
243
|
-
const headline = preview.preview
|
|
244
|
-
? `${sourcePath}:`
|
|
245
|
-
: source.exists
|
|
246
|
-
? `Updated ${sourcePath}`
|
|
247
|
-
: `Created ${sourcePath}`;
|
|
248
|
-
|
|
249
|
-
return {
|
|
250
|
-
content: [{ type: "text", text: `${headline}${newHashLine}${previewBlock}${warningsBlock}` }],
|
|
251
|
-
details: {
|
|
252
|
-
diff: diffResult.diff,
|
|
253
|
-
firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
|
|
254
|
-
diagnostics,
|
|
255
|
-
op: source.exists ? "update" : "create",
|
|
256
|
-
meta,
|
|
257
|
-
},
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
export async function executeHashlineSingle(
|
|
262
|
-
options: ExecuteHashlineSingleOptions,
|
|
263
|
-
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
|
|
264
|
-
const sections = mergeSamePathSections(
|
|
265
|
-
splitHashlineInputs(options.input, { cwd: options.session.cwd, path: options.path }),
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
// Fast path: a single section needs no preflight pass.
|
|
269
|
-
if (sections.length === 1) return executeHashlineSection({ ...options, ...sections[0] });
|
|
270
|
-
|
|
271
|
-
// Multi-section: validate everything up front so we don't apply a partial batch.
|
|
272
|
-
for (const section of sections) await preflightHashlineSection({ ...options, ...section });
|
|
273
|
-
|
|
274
|
-
const results = [];
|
|
275
|
-
for (const section of sections) {
|
|
276
|
-
results.push({ path: section.path, result: await executeHashlineSection({ ...options, ...section }) });
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
return {
|
|
280
|
-
content: [{ type: "text", text: results.map(({ result }) => getTextContent(result)).join("\n\n") }],
|
|
281
|
-
details: {
|
|
282
|
-
diff: results.map(({ result }) => getEditDetails(result).diff).join("\n"),
|
|
283
|
-
perFileResults: results.map(({ path: resultPath, result }) => {
|
|
284
|
-
const details = getEditDetails(result);
|
|
285
|
-
return {
|
|
286
|
-
path: resultPath,
|
|
287
|
-
diff: details.diff,
|
|
288
|
-
firstChangedLine: details.firstChangedLine,
|
|
289
|
-
diagnostics: details.diagnostics,
|
|
290
|
-
op: details.op,
|
|
291
|
-
move: details.move,
|
|
292
|
-
meta: details.meta,
|
|
293
|
-
};
|
|
294
|
-
}),
|
|
295
|
-
},
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* Collapse consecutive or interleaved sections targeting the same path into a
|
|
301
|
-
* single section with concatenated diffs. Anchors authored against the same
|
|
302
|
-
* file snapshot must be applied as one batch; otherwise the first sub-edit
|
|
303
|
-
* shifts line numbers out from under the second's anchors and validation fails.
|
|
304
|
-
* Path order is preserved by first occurrence.
|
|
305
|
-
*/
|
|
306
|
-
function mergeSamePathSections(sections: HashlineInputSection[]): HashlineInputSection[] {
|
|
307
|
-
const byPath = new Map<string, { fileHash?: string; diffs: string[] }>();
|
|
308
|
-
for (const section of sections) {
|
|
309
|
-
const existing = byPath.get(section.path);
|
|
310
|
-
if (existing) {
|
|
311
|
-
if (
|
|
312
|
-
existing.fileHash !== undefined &&
|
|
313
|
-
section.fileHash !== undefined &&
|
|
314
|
-
existing.fileHash !== section.fileHash
|
|
315
|
-
) {
|
|
316
|
-
throw new Error(
|
|
317
|
-
`Conflicting hashline file hashes for ${section.path}: #${existing.fileHash} and #${section.fileHash}. Re-read the file and retry with one current header.`,
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
if (existing.fileHash === undefined && section.fileHash !== undefined) existing.fileHash = section.fileHash;
|
|
321
|
-
existing.diffs.push(section.diff);
|
|
322
|
-
continue;
|
|
323
|
-
}
|
|
324
|
-
byPath.set(section.path, {
|
|
325
|
-
...(section.fileHash !== undefined ? { fileHash: section.fileHash } : {}),
|
|
326
|
-
diffs: [section.diff],
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
return Array.from(byPath, ([path, entry]) => ({
|
|
330
|
-
path,
|
|
331
|
-
...(entry.fileHash !== undefined ? { fileHash: entry.fileHash } : {}),
|
|
332
|
-
diff: entry.diffs.join("\n"),
|
|
333
|
-
}));
|
|
334
|
-
}
|