@oh-my-pi/pi-coding-agent 15.3.2 → 15.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +104 -0
- package/dist/types/cli/file-processor.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +45 -3
- package/dist/types/config/settings.d.ts +1 -1
- package/dist/types/debug/raw-sse.d.ts +2 -0
- package/dist/types/edit/file-read-cache.d.ts +15 -4
- package/dist/types/edit/index.d.ts +3 -8
- package/dist/types/edit/renderer.d.ts +1 -2
- package/dist/types/eval/__tests__/shared-executors.test.d.ts +1 -0
- package/dist/types/eval/js/shared/local-module-loader.d.ts +16 -0
- package/dist/types/eval/js/shared/rewrite-imports.d.ts +4 -0
- package/dist/types/eval/js/shared/runtime.d.ts +14 -8
- package/dist/types/eval/py/executor.d.ts +1 -2
- package/dist/types/eval/py/kernel.d.ts +6 -0
- package/dist/types/eval/py/tool-bridge.d.ts +1 -5
- package/dist/types/eval/session-id.d.ts +3 -0
- package/dist/types/extensibility/extensions/types.d.ts +1 -3
- package/dist/types/hashline/anchors.d.ts +15 -9
- package/dist/types/hashline/constants.d.ts +0 -2
- package/dist/types/hashline/diff.d.ts +1 -2
- package/dist/types/hashline/executor.d.ts +52 -0
- package/dist/types/hashline/hash.d.ts +44 -93
- package/dist/types/hashline/index.d.ts +2 -1
- package/dist/types/hashline/input.d.ts +2 -9
- package/dist/types/hashline/recovery.d.ts +3 -9
- package/dist/types/hashline/tokenizer.d.ts +91 -0
- package/dist/types/hashline/types.d.ts +5 -7
- package/dist/types/modes/components/extensions/types.d.ts +0 -4
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +11 -15
- package/dist/types/session/agent-storage.d.ts +11 -10
- package/dist/types/slash-commands/acp-builtins.d.ts +3 -3
- package/dist/types/slash-commands/types.d.ts +0 -5
- package/dist/types/task/executor.d.ts +2 -0
- package/dist/types/tool-discovery/tool-index.d.ts +0 -50
- package/dist/types/tools/index.d.ts +2 -8
- package/dist/types/tools/match-line-format.d.ts +4 -4
- package/dist/types/tools/output-schema-validator.d.ts +64 -0
- package/dist/types/tools/review.d.ts +13 -0
- package/dist/types/tools/search-tool-bm25.d.ts +1 -1
- package/dist/types/tools/search.d.ts +4 -3
- package/dist/types/utils/edit-mode.d.ts +1 -1
- package/dist/types/web/kagi.d.ts +4 -2
- package/dist/types/web/parallel.d.ts +4 -3
- package/dist/types/web/scrapers/types.d.ts +2 -1
- package/dist/types/web/search/index.d.ts +12 -4
- package/dist/types/web/search/provider.d.ts +2 -1
- package/dist/types/web/search/providers/anthropic.d.ts +9 -4
- package/dist/types/web/search/providers/base.d.ts +34 -2
- package/dist/types/web/search/providers/brave.d.ts +8 -1
- package/dist/types/web/search/providers/codex.d.ts +13 -9
- package/dist/types/web/search/providers/exa.d.ts +10 -1
- package/dist/types/web/search/providers/gemini.d.ts +20 -23
- package/dist/types/web/search/providers/jina.d.ts +2 -1
- package/dist/types/web/search/providers/kagi.d.ts +4 -1
- package/dist/types/web/search/providers/kimi.d.ts +10 -1
- package/dist/types/web/search/providers/parallel.d.ts +3 -2
- package/dist/types/web/search/providers/perplexity.d.ts +5 -2
- package/dist/types/web/search/providers/searxng.d.ts +2 -1
- package/dist/types/web/search/providers/synthetic.d.ts +5 -8
- package/dist/types/web/search/providers/tavily.d.ts +11 -4
- package/dist/types/web/search/providers/utils.d.ts +8 -6
- package/dist/types/web/search/providers/zai.d.ts +12 -3
- package/package.json +7 -7
- package/src/cli/file-processor.ts +12 -2
- package/src/cli.ts +0 -8
- package/src/commands/commit.ts +8 -8
- package/src/config/prompt-templates.ts +6 -6
- package/src/config/settings-schema.ts +47 -3
- package/src/config/settings.ts +5 -5
- package/src/debug/raw-sse.ts +68 -3
- package/src/edit/file-read-cache.ts +68 -25
- package/src/edit/index.ts +6 -37
- package/src/edit/renderer.ts +9 -47
- package/src/edit/streaming.ts +43 -56
- package/src/eval/__tests__/shared-executors.test.ts +520 -0
- package/src/eval/js/context-manager.ts +64 -53
- package/src/eval/js/shared/local-module-loader.ts +265 -0
- package/src/eval/js/shared/prelude.txt +4 -0
- package/src/eval/js/shared/rewrite-imports.ts +85 -0
- package/src/eval/js/shared/runtime.ts +129 -86
- package/src/eval/js/worker-core.ts +23 -38
- package/src/eval/py/executor.ts +155 -84
- package/src/eval/py/kernel.ts +10 -1
- package/src/eval/py/prelude.py +22 -24
- package/src/eval/py/runner.py +203 -85
- package/src/eval/py/tool-bridge.ts +17 -10
- package/src/eval/session-id.ts +8 -0
- package/src/exec/bash-executor.ts +27 -16
- package/src/extensibility/extensions/runner.ts +0 -1
- package/src/extensibility/extensions/types.ts +1 -3
- package/src/hashline/anchors.ts +56 -65
- package/src/hashline/apply.ts +29 -31
- package/src/hashline/constants.ts +0 -3
- package/src/hashline/diff-preview.ts +4 -5
- package/src/hashline/diff.ts +30 -4
- package/src/hashline/execute.ts +91 -26
- package/src/hashline/executor.ts +239 -0
- package/src/hashline/grammar.lark +12 -10
- package/src/hashline/hash.ts +69 -114
- package/src/hashline/index.ts +2 -1
- package/src/hashline/input.ts +48 -41
- package/src/hashline/prefixes.ts +21 -11
- package/src/hashline/recovery.ts +63 -71
- package/src/hashline/stream.ts +2 -2
- package/src/hashline/tokenizer.ts +467 -0
- package/src/hashline/types.ts +6 -8
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/modes/components/extensions/types.ts +0 -5
- package/src/modes/components/session-observer-overlay.ts +11 -2
- package/src/modes/components/tree-selector.ts +10 -2
- package/src/modes/controllers/command-controller.ts +1 -3
- package/src/modes/controllers/extension-ui-controller.ts +10 -11
- package/src/modes/controllers/selector-controller.ts +5 -5
- package/src/modes/types.ts +4 -1
- package/src/modes/utils/ui-helpers.ts +4 -0
- package/src/prompts/agents/explore.md +1 -1
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/prompts/tools/ast-grep.md +1 -1
- package/src/prompts/tools/eval.md +1 -1
- package/src/prompts/tools/hashline.md +73 -94
- package/src/prompts/tools/read.md +4 -4
- package/src/prompts/tools/search.md +3 -3
- package/src/sdk.ts +17 -23
- package/src/session/agent-session.ts +59 -66
- package/src/session/agent-storage.ts +13 -14
- package/src/slash-commands/acp-builtins.ts +3 -3
- package/src/slash-commands/types.ts +0 -6
- package/src/task/executor.ts +26 -57
- package/src/task/index.ts +8 -4
- package/src/tool-discovery/tool-index.ts +0 -134
- package/src/tools/ast-edit.ts +36 -13
- package/src/tools/ast-grep.ts +45 -4
- package/src/tools/browser/tab-worker.ts +3 -2
- package/src/tools/eval.ts +2 -1
- package/src/tools/fetch.ts +23 -14
- package/src/tools/index.ts +2 -8
- package/src/tools/irc.ts +59 -5
- package/src/tools/match-line-format.ts +5 -7
- package/src/tools/output-schema-validator.ts +132 -0
- package/src/tools/read.ts +142 -31
- package/src/tools/review.ts +23 -0
- package/src/tools/search-tool-bm25.ts +3 -30
- package/src/tools/search.ts +48 -16
- package/src/tools/write.ts +3 -3
- package/src/tools/yield.ts +32 -41
- package/src/utils/edit-mode.ts +1 -2
- package/src/utils/file-mentions.ts +2 -2
- package/src/web/kagi.ts +15 -6
- package/src/web/parallel.ts +9 -6
- package/src/web/scrapers/types.ts +7 -1
- package/src/web/scrapers/youtube.ts +13 -7
- package/src/web/search/index.ts +37 -11
- package/src/web/search/provider.ts +5 -3
- package/src/web/search/providers/anthropic.ts +30 -21
- package/src/web/search/providers/base.ts +35 -2
- package/src/web/search/providers/brave.ts +4 -4
- package/src/web/search/providers/codex.ts +118 -89
- package/src/web/search/providers/exa.ts +3 -2
- package/src/web/search/providers/gemini.ts +58 -155
- package/src/web/search/providers/jina.ts +4 -4
- package/src/web/search/providers/kagi.ts +17 -11
- package/src/web/search/providers/kimi.ts +29 -13
- package/src/web/search/providers/parallel.ts +171 -23
- package/src/web/search/providers/perplexity.ts +38 -37
- package/src/web/search/providers/searxng.ts +3 -1
- package/src/web/search/providers/synthetic.ts +16 -19
- package/src/web/search/providers/tavily.ts +23 -18
- package/src/web/search/providers/utils.ts +11 -17
- package/src/web/search/providers/zai.ts +16 -8
- package/dist/types/hashline/parser.d.ts +0 -7
- package/dist/types/mcp/discoverable-tool-metadata.d.ts +0 -7
- package/dist/types/tools/vim.d.ts +0 -58
- package/dist/types/vim/buffer.d.ts +0 -41
- package/dist/types/vim/commands.d.ts +0 -6
- package/dist/types/vim/engine.d.ts +0 -47
- package/dist/types/vim/parser.d.ts +0 -3
- package/dist/types/vim/render.d.ts +0 -25
- package/dist/types/vim/types.d.ts +0 -182
- package/src/hashline/parser.ts +0 -246
- package/src/mcp/discoverable-tool-metadata.ts +0 -24
- package/src/prompts/tools/vim.md +0 -98
- package/src/tools/vim.ts +0 -949
- package/src/vim/buffer.ts +0 -309
- package/src/vim/commands.ts +0 -382
- package/src/vim/engine.ts +0 -2409
- package/src/vim/parser.ts +0 -134
- package/src/vim/render.ts +0 -252
- package/src/vim/types.ts +0 -197
package/src/hashline/anchors.ts
CHANGED
|
@@ -1,113 +1,104 @@
|
|
|
1
|
-
import { formatCodeFrameLine } from "../tools/render-utils";
|
|
2
1
|
import { MISMATCH_CONTEXT } from "./constants";
|
|
3
|
-
import {
|
|
4
|
-
import type { HashMismatch } from "./types";
|
|
2
|
+
import { formatNumberedLine, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./hash";
|
|
5
3
|
|
|
6
|
-
const
|
|
7
|
-
const HL_ANCHOR_EXAMPLES = describeAnchorExamples("160");
|
|
8
|
-
const PARSE_TAG_RE = new RegExp(`^${HL_ANCHOR_RE_RAW}`);
|
|
4
|
+
const LINE_REF_RE = /^\s*[>+\-*]*\s*(\d+)(?::.*)?\s*$/;
|
|
9
5
|
|
|
10
6
|
export function formatFullAnchorRequirement(raw?: string): string {
|
|
11
|
-
const suffix = typeof raw === "string" ? raw.trim() : "";
|
|
12
|
-
const hashOnlyHint = HL_HASH_HINT_RE.test(suffix)
|
|
13
|
-
? ` It looks like you supplied only the hash suffix (${JSON.stringify(suffix)}). ` +
|
|
14
|
-
`Copy the full anchor exactly as shown (for example, "160${suffix}").`
|
|
15
|
-
: "";
|
|
16
7
|
const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
|
|
17
8
|
return (
|
|
18
|
-
`
|
|
19
|
-
`(
|
|
9
|
+
`a bare line number from read/search output plus the section header file hash ` +
|
|
10
|
+
`(for example ${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}1a2b and line "160")${received}`
|
|
20
11
|
);
|
|
21
12
|
}
|
|
22
13
|
|
|
23
|
-
export function parseTag(ref: string): { line: number
|
|
24
|
-
const match = ref.match(
|
|
14
|
+
export function parseTag(ref: string): { line: number } {
|
|
15
|
+
const match = ref.match(LINE_REF_RE);
|
|
25
16
|
if (!match) {
|
|
26
17
|
throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`);
|
|
27
18
|
}
|
|
28
19
|
const line = Number.parseInt(match[1], 10);
|
|
29
20
|
if (line < 1) throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
|
|
30
|
-
return { line
|
|
21
|
+
return { line };
|
|
31
22
|
}
|
|
32
23
|
|
|
33
|
-
|
|
24
|
+
export interface HashlineMismatchDetails {
|
|
25
|
+
path?: string;
|
|
26
|
+
expectedFileHash: string;
|
|
27
|
+
actualFileHash: string;
|
|
28
|
+
fileLines: string[];
|
|
29
|
+
anchorLines?: readonly number[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getMismatchDisplayLines(anchorLines: readonly number[], fileLines: string[]): number[] {
|
|
34
33
|
const displayLines = new Set<number>();
|
|
35
|
-
for (const
|
|
36
|
-
|
|
37
|
-
const
|
|
34
|
+
for (const line of anchorLines) {
|
|
35
|
+
if (line < 1 || line > fileLines.length) continue;
|
|
36
|
+
const lo = Math.max(1, line - MISMATCH_CONTEXT);
|
|
37
|
+
const hi = Math.min(fileLines.length, line + MISMATCH_CONTEXT);
|
|
38
38
|
for (let lineNum = lo; lineNum <= hi; lineNum++) displayLines.add(lineNum);
|
|
39
39
|
}
|
|
40
40
|
return [...displayLines].sort((a, b) => a - b);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export class HashlineMismatchError extends Error {
|
|
44
|
-
readonly
|
|
44
|
+
readonly path: string | undefined;
|
|
45
|
+
readonly expectedFileHash: string;
|
|
46
|
+
readonly actualFileHash: string;
|
|
47
|
+
readonly fileLines: string[];
|
|
48
|
+
readonly anchorLines: readonly number[];
|
|
45
49
|
|
|
46
|
-
constructor(
|
|
47
|
-
|
|
48
|
-
public readonly fileLines: string[],
|
|
49
|
-
) {
|
|
50
|
-
super(HashlineMismatchError.formatMessage(mismatches, fileLines));
|
|
50
|
+
constructor(details: HashlineMismatchDetails) {
|
|
51
|
+
super(HashlineMismatchError.formatMessage(details));
|
|
51
52
|
this.name = "HashlineMismatchError";
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
this.remaps = remaps;
|
|
53
|
+
this.path = details.path;
|
|
54
|
+
this.expectedFileHash = details.expectedFileHash;
|
|
55
|
+
this.actualFileHash = details.actualFileHash;
|
|
56
|
+
this.fileLines = details.fileLines;
|
|
57
|
+
this.anchorLines = details.anchorLines ?? [];
|
|
59
58
|
}
|
|
60
59
|
|
|
61
60
|
get displayMessage(): string {
|
|
62
|
-
return HashlineMismatchError.formatDisplayMessage(
|
|
61
|
+
return HashlineMismatchError.formatDisplayMessage({
|
|
62
|
+
path: this.path,
|
|
63
|
+
expectedFileHash: this.expectedFileHash,
|
|
64
|
+
actualFileHash: this.actualFileHash,
|
|
65
|
+
fileLines: this.fileLines,
|
|
66
|
+
anchorLines: this.anchorLines,
|
|
67
|
+
});
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
|
|
66
|
-
const
|
|
70
|
+
static rejectionHeader(details: HashlineMismatchDetails): string[] {
|
|
71
|
+
const pathText = details.path ? ` for ${details.path}` : "";
|
|
67
72
|
return [
|
|
68
|
-
`Edit rejected
|
|
69
|
-
|
|
73
|
+
`Edit rejected${pathText}: file changed between read and edit.`,
|
|
74
|
+
`Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}; re-read and try again.`,
|
|
70
75
|
];
|
|
71
76
|
}
|
|
72
77
|
|
|
73
|
-
static formatDisplayMessage(
|
|
74
|
-
|
|
75
|
-
const displayLines = getMismatchDisplayLines(mismatches, fileLines);
|
|
76
|
-
const width = displayLines.reduce((cur, n) => Math.max(cur, String(n).length), 0);
|
|
77
|
-
|
|
78
|
-
const out = [...HashlineMismatchError.rejectionHeader(mismatches), ""];
|
|
79
|
-
let previous = -1;
|
|
80
|
-
for (const lineNum of displayLines) {
|
|
81
|
-
if (previous !== -1 && lineNum > previous + 1) out.push("...");
|
|
82
|
-
previous = lineNum;
|
|
83
|
-
const marker = mismatchSet.has(lineNum) ? "*" : " ";
|
|
84
|
-
out.push(formatCodeFrameLine(marker, lineNum, fileLines[lineNum - 1] ?? "", width));
|
|
85
|
-
}
|
|
86
|
-
return out.join("\n");
|
|
78
|
+
static formatDisplayMessage(details: HashlineMismatchDetails): string {
|
|
79
|
+
return HashlineMismatchError.formatMessage(details);
|
|
87
80
|
}
|
|
88
81
|
|
|
89
|
-
static formatMessage(
|
|
90
|
-
const
|
|
91
|
-
const lines = HashlineMismatchError.rejectionHeader(
|
|
82
|
+
static formatMessage(details: HashlineMismatchDetails): string {
|
|
83
|
+
const anchorSet = new Set(details.anchorLines ?? []);
|
|
84
|
+
const lines = HashlineMismatchError.rejectionHeader(details);
|
|
85
|
+
const displayLines = getMismatchDisplayLines(details.anchorLines ?? [], details.fileLines);
|
|
86
|
+
if (displayLines.length === 0) return lines.join("\n");
|
|
87
|
+
lines.push("");
|
|
92
88
|
let previous = -1;
|
|
93
|
-
for (const lineNum of
|
|
89
|
+
for (const lineNum of displayLines) {
|
|
94
90
|
if (previous !== -1 && lineNum > previous + 1) lines.push("...");
|
|
95
91
|
previous = lineNum;
|
|
96
|
-
const text = fileLines[lineNum - 1] ?? "";
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
lines.push(`${marker}${lineNum}${hash}${HL_BODY_SEP}${text}`);
|
|
92
|
+
const text = details.fileLines[lineNum - 1] ?? "";
|
|
93
|
+
const marker = anchorSet.has(lineNum) ? "*" : " ";
|
|
94
|
+
lines.push(`${marker}${formatNumberedLine(lineNum, text)}`);
|
|
100
95
|
}
|
|
101
96
|
return lines.join("\n");
|
|
102
97
|
}
|
|
103
98
|
}
|
|
104
99
|
|
|
105
|
-
export function validateLineRef(ref: { line: number
|
|
100
|
+
export function validateLineRef(ref: { line: number }, fileLines: string[]): void {
|
|
106
101
|
if (ref.line < 1 || ref.line > fileLines.length) {
|
|
107
102
|
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
|
|
108
103
|
}
|
|
109
|
-
const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1] ?? "");
|
|
110
|
-
if (actualHash !== ref.hash) {
|
|
111
|
-
throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines);
|
|
112
|
-
}
|
|
113
104
|
}
|
package/src/hashline/apply.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { computeLineHash } from "./hash";
|
|
4
|
-
import { cloneCursor } from "./parser";
|
|
5
|
-
import type { Anchor, HashlineApplyOptions, HashlineCursor, HashlineEdit, HashMismatch } from "./types";
|
|
1
|
+
import { cloneCursor } from "./tokenizer";
|
|
2
|
+
import type { Anchor, HashlineApplyOptions, HashlineCursor, HashlineEdit } from "./types";
|
|
6
3
|
|
|
7
4
|
export interface HashlineApplyResult {
|
|
8
5
|
lines: string;
|
|
@@ -43,26 +40,17 @@ function getHashlineEditAnchors(edit: HashlineEdit): Anchor[] {
|
|
|
43
40
|
}
|
|
44
41
|
|
|
45
42
|
/**
|
|
46
|
-
* Verify every
|
|
47
|
-
*
|
|
48
|
-
* `HashlineMismatchError` so the model re-reads and re-anchors.
|
|
43
|
+
* Verify every anchored edit points at an existing line. File-version binding is
|
|
44
|
+
* checked once per section via the header hash before this function runs.
|
|
49
45
|
*/
|
|
50
|
-
function
|
|
51
|
-
const mismatches: HashMismatch[] = [];
|
|
46
|
+
function validateHashlineLineBounds(edits: HashlineEdit[], fileLines: string[]): void {
|
|
52
47
|
for (const edit of edits) {
|
|
53
48
|
for (const anchor of getHashlineEditAnchors(edit)) {
|
|
54
49
|
if (anchor.line < 1 || anchor.line > fileLines.length) {
|
|
55
50
|
throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
|
|
56
51
|
}
|
|
57
|
-
if (anchor.hash === RANGE_INTERIOR_HASH) continue;
|
|
58
|
-
|
|
59
|
-
const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1] ?? "");
|
|
60
|
-
if (actualHash === anchor.hash) continue;
|
|
61
|
-
|
|
62
|
-
mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
|
|
63
52
|
}
|
|
64
53
|
}
|
|
65
|
-
return mismatches;
|
|
66
54
|
}
|
|
67
55
|
|
|
68
56
|
function insertAtStart(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): void {
|
|
@@ -287,15 +275,10 @@ function contiguousRange(start: number, count: number): number[] {
|
|
|
287
275
|
return Array.from({ length: count }, (_, offset) => start + offset);
|
|
288
276
|
}
|
|
289
277
|
|
|
290
|
-
function deleteEditForAutoAbsorbedLine(
|
|
291
|
-
line: number,
|
|
292
|
-
sourceLineNum: number,
|
|
293
|
-
index: number,
|
|
294
|
-
fileLines: string[],
|
|
295
|
-
): HashlineEdit {
|
|
278
|
+
function deleteEditForAutoAbsorbedLine(line: number, sourceLineNum: number, index: number): HashlineEdit {
|
|
296
279
|
return {
|
|
297
280
|
kind: "delete",
|
|
298
|
-
anchor: { line
|
|
281
|
+
anchor: { line },
|
|
299
282
|
lineNum: sourceLineNum,
|
|
300
283
|
index,
|
|
301
284
|
};
|
|
@@ -314,7 +297,7 @@ function cursorMatches(a: HashlineCursor, b: HashlineCursor): boolean {
|
|
|
314
297
|
if (a.kind === "bof" || a.kind === "eof") return true;
|
|
315
298
|
const aAnchor = (a as { anchor: Anchor }).anchor;
|
|
316
299
|
const bAnchor = (b as { anchor: Anchor }).anchor;
|
|
317
|
-
return aAnchor.line === bAnchor.line
|
|
300
|
+
return aAnchor.line === bAnchor.line;
|
|
318
301
|
}
|
|
319
302
|
|
|
320
303
|
/**
|
|
@@ -606,13 +589,13 @@ function absorbReplacementBoundaryDuplicates(
|
|
|
606
589
|
}
|
|
607
590
|
|
|
608
591
|
for (const line of contiguousRange(startLine - safePrefixCount, safePrefixCount)) {
|
|
609
|
-
absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex
|
|
592
|
+
absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++));
|
|
610
593
|
}
|
|
611
594
|
for (let groupIndex = group.startIndex; groupIndex <= group.endIndex; groupIndex++) {
|
|
612
595
|
absorbed.push(edits[groupIndex]);
|
|
613
596
|
}
|
|
614
597
|
for (const line of contiguousRange(endLine + 1, safeSuffixCount)) {
|
|
615
|
-
absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex
|
|
598
|
+
absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++));
|
|
616
599
|
}
|
|
617
600
|
|
|
618
601
|
index = group.endIndex;
|
|
@@ -653,8 +636,7 @@ export function applyHashlineEdits(
|
|
|
653
636
|
if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
|
|
654
637
|
};
|
|
655
638
|
|
|
656
|
-
|
|
657
|
-
if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines);
|
|
639
|
+
validateHashlineLineBounds(edits, fileLines);
|
|
658
640
|
|
|
659
641
|
const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
|
|
660
642
|
|
|
@@ -669,10 +651,9 @@ export function applyHashlineEdits(
|
|
|
669
651
|
continue;
|
|
670
652
|
}
|
|
671
653
|
const nextLineNum = anchorLine + 1;
|
|
672
|
-
const nextContent = fileLines[nextLineNum - 1] ?? "";
|
|
673
654
|
edit.cursor = {
|
|
674
655
|
kind: "before_anchor",
|
|
675
|
-
anchor: { line: nextLineNum
|
|
656
|
+
anchor: { line: nextLineNum },
|
|
676
657
|
};
|
|
677
658
|
}
|
|
678
659
|
|
|
@@ -711,6 +692,23 @@ export function applyHashlineEdits(
|
|
|
711
692
|
}
|
|
712
693
|
if (beforeLines.length === 0 && !deleteLine) continue;
|
|
713
694
|
|
|
695
|
+
const replaceMode = beforeLines.length > 0;
|
|
696
|
+
if (deleteLine && !replaceMode) {
|
|
697
|
+
const balance = computeDelimiterBalance([currentLine]);
|
|
698
|
+
const trimmedCurrentLine = currentLine.trim();
|
|
699
|
+
const touchesStructuralBoundary =
|
|
700
|
+
trimmedCurrentLine.startsWith(")") ||
|
|
701
|
+
trimmedCurrentLine.startsWith("]") ||
|
|
702
|
+
trimmedCurrentLine.startsWith("}") ||
|
|
703
|
+
trimmedCurrentLine.endsWith("(") ||
|
|
704
|
+
trimmedCurrentLine.endsWith("[") ||
|
|
705
|
+
trimmedCurrentLine.endsWith("{");
|
|
706
|
+
if (balance.paren !== 0 || balance.bracket !== 0 || balance.brace !== 0 || touchesStructuralBoundary) {
|
|
707
|
+
warnings.push(
|
|
708
|
+
`Deleted line ${line} contains a structural bracket/brace boundary (${JSON.stringify(trimmedCurrentLine)}); verify the file is still balanced or use 'A:<replacement>' to keep the boundary intact.`,
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
714
712
|
const replacement = deleteLine ? beforeLines : [...beforeLines, currentLine];
|
|
715
713
|
const origins = replacement.map((): HashlineLineOrigin => (deleteLine ? "replacement" : "insert"));
|
|
716
714
|
if (!deleteLine) {
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
/** Lines of context shown either side of a hash mismatch. */
|
|
2
2
|
export const MISMATCH_CONTEXT = 2;
|
|
3
3
|
|
|
4
|
-
/** Filler hash used for the interior of a multi-line range; not validated. */
|
|
5
|
-
export const RANGE_INTERIOR_HASH = "**";
|
|
6
|
-
|
|
7
4
|
/** Optional patch envelope start marker; silently consumed when present. */
|
|
8
5
|
export const BEGIN_PATCH_MARKER = "*** Begin Patch";
|
|
9
6
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { computeLineHash, HL_BODY_SEP } from "./hash";
|
|
2
1
|
import type { CompactHashlineDiffOptions, CompactHashlineDiffPreview } from "./types";
|
|
3
2
|
|
|
4
3
|
export function buildCompactHashlineDiffPreview(
|
|
@@ -11,7 +10,7 @@ export function buildCompactHashlineDiffPreview(
|
|
|
11
10
|
|
|
12
11
|
// `generateDiffString` numbers `+` lines with the post-edit line number,
|
|
13
12
|
// `-` lines with the pre-edit line number, and context lines with the
|
|
14
|
-
// pre-edit line number. To emit fresh
|
|
13
|
+
// pre-edit line number. To emit fresh line numbers usable for follow-up edits,
|
|
15
14
|
// we convert context-line numbers to post-edit positions by tracking the
|
|
16
15
|
// running offset (added so far - removed so far) as we walk the diff.
|
|
17
16
|
const formatted = lines.map(line => {
|
|
@@ -28,13 +27,13 @@ export function buildCompactHashlineDiffPreview(
|
|
|
28
27
|
switch (kind) {
|
|
29
28
|
case "+":
|
|
30
29
|
addedLines++;
|
|
31
|
-
return `+${lineNumber}
|
|
30
|
+
return `+${lineNumber}:${content}`;
|
|
32
31
|
case "-":
|
|
33
32
|
removedLines++;
|
|
34
|
-
return `-${lineNumber}
|
|
33
|
+
return `-${lineNumber}:${content}`;
|
|
35
34
|
default: {
|
|
36
35
|
const newLineNumber = lineNumber + addedLines - removedLines;
|
|
37
|
-
return ` ${newLineNumber}
|
|
36
|
+
return ` ${newLineNumber}:${content}`;
|
|
38
37
|
}
|
|
39
38
|
}
|
|
40
39
|
});
|
package/src/hashline/diff.ts
CHANGED
|
@@ -3,9 +3,10 @@ import { normalizeToLF, stripBom } from "../edit/normalize";
|
|
|
3
3
|
import { readEditFileText } from "../edit/read-file";
|
|
4
4
|
import { resolveToCwd } from "../tools/path-utils";
|
|
5
5
|
import { applyHashlineEdits } from "./apply";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
6
|
+
import { parseHashline } from "./executor";
|
|
7
|
+
import { computeFileHash } from "./hash";
|
|
8
|
+
import { splitHashlineInputs } from "./input";
|
|
9
|
+
import type { HashlineApplyOptions, HashlineEdit, HashlineInputSection } from "./types";
|
|
9
10
|
|
|
10
11
|
async function readHashlineFileText(
|
|
11
12
|
_file: { text(): Promise<string> },
|
|
@@ -20,6 +21,28 @@ async function readHashlineFileText(
|
|
|
20
21
|
}
|
|
21
22
|
}
|
|
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
|
+
|
|
23
46
|
export async function computeHashlineSectionDiff(
|
|
24
47
|
section: HashlineInputSection,
|
|
25
48
|
cwd: string,
|
|
@@ -30,7 +53,10 @@ export async function computeHashlineSectionDiff(
|
|
|
30
53
|
const rawContent = await readHashlineFileText(Bun.file(absolutePath), absolutePath, section.path);
|
|
31
54
|
const { text: content } = stripBom(rawContent);
|
|
32
55
|
const normalized = normalizeToLF(content);
|
|
33
|
-
const
|
|
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);
|
|
34
60
|
if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
|
|
35
61
|
return generateDiffString(normalized, result.lines);
|
|
36
62
|
} catch (err) {
|
package/src/hashline/execute.ts
CHANGED
|
@@ -13,13 +13,15 @@ import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard"
|
|
|
13
13
|
import { HashlineMismatchError } from "./anchors";
|
|
14
14
|
import { applyHashlineEdits, type HashlineApplyResult } from "./apply";
|
|
15
15
|
import { buildCompactHashlineDiffPreview } from "./diff-preview";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
16
|
+
import { parseHashline } from "./executor";
|
|
17
|
+
import { computeFileHash } from "./hash";
|
|
18
|
+
import { splitHashlineInputs } from "./input";
|
|
18
19
|
import { tryRecoverHashlineWithCache } from "./recovery";
|
|
19
20
|
import type {
|
|
20
21
|
ExecuteHashlineSingleOptions,
|
|
21
22
|
HashlineApplyOptions,
|
|
22
23
|
HashlineEdit,
|
|
24
|
+
HashlineInputSection,
|
|
23
25
|
hashlineEditParamsSchema,
|
|
24
26
|
} from "./types";
|
|
25
27
|
|
|
@@ -46,6 +48,27 @@ function hasAnchorScopedEdit(edits: HashlineEdit[]): boolean {
|
|
|
46
48
|
});
|
|
47
49
|
}
|
|
48
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
|
+
|
|
49
72
|
function formatNoChangeDiagnostic(pathText: string): string {
|
|
50
73
|
return `Edits to ${pathText} resulted in no changes being made.`;
|
|
51
74
|
}
|
|
@@ -65,36 +88,48 @@ function getEditDetails(result: AgentToolResult<EditToolDetails>): EditToolDetai
|
|
|
65
88
|
}
|
|
66
89
|
|
|
67
90
|
/**
|
|
68
|
-
* Apply hashline edits with
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* synthetic warning. Otherwise re-throw the original mismatch error.
|
|
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.
|
|
72
94
|
*/
|
|
73
95
|
function applyHashlineEditsWithRecovery(
|
|
74
96
|
session: ToolSession,
|
|
75
97
|
absolutePath: string,
|
|
98
|
+
pathText: string,
|
|
76
99
|
text: string,
|
|
100
|
+
fileHash: string | undefined,
|
|
77
101
|
edits: HashlineEdit[],
|
|
78
102
|
options: HashlineApplyOptions,
|
|
79
103
|
): HashlineApplyResult {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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) {
|
|
92
119
|
return {
|
|
93
120
|
lines: recovered.lines,
|
|
94
121
|
firstChangedLine: recovered.firstChangedLine,
|
|
95
122
|
warnings: recovered.warnings,
|
|
96
123
|
};
|
|
97
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
|
+
});
|
|
98
133
|
}
|
|
99
134
|
|
|
100
135
|
/**
|
|
@@ -103,10 +138,11 @@ function applyHashlineEditsWithRecovery(
|
|
|
103
138
|
* any changes in a multi-section batch.
|
|
104
139
|
*/
|
|
105
140
|
async function preflightHashlineSection(options: ExecuteHashlineSingleOptions & HashlineInputSection): Promise<void> {
|
|
106
|
-
const { session, path: sectionPath, diff } = options;
|
|
141
|
+
const { session, path: sectionPath, fileHash, diff } = options;
|
|
107
142
|
|
|
108
143
|
const absolutePath = resolvePlanPath(session, sectionPath);
|
|
109
|
-
const { edits } =
|
|
144
|
+
const { edits } = parseHashline(diff);
|
|
145
|
+
assertSectionHashAllowed(sectionPath, fileHash, edits);
|
|
110
146
|
enforcePlanModeWrite(session, sectionPath, { op: "update" });
|
|
111
147
|
|
|
112
148
|
const source = await readHashlineFile(absolutePath, sectionPath);
|
|
@@ -118,7 +154,9 @@ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions &
|
|
|
118
154
|
const result = applyHashlineEditsWithRecovery(
|
|
119
155
|
session,
|
|
120
156
|
absolutePath,
|
|
157
|
+
sectionPath,
|
|
121
158
|
normalized,
|
|
159
|
+
source.exists ? fileHash : undefined,
|
|
122
160
|
edits,
|
|
123
161
|
getHashlineApplyOptions(session),
|
|
124
162
|
);
|
|
@@ -131,6 +169,7 @@ async function executeHashlineSection(
|
|
|
131
169
|
const {
|
|
132
170
|
session,
|
|
133
171
|
path: sourcePath,
|
|
172
|
+
fileHash,
|
|
134
173
|
diff,
|
|
135
174
|
signal,
|
|
136
175
|
batchRequest,
|
|
@@ -139,7 +178,8 @@ async function executeHashlineSection(
|
|
|
139
178
|
} = options;
|
|
140
179
|
|
|
141
180
|
const absolutePath = resolvePlanPath(session, sourcePath);
|
|
142
|
-
const { edits, warnings: parseWarnings } =
|
|
181
|
+
const { edits, warnings: parseWarnings } = parseHashline(diff);
|
|
182
|
+
assertSectionHashAllowed(sourcePath, fileHash, edits);
|
|
143
183
|
enforcePlanModeWrite(session, sourcePath, { op: "update" });
|
|
144
184
|
|
|
145
185
|
const source = await readHashlineFile(absolutePath, sourcePath);
|
|
@@ -152,7 +192,9 @@ async function executeHashlineSection(
|
|
|
152
192
|
const result = applyHashlineEditsWithRecovery(
|
|
153
193
|
session,
|
|
154
194
|
absolutePath,
|
|
195
|
+
sourcePath,
|
|
155
196
|
originalNormalized,
|
|
197
|
+
source.exists ? fileHash : undefined,
|
|
156
198
|
edits,
|
|
157
199
|
getHashlineApplyOptions(session),
|
|
158
200
|
);
|
|
@@ -182,7 +224,10 @@ async function executeHashlineSection(
|
|
|
182
224
|
// of the file: the model just received it back as the diff/preview. Cache
|
|
183
225
|
// it so a follow-up edit anchored against this state can still recover
|
|
184
226
|
// if the file is touched out-of-band before the next edit lands.
|
|
185
|
-
getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n")
|
|
227
|
+
getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n"), {
|
|
228
|
+
fullText: result.lines,
|
|
229
|
+
fileHash: computeFileHash(result.lines),
|
|
230
|
+
});
|
|
186
231
|
|
|
187
232
|
const diffResult = generateDiffString(originalNormalized, result.lines);
|
|
188
233
|
const meta = outputMeta()
|
|
@@ -257,11 +302,31 @@ export async function executeHashlineSingle(
|
|
|
257
302
|
* Path order is preserved by first occurrence.
|
|
258
303
|
*/
|
|
259
304
|
function mergeSamePathSections(sections: HashlineInputSection[]): HashlineInputSection[] {
|
|
260
|
-
const byPath = new Map<string, string[]>();
|
|
305
|
+
const byPath = new Map<string, { fileHash?: string; diffs: string[] }>();
|
|
261
306
|
for (const section of sections) {
|
|
262
307
|
const existing = byPath.get(section.path);
|
|
263
|
-
if (existing)
|
|
264
|
-
|
|
308
|
+
if (existing) {
|
|
309
|
+
if (
|
|
310
|
+
existing.fileHash !== undefined &&
|
|
311
|
+
section.fileHash !== undefined &&
|
|
312
|
+
existing.fileHash !== section.fileHash
|
|
313
|
+
) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`Conflicting hashline file hashes for ${section.path}: #${existing.fileHash} and #${section.fileHash}. Re-read the file and retry with one current header.`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (existing.fileHash === undefined && section.fileHash !== undefined) existing.fileHash = section.fileHash;
|
|
319
|
+
existing.diffs.push(section.diff);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
byPath.set(section.path, {
|
|
323
|
+
...(section.fileHash !== undefined ? { fileHash: section.fileHash } : {}),
|
|
324
|
+
diffs: [section.diff],
|
|
325
|
+
});
|
|
265
326
|
}
|
|
266
|
-
return Array.from(byPath, ([path,
|
|
327
|
+
return Array.from(byPath, ([path, entry]) => ({
|
|
328
|
+
path,
|
|
329
|
+
...(entry.fileHash !== undefined ? { fileHash: entry.fileHash } : {}),
|
|
330
|
+
diff: entry.diffs.join("\n"),
|
|
331
|
+
}));
|
|
267
332
|
}
|