@oh-my-pi/pi-coding-agent 13.19.0 → 14.0.2
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 +266 -1
- package/package.json +86 -20
- package/scripts/format-prompts.ts +2 -2
- package/src/autoresearch/apply-contract-to-state.ts +24 -0
- package/src/autoresearch/contract.ts +0 -44
- package/src/autoresearch/dashboard.ts +1 -2
- package/src/autoresearch/git.ts +91 -0
- package/src/autoresearch/helpers.ts +49 -0
- package/src/autoresearch/index.ts +28 -187
- package/src/autoresearch/prompt.md +26 -9
- package/src/autoresearch/state.ts +0 -6
- package/src/autoresearch/tools/init-experiment.ts +202 -117
- package/src/autoresearch/tools/log-experiment.ts +83 -125
- package/src/autoresearch/tools/run-experiment.ts +48 -10
- package/src/autoresearch/types.ts +2 -2
- package/src/capability/index.ts +4 -2
- package/src/cli/file-processor.ts +3 -3
- package/src/cli/grep-cli.ts +8 -8
- package/src/cli/grievances-cli.ts +78 -0
- package/src/cli/read-cli.ts +67 -0
- package/src/cli/setup-cli.ts +4 -4
- package/src/cli/update-cli.ts +3 -3
- package/src/cli.ts +2 -0
- package/src/commands/grep.ts +6 -1
- package/src/commands/grievances.ts +20 -0
- package/src/commands/read.ts +33 -0
- package/src/commit/agentic/agent.ts +5 -5
- package/src/commit/agentic/index.ts +3 -4
- package/src/commit/agentic/tools/analyze-file.ts +3 -3
- package/src/commit/agentic/validation.ts +1 -1
- package/src/commit/analysis/conventional.ts +4 -4
- package/src/commit/analysis/summary.ts +3 -3
- package/src/commit/changelog/generate.ts +4 -4
- package/src/commit/map-reduce/map-phase.ts +4 -4
- package/src/commit/map-reduce/reduce-phase.ts +4 -4
- package/src/commit/pipeline.ts +3 -4
- package/src/config/prompt-templates.ts +44 -226
- package/src/config/resolve-config-value.ts +4 -2
- package/src/config/settings-schema.ts +54 -2
- package/src/config/settings.ts +25 -26
- package/src/dap/client.ts +674 -0
- package/src/dap/config.ts +150 -0
- package/src/dap/defaults.json +211 -0
- package/src/dap/index.ts +4 -0
- package/src/dap/session.ts +1255 -0
- package/src/dap/types.ts +600 -0
- package/src/debug/log-viewer.ts +3 -2
- package/src/discovery/builtin.ts +1 -2
- package/src/discovery/codex.ts +2 -2
- package/src/discovery/github.ts +2 -1
- package/src/discovery/helpers.ts +2 -2
- package/src/discovery/opencode.ts +2 -2
- package/src/edit/diff.ts +818 -0
- package/src/edit/index.ts +309 -0
- package/src/edit/line-hash.ts +67 -0
- package/src/edit/modes/chunk.ts +454 -0
- package/src/{patch → edit/modes}/hashline.ts +741 -361
- package/src/{patch/applicator.ts → edit/modes/patch.ts} +420 -117
- package/src/{patch/fuzzy.ts → edit/modes/replace.ts} +519 -197
- package/src/{patch → edit}/normalize.ts +97 -76
- package/src/{patch/shared.ts → edit/renderer.ts} +181 -108
- package/src/exec/bash-executor.ts +4 -2
- package/src/exec/idle-timeout-watchdog.ts +126 -0
- package/src/exec/non-interactive-env.ts +5 -0
- package/src/extensibility/custom-commands/bundled/ci-green/index.ts +2 -2
- package/src/extensibility/custom-commands/bundled/review/index.ts +2 -2
- package/src/extensibility/custom-commands/loader.ts +1 -2
- package/src/extensibility/custom-tools/loader.ts +34 -11
- package/src/extensibility/extensions/loader.ts +9 -4
- package/src/extensibility/extensions/runner.ts +24 -1
- package/src/extensibility/extensions/types.ts +1 -1
- package/src/extensibility/hooks/loader.ts +5 -6
- package/src/extensibility/hooks/types.ts +1 -1
- package/src/extensibility/plugins/doctor.ts +2 -1
- package/src/extensibility/slash-commands.ts +3 -7
- package/src/index.ts +2 -1
- package/src/internal-urls/docs-index.generated.ts +11 -11
- package/src/ipy/executor.ts +58 -17
- package/src/ipy/gateway-coordinator.ts +6 -4
- package/src/ipy/kernel.ts +45 -22
- package/src/ipy/runtime.ts +2 -2
- package/src/lsp/client.ts +7 -4
- package/src/lsp/clients/lsp-linter-client.ts +4 -4
- package/src/lsp/config.ts +2 -2
- package/src/lsp/defaults.json +688 -154
- package/src/lsp/index.ts +234 -45
- package/src/lsp/lspmux.ts +2 -2
- package/src/lsp/startup-events.ts +13 -0
- package/src/lsp/types.ts +12 -1
- package/src/lsp/utils.ts +8 -1
- package/src/main.ts +102 -46
- package/src/memories/index.ts +4 -5
- package/src/modes/acp/acp-agent.ts +563 -163
- package/src/modes/acp/acp-event-mapper.ts +9 -1
- package/src/modes/acp/acp-mode.ts +4 -2
- package/src/modes/components/agent-dashboard.ts +3 -4
- package/src/modes/components/diff.ts +6 -7
- package/src/modes/components/read-tool-group.ts +6 -12
- package/src/modes/components/settings-defs.ts +5 -0
- package/src/modes/components/tool-execution.ts +1 -1
- package/src/modes/components/welcome.ts +1 -1
- package/src/modes/controllers/btw-controller.ts +2 -2
- package/src/modes/controllers/command-controller.ts +3 -2
- package/src/modes/controllers/input-controller.ts +12 -8
- package/src/modes/index.ts +20 -2
- package/src/modes/interactive-mode.ts +94 -37
- package/src/modes/rpc/host-tools.ts +186 -0
- package/src/modes/rpc/rpc-client.ts +178 -13
- package/src/modes/rpc/rpc-mode.ts +73 -3
- package/src/modes/rpc/rpc-types.ts +53 -1
- package/src/modes/theme/theme.ts +80 -8
- package/src/modes/types.ts +2 -2
- package/src/prompts/system/system-prompt.md +2 -1
- package/src/prompts/tools/chunk-edit.md +219 -0
- package/src/prompts/tools/debug.md +43 -0
- package/src/prompts/tools/grep.md +3 -0
- package/src/prompts/tools/lsp.md +5 -5
- package/src/prompts/tools/read-chunk.md +17 -0
- package/src/prompts/tools/read.md +19 -5
- package/src/sdk.ts +190 -154
- package/src/secrets/obfuscator.ts +1 -1
- package/src/session/agent-session.ts +306 -256
- package/src/session/agent-storage.ts +12 -12
- package/src/session/compaction/branch-summarization.ts +3 -3
- package/src/session/compaction/compaction.ts +5 -6
- package/src/session/compaction/utils.ts +3 -3
- package/src/session/history-storage.ts +62 -19
- package/src/session/messages.ts +3 -3
- package/src/session/session-dump-format.ts +203 -0
- package/src/session/session-storage.ts +4 -2
- package/src/session/streaming-output.ts +1 -1
- package/src/session/tool-choice-queue.ts +213 -0
- package/src/slash-commands/builtin-registry.ts +56 -8
- package/src/ssh/connection-manager.ts +2 -2
- package/src/ssh/sshfs-mount.ts +5 -5
- package/src/stt/downloader.ts +4 -4
- package/src/stt/recorder.ts +4 -4
- package/src/stt/transcriber.ts +2 -2
- package/src/system-prompt.ts +21 -13
- package/src/task/agents.ts +5 -6
- package/src/task/commands.ts +2 -5
- package/src/task/executor.ts +4 -4
- package/src/task/index.ts +3 -4
- package/src/task/template.ts +2 -2
- package/src/task/worktree.ts +4 -4
- package/src/tools/ask.ts +2 -3
- package/src/tools/ast-edit.ts +7 -7
- package/src/tools/ast-grep.ts +7 -7
- package/src/tools/auto-generated-guard.ts +36 -41
- package/src/tools/await-tool.ts +2 -2
- package/src/tools/bash.ts +5 -23
- package/src/tools/browser.ts +4 -5
- package/src/tools/calculator.ts +2 -3
- package/src/tools/cancel-job.ts +2 -2
- package/src/tools/checkpoint.ts +3 -3
- package/src/tools/debug.ts +1007 -0
- package/src/tools/exit-plan-mode.ts +2 -3
- package/src/tools/fetch.ts +67 -3
- package/src/tools/find.ts +4 -5
- package/src/tools/fs-cache-invalidation.ts +5 -0
- package/src/tools/gemini-image.ts +13 -5
- package/src/tools/gh.ts +10 -11
- package/src/tools/grep.ts +57 -9
- package/src/tools/index.ts +44 -22
- package/src/tools/inspect-image.ts +4 -4
- package/src/tools/output-meta.ts +1 -1
- package/src/tools/python.ts +19 -6
- package/src/tools/read.ts +198 -67
- package/src/tools/render-mermaid.ts +2 -3
- package/src/tools/render-utils.ts +20 -6
- package/src/tools/renderers.ts +3 -1
- package/src/tools/report-tool-issue.ts +80 -0
- package/src/tools/resolve.ts +70 -39
- package/src/tools/search-tool-bm25.ts +2 -2
- package/src/tools/ssh.ts +2 -2
- package/src/tools/todo-write.ts +2 -2
- package/src/tools/tool-timeouts.ts +1 -0
- package/src/tools/write.ts +5 -6
- package/src/tui/tree-list.ts +3 -1
- package/src/utils/clipboard.ts +80 -0
- package/src/utils/commit-message-generator.ts +2 -3
- package/src/utils/edit-mode.ts +49 -0
- package/src/utils/file-display-mode.ts +6 -5
- package/src/utils/file-mentions.ts +8 -7
- package/src/utils/git.ts +4 -4
- package/src/utils/image-loading.ts +98 -0
- package/src/utils/title-generator.ts +2 -3
- package/src/utils/tools-manager.ts +6 -6
- package/src/web/scrapers/choosealicense.ts +1 -1
- package/src/web/search/index.ts +3 -3
- package/src/autoresearch/command-initialize.md +0 -34
- package/src/patch/diff.ts +0 -433
- package/src/patch/index.ts +0 -888
- package/src/patch/parser.ts +0 -532
- package/src/patch/types.ts +0 -292
- package/src/prompts/agents/oracle.md +0 -77
- package/src/tools/pending-action.ts +0 -49
- package/src/utils/child-process.ts +0 -88
- package/src/utils/frontmatter.ts +0 -117
- package/src/utils/image-input.ts +0 -274
- package/src/utils/mime.ts +0 -53
- package/src/utils/prompt-format.ts +0 -170
|
@@ -12,7 +12,33 @@
|
|
|
12
12
|
* Reference format: `"LINENUM#HASH"` (e.g. `"5#aa"`)
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import
|
|
15
|
+
import * as fs from "node:fs/promises";
|
|
16
|
+
import * as nodePath from "node:path";
|
|
17
|
+
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
18
|
+
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
19
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
20
|
+
import type { BunFile } from "bun";
|
|
21
|
+
import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
|
|
22
|
+
import type { ToolSession } from "../../tools";
|
|
23
|
+
import { assertEditableFileContent } from "../../tools/auto-generated-guard";
|
|
24
|
+
import {
|
|
25
|
+
invalidateFsScanAfterDelete,
|
|
26
|
+
invalidateFsScanAfterRename,
|
|
27
|
+
invalidateFsScanAfterWrite,
|
|
28
|
+
} from "../../tools/fs-cache-invalidation";
|
|
29
|
+
import { outputMeta } from "../../tools/output-meta";
|
|
30
|
+
import { resolveToCwd } from "../../tools/path-utils";
|
|
31
|
+
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
|
32
|
+
import { generateDiffString } from "../diff";
|
|
33
|
+
import { computeLineHash, formatLineHash } from "../line-hash";
|
|
34
|
+
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
|
|
35
|
+
import type { EditToolDetails, LspBatchRequest } from "../renderer";
|
|
36
|
+
|
|
37
|
+
export interface HashMismatch {
|
|
38
|
+
line: number;
|
|
39
|
+
expected: string;
|
|
40
|
+
actual: string;
|
|
41
|
+
}
|
|
16
42
|
|
|
17
43
|
export type Anchor = { line: number; hash: string };
|
|
18
44
|
export type HashlineEdit =
|
|
@@ -23,64 +49,188 @@ export type HashlineEdit =
|
|
|
23
49
|
| { op: "append_file"; lines: string[] }
|
|
24
50
|
| { op: "prepend_file"; lines: string[] };
|
|
25
51
|
|
|
26
|
-
const
|
|
52
|
+
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\+?\s*(?:\d+\s*#\s*|#\s*)|\+)\s*[ZPMQVRWSNKTXJBYH]{2}:/;
|
|
53
|
+
const HASHLINE_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*(?:\d+\s*#\s*|#\s*)?[ZPMQVRWSNKTXJBYH]{2}:/;
|
|
54
|
+
const DIFF_PLUS_RE = /^[+](?![+])/;
|
|
55
|
+
|
|
56
|
+
type LinePrefixStats = {
|
|
57
|
+
nonEmpty: number;
|
|
58
|
+
hashPrefixCount: number;
|
|
59
|
+
diffPlusHashPrefixCount: number;
|
|
60
|
+
diffPlusCount: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function collectLinePrefixStats(lines: string[]): LinePrefixStats {
|
|
64
|
+
const stats: LinePrefixStats = {
|
|
65
|
+
nonEmpty: 0,
|
|
66
|
+
hashPrefixCount: 0,
|
|
67
|
+
diffPlusHashPrefixCount: 0,
|
|
68
|
+
diffPlusCount: 0,
|
|
69
|
+
};
|
|
27
70
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
if (line.length === 0) continue;
|
|
73
|
+
stats.nonEmpty++;
|
|
74
|
+
if (HASHLINE_PREFIX_RE.test(line)) stats.hashPrefixCount++;
|
|
75
|
+
if (HASHLINE_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
|
|
76
|
+
if (DIFF_PLUS_RE.test(line)) stats.diffPlusCount++;
|
|
77
|
+
}
|
|
33
78
|
|
|
34
|
-
|
|
79
|
+
return stats;
|
|
80
|
+
}
|
|
35
81
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
82
|
+
export function stripNewLinePrefixes(lines: string[]): string[] {
|
|
83
|
+
const { nonEmpty, hashPrefixCount, diffPlusHashPrefixCount, diffPlusCount } = collectLinePrefixStats(lines);
|
|
84
|
+
if (nonEmpty === 0) return lines;
|
|
85
|
+
|
|
86
|
+
const stripHash = hashPrefixCount > 0 && hashPrefixCount === nonEmpty;
|
|
87
|
+
const stripPlus =
|
|
88
|
+
!stripHash && diffPlusHashPrefixCount === 0 && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
|
|
89
|
+
if (!stripHash && !stripPlus && diffPlusHashPrefixCount === 0) return lines;
|
|
90
|
+
|
|
91
|
+
return lines.map(line => {
|
|
92
|
+
if (stripHash) return line.replace(HASHLINE_PREFIX_RE, "");
|
|
93
|
+
if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
|
|
94
|
+
if (diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(line)) {
|
|
95
|
+
return line.replace(HASHLINE_PREFIX_RE, "");
|
|
96
|
+
}
|
|
97
|
+
return line;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function stripHashlinePrefixes(lines: string[]): string[] {
|
|
102
|
+
const { nonEmpty, hashPrefixCount } = collectLinePrefixStats(lines);
|
|
103
|
+
if (nonEmpty === 0 || hashPrefixCount !== nonEmpty) return lines;
|
|
104
|
+
return lines.map(line => line.replace(HASHLINE_PREFIX_RE, ""));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const linesSchema = Type.Union([
|
|
108
|
+
Type.Array(Type.String(), { description: "content (preferred format)" }),
|
|
109
|
+
Type.String(),
|
|
110
|
+
Type.Null(),
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const locSchema = Type.Union(
|
|
114
|
+
[
|
|
115
|
+
Type.Literal("append"),
|
|
116
|
+
Type.Literal("prepend"),
|
|
117
|
+
Type.Object({ append: Type.String({ description: "anchor" }) }),
|
|
118
|
+
Type.Object({ prepend: Type.String({ description: "anchor" }) }),
|
|
119
|
+
Type.Object({
|
|
120
|
+
range: Type.Object({
|
|
121
|
+
pos: Type.String({ description: "first line to edit (inclusive)" }),
|
|
122
|
+
end: Type.String({ description: "last line to edit (inclusive)" }),
|
|
123
|
+
}),
|
|
124
|
+
}),
|
|
125
|
+
],
|
|
126
|
+
{ description: "insert location" },
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
export const hashlineEditSchema = Type.Object(
|
|
130
|
+
{
|
|
131
|
+
loc: locSchema,
|
|
132
|
+
content: linesSchema,
|
|
133
|
+
},
|
|
134
|
+
{ additionalProperties: false },
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
export const hashlineEditParamsSchema = Type.Object(
|
|
138
|
+
{
|
|
139
|
+
path: Type.String({ description: "path" }),
|
|
140
|
+
edits: Type.Array(hashlineEditSchema, { description: "edits over $path" }),
|
|
141
|
+
delete: Type.Optional(Type.Boolean({ description: "If true, delete $path" })),
|
|
142
|
+
move: Type.Optional(Type.String({ description: "If set, move $path to $move" })),
|
|
143
|
+
},
|
|
144
|
+
{ additionalProperties: false },
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
export type HashlineToolEdit = Static<typeof hashlineEditSchema>;
|
|
148
|
+
export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
|
|
149
|
+
|
|
150
|
+
interface ExecuteHashlineModeOptions {
|
|
151
|
+
session: ToolSession;
|
|
152
|
+
params: HashlineParams;
|
|
153
|
+
signal?: AbortSignal;
|
|
154
|
+
batchRequest?: LspBatchRequest;
|
|
155
|
+
writethrough: WritethroughCallback;
|
|
156
|
+
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
|
157
|
+
}
|
|
46
158
|
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
|
|
159
|
+
export function hashlineParseText(edit: string[] | string | null): string[] {
|
|
160
|
+
if (edit === null) return [];
|
|
161
|
+
if (typeof edit === "string") {
|
|
162
|
+
const normalizedEdit = edit.endsWith("\n") ? edit.slice(0, -1) : edit;
|
|
163
|
+
edit = normalizedEdit.replaceAll("\r", "").split("\n");
|
|
50
164
|
}
|
|
51
|
-
return
|
|
165
|
+
return stripNewLinePrefixes(edit);
|
|
52
166
|
}
|
|
53
167
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
168
|
+
export function isHashlineParams(params: unknown): params is HashlineParams {
|
|
169
|
+
return (
|
|
170
|
+
typeof params === "object" &&
|
|
171
|
+
params !== null &&
|
|
172
|
+
"edits" in params &&
|
|
173
|
+
Array.isArray(params.edits) &&
|
|
174
|
+
(params.edits.length === 0 ||
|
|
175
|
+
(typeof params.edits[0] === "object" && params.edits[0] !== null && "loc" in params.edits[0]))
|
|
176
|
+
);
|
|
59
177
|
}
|
|
60
178
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
179
|
+
function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
|
|
180
|
+
return edits.map(resolveEditAnchor);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function tryParseTag(raw: string): Anchor | undefined {
|
|
184
|
+
try {
|
|
185
|
+
return parseTag(raw);
|
|
186
|
+
} catch {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function requireParsedAnchor(raw: string, op: "append" | "prepend"): Anchor {
|
|
192
|
+
const anchor = tryParseTag(raw);
|
|
193
|
+
if (!anchor) throw new Error(`${op} requires a valid anchor.`);
|
|
194
|
+
return anchor;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function requireParsedRange(range: { pos: string; end: string }): { pos: Anchor; end: Anchor } {
|
|
198
|
+
const pos = tryParseTag(range.pos);
|
|
199
|
+
const end = tryParseTag(range.end);
|
|
200
|
+
if (!pos || !end) throw new Error("range requires valid pos and end anchors.");
|
|
201
|
+
return { pos, end };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resolveEditAnchor(edit: HashlineToolEdit): HashlineEdit {
|
|
205
|
+
const lines = hashlineParseText(edit.content);
|
|
206
|
+
const loc = edit.loc;
|
|
207
|
+
|
|
208
|
+
if (loc === "append") {
|
|
209
|
+
return { op: "append_file", lines };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (loc === "prepend") {
|
|
213
|
+
return { op: "prepend_file", lines };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (typeof loc !== "object") {
|
|
217
|
+
throw new Error(`Invalid loc value: ${JSON.stringify(loc)}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if ("append" in loc) {
|
|
221
|
+
return { op: "append_at", pos: requireParsedAnchor(loc.append, "append"), lines };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if ("prepend" in loc) {
|
|
225
|
+
return { op: "prepend_at", pos: requireParsedAnchor(loc.prepend, "prepend"), lines };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if ("range" in loc) {
|
|
229
|
+
const { pos, end } = requireParsedRange(loc.range);
|
|
230
|
+
return { op: "replace_range", pos, end, lines };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
throw new Error("Unknown loc shape. Expected append, prepend, or range.");
|
|
84
234
|
}
|
|
85
235
|
|
|
86
236
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -96,6 +246,77 @@ export interface HashlineStreamOptions {
|
|
|
96
246
|
maxChunkBytes?: number;
|
|
97
247
|
}
|
|
98
248
|
|
|
249
|
+
interface ResolvedHashlineStreamOptions {
|
|
250
|
+
startLine: number;
|
|
251
|
+
maxChunkLines: number;
|
|
252
|
+
maxChunkBytes: number;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
type HashlineLineFormatter = (lineNumber: number, line: string) => string;
|
|
256
|
+
|
|
257
|
+
interface HashlineChunkEmitter {
|
|
258
|
+
pushLine: (line: string) => string[];
|
|
259
|
+
flush: () => string | undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function resolveHashlineStreamOptions(options: HashlineStreamOptions): ResolvedHashlineStreamOptions {
|
|
263
|
+
return {
|
|
264
|
+
startLine: options.startLine ?? 1,
|
|
265
|
+
maxChunkLines: options.maxChunkLines ?? 200,
|
|
266
|
+
maxChunkBytes: options.maxChunkBytes ?? 64 * 1024,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function createHashlineChunkEmitter(
|
|
271
|
+
options: ResolvedHashlineStreamOptions,
|
|
272
|
+
formatLine: HashlineLineFormatter,
|
|
273
|
+
): HashlineChunkEmitter {
|
|
274
|
+
let lineNumber = options.startLine;
|
|
275
|
+
let outLines: string[] = [];
|
|
276
|
+
let outBytes = 0;
|
|
277
|
+
|
|
278
|
+
const flush = (): string | undefined => {
|
|
279
|
+
if (outLines.length === 0) return undefined;
|
|
280
|
+
const chunk = outLines.join("\n");
|
|
281
|
+
outLines = [];
|
|
282
|
+
outBytes = 0;
|
|
283
|
+
return chunk;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const pushLine = (line: string): string[] => {
|
|
287
|
+
const formatted = formatLine(lineNumber, line);
|
|
288
|
+
lineNumber++;
|
|
289
|
+
|
|
290
|
+
const chunksToYield: string[] = [];
|
|
291
|
+
const sepBytes = outLines.length === 0 ? 0 : 1;
|
|
292
|
+
const lineBytes = Buffer.byteLength(formatted, "utf-8");
|
|
293
|
+
|
|
294
|
+
if (
|
|
295
|
+
outLines.length > 0 &&
|
|
296
|
+
(outLines.length >= options.maxChunkLines || outBytes + sepBytes + lineBytes > options.maxChunkBytes)
|
|
297
|
+
) {
|
|
298
|
+
const flushed = flush();
|
|
299
|
+
if (flushed) chunksToYield.push(flushed);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
outLines.push(formatted);
|
|
303
|
+
outBytes += (outLines.length === 1 ? 0 : 1) + lineBytes;
|
|
304
|
+
|
|
305
|
+
if (outLines.length >= options.maxChunkLines || outBytes >= options.maxChunkBytes) {
|
|
306
|
+
const flushed = flush();
|
|
307
|
+
if (flushed) chunksToYield.push(flushed);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return chunksToYield;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
return { pushLine, flush };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function formatHashlineStreamLine(lineNumber: number, line: string): string {
|
|
317
|
+
return `${formatLineHash(lineNumber, line)}:${line}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
99
320
|
function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
|
|
100
321
|
return (
|
|
101
322
|
typeof value === "object" &&
|
|
@@ -129,52 +350,13 @@ export async function* streamHashLinesFromUtf8(
|
|
|
129
350
|
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
130
351
|
options: HashlineStreamOptions = {},
|
|
131
352
|
): AsyncGenerator<string> {
|
|
132
|
-
const
|
|
133
|
-
const maxChunkLines = options.maxChunkLines ?? 200;
|
|
134
|
-
const maxChunkBytes = options.maxChunkBytes ?? 64 * 1024;
|
|
353
|
+
const resolvedOptions = resolveHashlineStreamOptions(options);
|
|
135
354
|
const decoder = new TextDecoder("utf-8");
|
|
136
355
|
const chunks = isReadableStream(source) ? bytesFromReadableStream(source) : source;
|
|
137
|
-
let lineNum = startLine;
|
|
138
356
|
let pending = "";
|
|
139
357
|
let sawAnyText = false;
|
|
140
358
|
let endedWithNewline = false;
|
|
141
|
-
|
|
142
|
-
let outBytes = 0;
|
|
143
|
-
|
|
144
|
-
const flush = (): string | undefined => {
|
|
145
|
-
if (outLines.length === 0) return undefined;
|
|
146
|
-
const chunk = outLines.join("\n");
|
|
147
|
-
outLines = [];
|
|
148
|
-
outBytes = 0;
|
|
149
|
-
return chunk;
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
const pushLine = (line: string): string[] => {
|
|
153
|
-
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}:${line}`;
|
|
154
|
-
lineNum++;
|
|
155
|
-
|
|
156
|
-
const chunksToYield: string[] = [];
|
|
157
|
-
const sepBytes = outLines.length === 0 ? 0 : 1; // "\n"
|
|
158
|
-
const lineBytes = Buffer.byteLength(formatted, "utf-8");
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
outLines.length > 0 &&
|
|
162
|
-
(outLines.length >= maxChunkLines || outBytes + sepBytes + lineBytes > maxChunkBytes)
|
|
163
|
-
) {
|
|
164
|
-
const flushed = flush();
|
|
165
|
-
if (flushed) chunksToYield.push(flushed);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
outLines.push(formatted);
|
|
169
|
-
outBytes += (outLines.length === 1 ? 0 : 1) + lineBytes;
|
|
170
|
-
|
|
171
|
-
if (outLines.length >= maxChunkLines || outBytes >= maxChunkBytes) {
|
|
172
|
-
const flushed = flush();
|
|
173
|
-
if (flushed) chunksToYield.push(flushed);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return chunksToYield;
|
|
177
|
-
};
|
|
359
|
+
const emitter = createHashlineChunkEmitter(resolvedOptions, formatHashlineStreamLine);
|
|
178
360
|
|
|
179
361
|
const consumeText = (text: string): string[] => {
|
|
180
362
|
if (text.length === 0) return [];
|
|
@@ -187,7 +369,7 @@ export async function* streamHashLinesFromUtf8(
|
|
|
187
369
|
const line = pending.slice(0, idx);
|
|
188
370
|
pending = pending.slice(idx + 1);
|
|
189
371
|
endedWithNewline = true;
|
|
190
|
-
chunksToYield.push(...pushLine(line));
|
|
372
|
+
chunksToYield.push(...emitter.pushLine(line));
|
|
191
373
|
}
|
|
192
374
|
if (pending.length > 0) endedWithNewline = false;
|
|
193
375
|
return chunksToYield;
|
|
@@ -203,17 +385,17 @@ export async function* streamHashLinesFromUtf8(
|
|
|
203
385
|
}
|
|
204
386
|
if (!sawAnyText) {
|
|
205
387
|
// Mirror `"".split("\n")` behavior: one empty line.
|
|
206
|
-
for (const out of pushLine("")) {
|
|
388
|
+
for (const out of emitter.pushLine("")) {
|
|
207
389
|
yield out;
|
|
208
390
|
}
|
|
209
391
|
} else if (pending.length > 0 || endedWithNewline) {
|
|
210
392
|
// Emit the final line (may be empty if the file ended with a newline).
|
|
211
|
-
for (const out of pushLine(pending)) {
|
|
393
|
+
for (const out of emitter.pushLine(pending)) {
|
|
212
394
|
yield out;
|
|
213
395
|
}
|
|
214
396
|
}
|
|
215
397
|
|
|
216
|
-
const last = flush();
|
|
398
|
+
const last = emitter.flush();
|
|
217
399
|
if (last) yield last;
|
|
218
400
|
}
|
|
219
401
|
|
|
@@ -226,72 +408,34 @@ export async function* streamHashLinesFromLines(
|
|
|
226
408
|
lines: Iterable<string> | AsyncIterable<string>,
|
|
227
409
|
options: HashlineStreamOptions = {},
|
|
228
410
|
): AsyncGenerator<string> {
|
|
229
|
-
const
|
|
230
|
-
const
|
|
231
|
-
const maxChunkBytes = options.maxChunkBytes ?? 64 * 1024;
|
|
232
|
-
|
|
233
|
-
let lineNum = startLine;
|
|
234
|
-
let outLines: string[] = [];
|
|
235
|
-
let outBytes = 0;
|
|
411
|
+
const resolvedOptions = resolveHashlineStreamOptions(options);
|
|
412
|
+
const emitter = createHashlineChunkEmitter(resolvedOptions, formatHashlineStreamLine);
|
|
236
413
|
let sawAnyLine = false;
|
|
237
|
-
const flush = (): string | undefined => {
|
|
238
|
-
if (outLines.length === 0) return undefined;
|
|
239
|
-
const chunk = outLines.join("\n");
|
|
240
|
-
outLines = [];
|
|
241
|
-
outBytes = 0;
|
|
242
|
-
return chunk;
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
const pushLine = (line: string): string[] => {
|
|
246
|
-
sawAnyLine = true;
|
|
247
|
-
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}:${line}`;
|
|
248
|
-
lineNum++;
|
|
249
|
-
|
|
250
|
-
const chunksToYield: string[] = [];
|
|
251
|
-
const sepBytes = outLines.length === 0 ? 0 : 1;
|
|
252
|
-
const lineBytes = Buffer.byteLength(formatted, "utf-8");
|
|
253
|
-
|
|
254
|
-
if (
|
|
255
|
-
outLines.length > 0 &&
|
|
256
|
-
(outLines.length >= maxChunkLines || outBytes + sepBytes + lineBytes > maxChunkBytes)
|
|
257
|
-
) {
|
|
258
|
-
const flushed = flush();
|
|
259
|
-
if (flushed) chunksToYield.push(flushed);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
outLines.push(formatted);
|
|
263
|
-
outBytes += (outLines.length === 1 ? 0 : 1) + lineBytes;
|
|
264
|
-
|
|
265
|
-
if (outLines.length >= maxChunkLines || outBytes >= maxChunkBytes) {
|
|
266
|
-
const flushed = flush();
|
|
267
|
-
if (flushed) chunksToYield.push(flushed);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return chunksToYield;
|
|
271
|
-
};
|
|
272
414
|
|
|
273
415
|
const asyncIterator = (lines as AsyncIterable<string>)[Symbol.asyncIterator];
|
|
274
416
|
if (typeof asyncIterator === "function") {
|
|
275
417
|
for await (const line of lines as AsyncIterable<string>) {
|
|
276
|
-
|
|
418
|
+
sawAnyLine = true;
|
|
419
|
+
for (const out of emitter.pushLine(line)) {
|
|
277
420
|
yield out;
|
|
278
421
|
}
|
|
279
422
|
}
|
|
280
423
|
} else {
|
|
281
424
|
for (const line of lines as Iterable<string>) {
|
|
282
|
-
|
|
425
|
+
sawAnyLine = true;
|
|
426
|
+
for (const out of emitter.pushLine(line)) {
|
|
283
427
|
yield out;
|
|
284
428
|
}
|
|
285
429
|
}
|
|
286
430
|
}
|
|
287
431
|
if (!sawAnyLine) {
|
|
288
432
|
// Mirror `"".split("\n")` behavior: one empty line.
|
|
289
|
-
for (const out of pushLine("")) {
|
|
433
|
+
for (const out of emitter.pushLine("")) {
|
|
290
434
|
yield out;
|
|
291
435
|
}
|
|
292
436
|
}
|
|
293
437
|
|
|
294
|
-
const last = flush();
|
|
438
|
+
const last = emitter.flush();
|
|
295
439
|
if (last) yield last;
|
|
296
440
|
}
|
|
297
441
|
|
|
@@ -454,124 +598,48 @@ function maybeWarnSuspiciousUnicodeEscapePlaceholder(edits: HashlineEdit[], warn
|
|
|
454
598
|
);
|
|
455
599
|
}
|
|
456
600
|
}
|
|
457
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
458
|
-
// Edit Application
|
|
459
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* Apply an array of hashline edits to file content.
|
|
463
|
-
*
|
|
464
|
-
* Each edit operation identifies target lines directly (`replace`,
|
|
465
|
-
* `append`, `prepend`). Line references are resolved via {@link parseTag}
|
|
466
|
-
* and hashes validated before any mutation.
|
|
467
|
-
*
|
|
468
|
-
* Edits are sorted bottom-up (highest effective line first) so earlier
|
|
469
|
-
* splices don't invalidate later line numbers.
|
|
470
|
-
*
|
|
471
|
-
* @returns The modified content and the 1-indexed first changed line number
|
|
472
|
-
*/
|
|
473
|
-
export function applyHashlineEdits(
|
|
474
|
-
text: string,
|
|
475
|
-
edits: HashlineEdit[],
|
|
476
|
-
): {
|
|
477
|
-
lines: string;
|
|
478
|
-
firstChangedLine: number | undefined;
|
|
479
|
-
warnings?: string[];
|
|
480
|
-
noopEdits?: Array<{ editIndex: number; loc: string; current: string }>;
|
|
481
|
-
} {
|
|
482
|
-
if (edits.length === 0) {
|
|
483
|
-
return { lines: text, firstChangedLine: undefined };
|
|
484
|
-
}
|
|
485
601
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const warnings: string[] = [];
|
|
602
|
+
function runHashlinePreflightSanitizers(edits: HashlineEdit[], warnings: string[]): void {
|
|
603
|
+
maybeAutocorrectEscapedTabIndentation(edits, warnings);
|
|
604
|
+
maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
|
|
605
|
+
}
|
|
491
606
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
if (ref.line < 1 || ref.line > fileLines.length) {
|
|
496
|
-
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
|
|
497
|
-
}
|
|
498
|
-
const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
|
|
499
|
-
if (actualHash === ref.hash) {
|
|
500
|
-
return true;
|
|
501
|
-
}
|
|
502
|
-
mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
|
|
503
|
-
return false;
|
|
607
|
+
function ensureHashlineEditHasContent(edit: HashlineEdit): void {
|
|
608
|
+
if (edit.lines.length === 0) {
|
|
609
|
+
edit.lines = [""];
|
|
504
610
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
break;
|
|
519
|
-
}
|
|
520
|
-
case "append_at":
|
|
521
|
-
case "prepend_at": {
|
|
522
|
-
if (!validateRef(edit.pos)) continue;
|
|
523
|
-
if (edit.lines.length === 0) {
|
|
524
|
-
edit.lines = [""]; // insert an empty line
|
|
525
|
-
}
|
|
526
|
-
break;
|
|
527
|
-
}
|
|
528
|
-
case "append_file":
|
|
529
|
-
case "prepend_file": {
|
|
530
|
-
if (edit.lines.length === 0) {
|
|
531
|
-
edit.lines = [""]; // insert an empty line
|
|
532
|
-
}
|
|
533
|
-
break;
|
|
534
|
-
}
|
|
535
|
-
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function collectBoundaryDuplicationWarning(edit: HashlineEdit, originalFileLines: string[], warnings: string[]): void {
|
|
614
|
+
let endLine: number;
|
|
615
|
+
switch (edit.op) {
|
|
616
|
+
case "replace_line":
|
|
617
|
+
endLine = edit.pos.line;
|
|
618
|
+
break;
|
|
619
|
+
case "replace_range":
|
|
620
|
+
endLine = edit.end.line;
|
|
621
|
+
break;
|
|
622
|
+
default:
|
|
623
|
+
return;
|
|
536
624
|
}
|
|
537
|
-
|
|
538
|
-
|
|
625
|
+
|
|
626
|
+
if (edit.lines.length === 0) return;
|
|
627
|
+
const nextSurvivingIdx = endLine;
|
|
628
|
+
if (nextSurvivingIdx >= originalFileLines.length) return;
|
|
629
|
+
const nextSurvivingLine = originalFileLines[nextSurvivingIdx];
|
|
630
|
+
const lastInsertedLine = edit.lines[edit.lines.length - 1];
|
|
631
|
+
const trimmedNext = nextSurvivingLine.trim();
|
|
632
|
+
const trimmedLast = lastInsertedLine.trim();
|
|
633
|
+
if (trimmedLast.length > 0 && trimmedLast === trimmedNext) {
|
|
634
|
+
const tag = formatLineHash(endLine + 1, nextSurvivingLine);
|
|
635
|
+
warnings.push(
|
|
636
|
+
`Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` +
|
|
637
|
+
`If you meant to replace the entire block, set \`end\` to ${tag} instead.`,
|
|
638
|
+
);
|
|
539
639
|
}
|
|
540
|
-
|
|
541
|
-
maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
|
|
640
|
+
}
|
|
542
641
|
|
|
543
|
-
|
|
544
|
-
// This catches the common boundary-overreach pattern where the agent includes a closing delimiter
|
|
545
|
-
// in the replacement but sets `end` to the line before the delimiter, causing duplication.
|
|
546
|
-
for (const edit of edits) {
|
|
547
|
-
let endLine: number;
|
|
548
|
-
switch (edit.op) {
|
|
549
|
-
case "replace_line":
|
|
550
|
-
endLine = edit.pos.line;
|
|
551
|
-
break;
|
|
552
|
-
case "replace_range":
|
|
553
|
-
endLine = edit.end.line;
|
|
554
|
-
break;
|
|
555
|
-
default:
|
|
556
|
-
continue;
|
|
557
|
-
}
|
|
558
|
-
if (edit.lines.length === 0) continue;
|
|
559
|
-
const nextSurvivingIdx = endLine; // 0-indexed: endLine (1-indexed) is the next line after `end`
|
|
560
|
-
if (nextSurvivingIdx >= originalFileLines.length) continue;
|
|
561
|
-
const nextSurvivingLine = originalFileLines[nextSurvivingIdx];
|
|
562
|
-
const lastInsertedLine = edit.lines[edit.lines.length - 1];
|
|
563
|
-
const trimmedNext = nextSurvivingLine.trim();
|
|
564
|
-
const trimmedLast = lastInsertedLine.trim();
|
|
565
|
-
// Only warn for non-trivial lines to avoid false positives on blank lines or bare punctuation
|
|
566
|
-
if (trimmedLast.length > 0 && trimmedLast === trimmedNext) {
|
|
567
|
-
const tag = formatLineTag(endLine + 1, nextSurvivingLine);
|
|
568
|
-
warnings.push(
|
|
569
|
-
`Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` +
|
|
570
|
-
`If you meant to replace the entire block, set \`end\` to ${tag} instead.`,
|
|
571
|
-
);
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
// Deduplicate identical edits targeting the same line(s)
|
|
642
|
+
function dedupeHashlineEdits(edits: HashlineEdit[]): void {
|
|
575
643
|
const seenEditKeys = new Map<string, number>();
|
|
576
644
|
const dedupIndices = new Set<number>();
|
|
577
645
|
for (let i = 0; i < edits.length; i++) {
|
|
@@ -604,137 +672,234 @@ export function applyHashlineEdits(
|
|
|
604
672
|
seenEditKeys.set(dstKey, i);
|
|
605
673
|
}
|
|
606
674
|
}
|
|
607
|
-
if (dedupIndices.size
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
}
|
|
675
|
+
if (dedupIndices.size === 0) return;
|
|
676
|
+
for (let i = edits.length - 1; i >= 0; i--) {
|
|
677
|
+
if (dedupIndices.has(i)) edits.splice(i, 1);
|
|
611
678
|
}
|
|
679
|
+
}
|
|
612
680
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
break;
|
|
630
|
-
case "prepend_at":
|
|
631
|
-
sortLine = edit.pos.line;
|
|
632
|
-
precedence = 2;
|
|
633
|
-
break;
|
|
634
|
-
case "append_file":
|
|
635
|
-
sortLine = fileLines.length + 1;
|
|
636
|
-
precedence = 1;
|
|
637
|
-
break;
|
|
638
|
-
case "prepend_file":
|
|
639
|
-
sortLine = 0;
|
|
640
|
-
precedence = 2;
|
|
641
|
-
break;
|
|
642
|
-
}
|
|
643
|
-
return { edit, idx, sortLine, precedence };
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
annotated.sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx);
|
|
681
|
+
function getHashlineEditSortKey(edit: HashlineEdit, fileLineCount: number): { sortLine: number; precedence: number } {
|
|
682
|
+
switch (edit.op) {
|
|
683
|
+
case "replace_line":
|
|
684
|
+
return { sortLine: edit.pos.line, precedence: 0 };
|
|
685
|
+
case "replace_range":
|
|
686
|
+
return { sortLine: edit.end.line, precedence: 0 };
|
|
687
|
+
case "append_at":
|
|
688
|
+
return { sortLine: edit.pos.line, precedence: 1 };
|
|
689
|
+
case "prepend_at":
|
|
690
|
+
return { sortLine: edit.pos.line, precedence: 2 };
|
|
691
|
+
case "append_file":
|
|
692
|
+
return { sortLine: fileLineCount + 1, precedence: 1 };
|
|
693
|
+
case "prepend_file":
|
|
694
|
+
return { sortLine: 0, precedence: 2 };
|
|
695
|
+
}
|
|
696
|
+
}
|
|
647
697
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
case "replace_range": {
|
|
667
|
-
const count = edit.end.line - edit.pos.line + 1;
|
|
668
|
-
fileLines.splice(edit.pos.line - 1, count, ...edit.lines);
|
|
669
|
-
trackFirstChanged(edit.pos.line);
|
|
698
|
+
function applyHashlineEditToLines(
|
|
699
|
+
edit: HashlineEdit,
|
|
700
|
+
fileLines: string[],
|
|
701
|
+
originalFileLines: string[],
|
|
702
|
+
editIndex: number,
|
|
703
|
+
noopEdits: Array<{ editIndex: number; loc: string; current: string }>,
|
|
704
|
+
trackFirstChanged: (line: number) => void,
|
|
705
|
+
): void {
|
|
706
|
+
switch (edit.op) {
|
|
707
|
+
case "replace_line": {
|
|
708
|
+
const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line);
|
|
709
|
+
const newLines = edit.lines;
|
|
710
|
+
if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
|
|
711
|
+
noopEdits.push({
|
|
712
|
+
editIndex,
|
|
713
|
+
loc: `${edit.pos.line}#${edit.pos.hash}`,
|
|
714
|
+
current: origLines.join("\n"),
|
|
715
|
+
});
|
|
670
716
|
break;
|
|
671
717
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
718
|
+
fileLines.splice(edit.pos.line - 1, 1, ...newLines);
|
|
719
|
+
trackFirstChanged(edit.pos.line);
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
case "replace_range": {
|
|
723
|
+
const count = edit.end.line - edit.pos.line + 1;
|
|
724
|
+
fileLines.splice(edit.pos.line - 1, count, ...edit.lines);
|
|
725
|
+
trackFirstChanged(edit.pos.line);
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
case "append_at": {
|
|
729
|
+
const inserted = edit.lines;
|
|
730
|
+
if (inserted.length === 0) {
|
|
731
|
+
noopEdits.push({
|
|
732
|
+
editIndex,
|
|
733
|
+
loc: `${edit.pos.line}#${edit.pos.hash}`,
|
|
734
|
+
current: originalFileLines[edit.pos.line - 1],
|
|
735
|
+
});
|
|
684
736
|
break;
|
|
685
737
|
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
738
|
+
fileLines.splice(edit.pos.line, 0, ...inserted);
|
|
739
|
+
trackFirstChanged(edit.pos.line + 1);
|
|
740
|
+
break;
|
|
741
|
+
}
|
|
742
|
+
case "prepend_at": {
|
|
743
|
+
const inserted = edit.lines;
|
|
744
|
+
if (inserted.length === 0) {
|
|
745
|
+
noopEdits.push({
|
|
746
|
+
editIndex,
|
|
747
|
+
loc: `${edit.pos.line}#${edit.pos.hash}`,
|
|
748
|
+
current: originalFileLines[edit.pos.line - 1],
|
|
749
|
+
});
|
|
698
750
|
break;
|
|
699
751
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
trackFirstChanged(1);
|
|
709
|
-
} else {
|
|
710
|
-
fileLines.splice(fileLines.length, 0, ...inserted);
|
|
711
|
-
trackFirstChanged(fileLines.length - inserted.length + 1);
|
|
712
|
-
}
|
|
752
|
+
fileLines.splice(edit.pos.line - 1, 0, ...inserted);
|
|
753
|
+
trackFirstChanged(edit.pos.line);
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
case "append_file": {
|
|
757
|
+
const inserted = edit.lines;
|
|
758
|
+
if (inserted.length === 0) {
|
|
759
|
+
noopEdits.push({ editIndex, loc: "EOF", current: "" });
|
|
713
760
|
break;
|
|
714
761
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
if (inserted.length === 0) {
|
|
718
|
-
noopEdits.push({ editIndex: idx, loc: "BOF", current: "" });
|
|
719
|
-
break;
|
|
720
|
-
}
|
|
721
|
-
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
722
|
-
fileLines.splice(0, 1, ...inserted);
|
|
723
|
-
} else {
|
|
724
|
-
fileLines.splice(0, 0, ...inserted);
|
|
725
|
-
}
|
|
762
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
763
|
+
fileLines.splice(0, 1, ...inserted);
|
|
726
764
|
trackFirstChanged(1);
|
|
765
|
+
} else {
|
|
766
|
+
fileLines.splice(fileLines.length, 0, ...inserted);
|
|
767
|
+
trackFirstChanged(fileLines.length - inserted.length + 1);
|
|
768
|
+
}
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
case "prepend_file": {
|
|
772
|
+
const inserted = edit.lines;
|
|
773
|
+
if (inserted.length === 0) {
|
|
774
|
+
noopEdits.push({ editIndex, loc: "BOF", current: "" });
|
|
727
775
|
break;
|
|
728
776
|
}
|
|
777
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
778
|
+
fileLines.splice(0, 1, ...inserted);
|
|
779
|
+
} else {
|
|
780
|
+
fileLines.splice(0, 0, ...inserted);
|
|
781
|
+
}
|
|
782
|
+
trackFirstChanged(1);
|
|
783
|
+
break;
|
|
729
784
|
}
|
|
730
785
|
}
|
|
786
|
+
}
|
|
731
787
|
|
|
788
|
+
function buildHashlineEditResult(params: {
|
|
789
|
+
fileLines: string[];
|
|
790
|
+
firstChangedLine: number | undefined;
|
|
791
|
+
warnings: string[];
|
|
792
|
+
noopEdits: Array<{ editIndex: number; loc: string; current: string }>;
|
|
793
|
+
}): {
|
|
794
|
+
lines: string;
|
|
795
|
+
firstChangedLine: number | undefined;
|
|
796
|
+
warnings?: string[];
|
|
797
|
+
noopEdits?: Array<{ editIndex: number; loc: string; current: string }>;
|
|
798
|
+
} {
|
|
799
|
+
const { fileLines, firstChangedLine, warnings, noopEdits } = params;
|
|
732
800
|
return {
|
|
733
801
|
lines: fileLines.join("\n"),
|
|
734
802
|
firstChangedLine,
|
|
735
803
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
736
804
|
...(noopEdits.length > 0 ? { noopEdits } : {}),
|
|
737
805
|
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[]): HashMismatch[] {
|
|
809
|
+
const mismatches: HashMismatch[] = [];
|
|
810
|
+
for (const edit of edits) {
|
|
811
|
+
switch (edit.op) {
|
|
812
|
+
case "replace_line":
|
|
813
|
+
validateHashlineRef(edit.pos);
|
|
814
|
+
break;
|
|
815
|
+
case "replace_range":
|
|
816
|
+
validateHashlineRef(edit.pos);
|
|
817
|
+
validateHashlineRef(edit.end);
|
|
818
|
+
if (edit.pos.line > edit.end.line) {
|
|
819
|
+
throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`);
|
|
820
|
+
}
|
|
821
|
+
break;
|
|
822
|
+
case "append_at":
|
|
823
|
+
case "prepend_at":
|
|
824
|
+
validateHashlineRef(edit.pos);
|
|
825
|
+
ensureHashlineEditHasContent(edit);
|
|
826
|
+
break;
|
|
827
|
+
case "append_file":
|
|
828
|
+
case "prepend_file":
|
|
829
|
+
ensureHashlineEditHasContent(edit);
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return mismatches;
|
|
834
|
+
|
|
835
|
+
function validateHashlineRef(ref: { line: number; hash: string }): void {
|
|
836
|
+
if (ref.line < 1 || ref.line > fileLines.length) {
|
|
837
|
+
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
|
|
838
|
+
}
|
|
839
|
+
const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
|
|
840
|
+
if (actualHash === ref.hash) {
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
847
|
+
// Edit Application
|
|
848
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Apply an array of hashline edits to file content.
|
|
852
|
+
*
|
|
853
|
+
* Each edit operation identifies target lines directly (`replace`,
|
|
854
|
+
* `append`, `prepend`). Line references are resolved via {@link parseTag}
|
|
855
|
+
* and hashes validated before any mutation.
|
|
856
|
+
*
|
|
857
|
+
* Edits are sorted bottom-up (highest effective line first) so earlier
|
|
858
|
+
* splices don't invalidate later line numbers.
|
|
859
|
+
*
|
|
860
|
+
* @returns The modified content and the 1-indexed first changed line number
|
|
861
|
+
*/
|
|
862
|
+
export function applyHashlineEdits(
|
|
863
|
+
text: string,
|
|
864
|
+
edits: HashlineEdit[],
|
|
865
|
+
): {
|
|
866
|
+
lines: string;
|
|
867
|
+
firstChangedLine: number | undefined;
|
|
868
|
+
warnings?: string[];
|
|
869
|
+
noopEdits?: Array<{ editIndex: number; loc: string; current: string }>;
|
|
870
|
+
} {
|
|
871
|
+
if (edits.length === 0) {
|
|
872
|
+
return { lines: text, firstChangedLine: undefined };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const fileLines = text.split("\n");
|
|
876
|
+
const originalFileLines = [...fileLines];
|
|
877
|
+
let firstChangedLine: number | undefined;
|
|
878
|
+
const noopEdits: Array<{ editIndex: number; loc: string; current: string }> = [];
|
|
879
|
+
const warnings: string[] = [];
|
|
880
|
+
|
|
881
|
+
const mismatches = validateHashlineEditRefs(edits, fileLines);
|
|
882
|
+
if (mismatches.length > 0) {
|
|
883
|
+
throw new HashlineMismatchError(mismatches, fileLines);
|
|
884
|
+
}
|
|
885
|
+
runHashlinePreflightSanitizers(edits, warnings);
|
|
886
|
+
for (const edit of edits) {
|
|
887
|
+
collectBoundaryDuplicationWarning(edit, originalFileLines, warnings);
|
|
888
|
+
}
|
|
889
|
+
dedupeHashlineEdits(edits);
|
|
890
|
+
|
|
891
|
+
const annotated = edits
|
|
892
|
+
.map((edit, idx) => {
|
|
893
|
+
const { sortLine, precedence } = getHashlineEditSortKey(edit, fileLines.length);
|
|
894
|
+
return { edit, idx, sortLine, precedence };
|
|
895
|
+
})
|
|
896
|
+
.sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx);
|
|
897
|
+
|
|
898
|
+
for (const { edit, idx } of annotated) {
|
|
899
|
+
applyHashlineEditToLines(edit, fileLines, originalFileLines, idx, noopEdits, trackFirstChanged);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return buildHashlineEditResult({ fileLines, firstChangedLine, warnings, noopEdits });
|
|
738
903
|
|
|
739
904
|
function trackFirstChanged(line: number): void {
|
|
740
905
|
if (firstChangedLine === undefined || line < firstChangedLine) {
|
|
@@ -959,3 +1124,218 @@ export function buildCompactHashlineDiffPreview(
|
|
|
959
1124
|
|
|
960
1125
|
return { preview: out.join("\n"), addedLines, removedLines };
|
|
961
1126
|
}
|
|
1127
|
+
|
|
1128
|
+
export async function computeHashlineDiff(
|
|
1129
|
+
input: { path: string; edits: HashlineEdit[]; move?: string },
|
|
1130
|
+
cwd: string,
|
|
1131
|
+
): Promise<
|
|
1132
|
+
| {
|
|
1133
|
+
diff: string;
|
|
1134
|
+
firstChangedLine: number | undefined;
|
|
1135
|
+
}
|
|
1136
|
+
| {
|
|
1137
|
+
error: string;
|
|
1138
|
+
}
|
|
1139
|
+
> {
|
|
1140
|
+
const { path, edits, move } = input;
|
|
1141
|
+
const absolutePath = resolveToCwd(path, cwd);
|
|
1142
|
+
const movePath = move ? resolveToCwd(move, cwd) : undefined;
|
|
1143
|
+
const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0;
|
|
1144
|
+
|
|
1145
|
+
try {
|
|
1146
|
+
const file = Bun.file(absolutePath);
|
|
1147
|
+
|
|
1148
|
+
if (movePath === absolutePath) {
|
|
1149
|
+
return { error: "move path is the same as source path" };
|
|
1150
|
+
}
|
|
1151
|
+
if (isMoveOnly) {
|
|
1152
|
+
return { diff: "", firstChangedLine: undefined };
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const rawContent = await readHashlineFileText(file, path);
|
|
1156
|
+
|
|
1157
|
+
const { text: content } = stripBom(rawContent);
|
|
1158
|
+
const normalizedContent = normalizeToLF(content);
|
|
1159
|
+
const result = applyHashlineEdits(normalizedContent, edits);
|
|
1160
|
+
if (normalizedContent === result.lines && !move) {
|
|
1161
|
+
return { error: `No changes would be made to ${path}. The edits produce identical content.` };
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
return generateDiffString(normalizedContent, result.lines);
|
|
1165
|
+
} catch (err) {
|
|
1166
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
async function readHashlineFileText(file: BunFile, path: string): Promise<string> {
|
|
1171
|
+
try {
|
|
1172
|
+
return await file.text();
|
|
1173
|
+
} catch (error) {
|
|
1174
|
+
if (isEnoent(error)) {
|
|
1175
|
+
throw new Error(`File not found: ${path}`);
|
|
1176
|
+
}
|
|
1177
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1178
|
+
throw new Error(message || `Unable to read ${path}`);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
export async function executeHashlineMode(
|
|
1183
|
+
options: ExecuteHashlineModeOptions,
|
|
1184
|
+
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
|
|
1185
|
+
const { session, params, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
|
|
1186
|
+
const { path, edits, delete: deleteFile, move } = params;
|
|
1187
|
+
|
|
1188
|
+
enforcePlanModeWrite(session, path, { op: deleteFile ? "delete" : "update", move });
|
|
1189
|
+
|
|
1190
|
+
if (path.endsWith(".ipynb") && edits?.length > 0) {
|
|
1191
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const absolutePath = resolvePlanPath(session, path);
|
|
1195
|
+
const resolvedMove = move ? resolvePlanPath(session, move) : undefined;
|
|
1196
|
+
if (resolvedMove === absolutePath) {
|
|
1197
|
+
throw new Error("move path is the same as source path");
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const sourceFile = Bun.file(absolutePath);
|
|
1201
|
+
const sourceExists = await sourceFile.exists();
|
|
1202
|
+
const isMoveOnly = Boolean(resolvedMove) && edits.length === 0;
|
|
1203
|
+
|
|
1204
|
+
if (deleteFile) {
|
|
1205
|
+
if (sourceExists) {
|
|
1206
|
+
await sourceFile.unlink();
|
|
1207
|
+
}
|
|
1208
|
+
invalidateFsScanAfterDelete(absolutePath);
|
|
1209
|
+
return {
|
|
1210
|
+
content: [{ type: "text", text: `Deleted ${path}` }],
|
|
1211
|
+
details: {
|
|
1212
|
+
diff: "",
|
|
1213
|
+
op: "delete",
|
|
1214
|
+
meta: outputMeta().get(),
|
|
1215
|
+
},
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
if (isMoveOnly && resolvedMove) {
|
|
1220
|
+
if (!sourceExists) {
|
|
1221
|
+
throw new Error(`File not found: ${path}`);
|
|
1222
|
+
}
|
|
1223
|
+
const parentDir = nodePath.dirname(resolvedMove);
|
|
1224
|
+
if (parentDir && parentDir !== ".") {
|
|
1225
|
+
await fs.mkdir(parentDir, { recursive: true });
|
|
1226
|
+
}
|
|
1227
|
+
await fs.rename(absolutePath, resolvedMove);
|
|
1228
|
+
invalidateFsScanAfterRename(absolutePath, resolvedMove);
|
|
1229
|
+
return {
|
|
1230
|
+
content: [{ type: "text", text: `Moved ${path} to ${move}` }],
|
|
1231
|
+
details: {
|
|
1232
|
+
diff: "",
|
|
1233
|
+
op: "update",
|
|
1234
|
+
move,
|
|
1235
|
+
meta: outputMeta().get(),
|
|
1236
|
+
},
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
if (!sourceExists) {
|
|
1241
|
+
const lines: string[] = [];
|
|
1242
|
+
for (const edit of edits) {
|
|
1243
|
+
if (edit.loc === "append") {
|
|
1244
|
+
lines.push(...hashlineParseText(edit.content));
|
|
1245
|
+
} else if (edit.loc === "prepend") {
|
|
1246
|
+
lines.unshift(...hashlineParseText(edit.content));
|
|
1247
|
+
} else {
|
|
1248
|
+
throw new Error(`File not found: ${path}`);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
await Bun.write(absolutePath, lines.join("\n"));
|
|
1253
|
+
invalidateFsScanAfterWrite(absolutePath);
|
|
1254
|
+
return {
|
|
1255
|
+
content: [{ type: "text", text: `Created ${path}` }],
|
|
1256
|
+
details: {
|
|
1257
|
+
diff: "",
|
|
1258
|
+
op: "create",
|
|
1259
|
+
meta: outputMeta().get(),
|
|
1260
|
+
},
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const anchorEdits = resolveEditAnchors(edits);
|
|
1265
|
+
const rawContent = await sourceFile.text();
|
|
1266
|
+
assertEditableFileContent(rawContent, path);
|
|
1267
|
+
|
|
1268
|
+
const { bom, text } = stripBom(rawContent);
|
|
1269
|
+
const originalEnding = detectLineEnding(text);
|
|
1270
|
+
const originalNormalized = normalizeToLF(text);
|
|
1271
|
+
let normalizedText = originalNormalized;
|
|
1272
|
+
|
|
1273
|
+
const anchorResult = applyHashlineEdits(normalizedText, anchorEdits);
|
|
1274
|
+
normalizedText = anchorResult.lines;
|
|
1275
|
+
|
|
1276
|
+
const result = {
|
|
1277
|
+
text: normalizedText,
|
|
1278
|
+
firstChangedLine: anchorResult.firstChangedLine,
|
|
1279
|
+
warnings: anchorResult.warnings,
|
|
1280
|
+
noopEdits: anchorResult.noopEdits,
|
|
1281
|
+
};
|
|
1282
|
+
if (originalNormalized === result.text && !move) {
|
|
1283
|
+
let diagnostic = `No changes made to ${path}. The edits produced identical content.`;
|
|
1284
|
+
if (result.noopEdits && result.noopEdits.length > 0) {
|
|
1285
|
+
const details = result.noopEdits
|
|
1286
|
+
.map(
|
|
1287
|
+
edit =>
|
|
1288
|
+
`Edit ${edit.editIndex}: replacement for ${edit.loc} is identical to current content:\n ${edit.loc}| ${edit.current}`,
|
|
1289
|
+
)
|
|
1290
|
+
.join("\n");
|
|
1291
|
+
diagnostic += `\n${details}`;
|
|
1292
|
+
if (result.noopEdits.length === 1 && result.noopEdits[0]?.current) {
|
|
1293
|
+
const preview = result.noopEdits[0].current.trimEnd();
|
|
1294
|
+
if (preview.length > 0) {
|
|
1295
|
+
diagnostic += `\nThe file currently contains these lines:\n${preview}\nYour edits were normalized back to the original content (whitespace-only differences are preserved as-is). Ensure your replacement changes actual code, not just formatting.`;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
throw new Error(diagnostic);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const writePath = resolvedMove ?? absolutePath;
|
|
1303
|
+
const finalContent = bom + restoreLineEndings(result.text, originalEnding);
|
|
1304
|
+
const diagnostics = await writethrough(writePath, finalContent, signal, Bun.file(writePath), batchRequest, dst =>
|
|
1305
|
+
dst === writePath ? beginDeferredDiagnosticsForPath(writePath) : undefined,
|
|
1306
|
+
);
|
|
1307
|
+
if (resolvedMove && resolvedMove !== absolutePath) {
|
|
1308
|
+
await sourceFile.unlink();
|
|
1309
|
+
invalidateFsScanAfterRename(absolutePath, resolvedMove);
|
|
1310
|
+
} else {
|
|
1311
|
+
invalidateFsScanAfterWrite(absolutePath);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const diffResult = generateDiffString(originalNormalized, result.text);
|
|
1315
|
+
const meta = outputMeta()
|
|
1316
|
+
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
|
1317
|
+
.get();
|
|
1318
|
+
|
|
1319
|
+
const resultText = move ? `Moved ${path} to ${move}` : `Updated ${path}`;
|
|
1320
|
+
const preview = buildCompactHashlineDiffPreview(diffResult.diff);
|
|
1321
|
+
const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${preview.preview ? "" : " (no textual diff preview)"}`;
|
|
1322
|
+
const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
|
|
1323
|
+
const previewBlock = preview.preview ? `\n\nDiff preview:\n${preview.preview}` : "";
|
|
1324
|
+
|
|
1325
|
+
return {
|
|
1326
|
+
content: [
|
|
1327
|
+
{
|
|
1328
|
+
type: "text",
|
|
1329
|
+
text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`,
|
|
1330
|
+
},
|
|
1331
|
+
],
|
|
1332
|
+
details: {
|
|
1333
|
+
diff: diffResult.diff,
|
|
1334
|
+
firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
|
|
1335
|
+
diagnostics,
|
|
1336
|
+
op: "update",
|
|
1337
|
+
move,
|
|
1338
|
+
meta,
|
|
1339
|
+
},
|
|
1340
|
+
};
|
|
1341
|
+
}
|