@oh-my-pi/pi-coding-agent 14.8.1 → 14.9.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 +38 -0
- package/package.json +16 -7
- package/src/config/model-resolver.ts +92 -35
- package/src/config/prompt-templates.ts +1 -1
- package/src/debug/index.ts +21 -0
- package/src/debug/raw-sse-buffer.ts +229 -0
- package/src/debug/raw-sse.ts +213 -0
- package/src/edit/index.ts +9 -10
- package/src/edit/streaming.ts +6 -5
- package/src/eval/js/context-manager.ts +91 -47
- package/src/extensibility/extensions/loader.ts +9 -3
- package/src/extensibility/plugins/legacy-pi-compat.ts +99 -20
- package/src/hashline/anchors.ts +113 -0
- package/src/hashline/apply.ts +732 -0
- package/src/hashline/bigrams.json +649 -0
- package/src/hashline/constants.ts +8 -0
- package/src/hashline/diff-preview.ts +43 -0
- package/src/hashline/diff.ts +56 -0
- package/src/hashline/execute.ts +268 -0
- package/src/{edit/modes/hashline.lark → hashline/grammar.lark} +1 -1
- package/src/{edit/line-hash.ts → hashline/hash.ts} +5 -651
- package/src/hashline/index.ts +14 -0
- package/src/hashline/input.ts +110 -0
- package/src/hashline/parser.ts +220 -0
- package/src/hashline/prefixes.ts +101 -0
- package/src/hashline/recovery.ts +72 -0
- package/src/hashline/stream.ts +123 -0
- package/src/hashline/types.ts +69 -0
- package/src/hashline/utils.ts +3 -0
- package/src/index.ts +1 -1
- package/src/lsp/index.ts +1 -1
- package/src/lsp/render.ts +4 -0
- package/src/memories/index.ts +13 -4
- package/src/modes/components/assistant-message.ts +55 -9
- package/src/modes/components/welcome.ts +114 -38
- package/src/modes/controllers/event-controller.ts +3 -1
- package/src/modes/controllers/input-controller.ts +8 -1
- package/src/modes/interactive-mode.ts +9 -9
- package/src/modes/rpc/rpc-client.ts +53 -2
- package/src/modes/rpc/rpc-mode.ts +67 -1
- package/src/modes/rpc/rpc-types.ts +17 -2
- package/src/modes/utils/ui-helpers.ts +3 -1
- package/src/prompts/agents/reviewer.md +14 -0
- package/src/prompts/tools/hashline.md +57 -10
- package/src/sdk.ts +4 -3
- package/src/session/agent-session.ts +195 -30
- package/src/session/compaction/branch-summarization.ts +4 -2
- package/src/session/compaction/compaction.ts +22 -3
- package/src/task/executor.ts +21 -2
- package/src/task/index.ts +4 -1
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/match-line-format.ts +1 -1
- package/src/tools/read.ts +1 -1
- package/src/utils/file-mentions.ts +1 -1
- package/src/utils/title-generator.ts +11 -0
- package/src/edit/modes/hashline.ts +0 -2039
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { generateDiffString } from "../edit/diff";
|
|
2
|
+
import { normalizeToLF, stripBom } from "../edit/normalize";
|
|
3
|
+
import { readEditFileText } from "../edit/read-file";
|
|
4
|
+
import { resolveToCwd } from "../tools/path-utils";
|
|
5
|
+
import { applyHashlineEdits } from "./apply";
|
|
6
|
+
import { type HashlineInputSection, splitHashlineInputs } from "./input";
|
|
7
|
+
import { parseHashline } from "./parser";
|
|
8
|
+
import type { HashlineApplyOptions } from "./types";
|
|
9
|
+
|
|
10
|
+
async function readHashlineFileText(
|
|
11
|
+
_file: { text(): Promise<string> },
|
|
12
|
+
absolutePath: string,
|
|
13
|
+
pathText: string,
|
|
14
|
+
): Promise<string> {
|
|
15
|
+
try {
|
|
16
|
+
return await readEditFileText(absolutePath, pathText);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
19
|
+
throw new Error(message || `Unable to read ${pathText}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function computeHashlineSectionDiff(
|
|
24
|
+
section: HashlineInputSection,
|
|
25
|
+
cwd: string,
|
|
26
|
+
options: HashlineApplyOptions = {},
|
|
27
|
+
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
|
28
|
+
try {
|
|
29
|
+
const absolutePath = resolveToCwd(section.path, cwd);
|
|
30
|
+
const rawContent = await readHashlineFileText(Bun.file(absolutePath), absolutePath, section.path);
|
|
31
|
+
const { text: content } = stripBom(rawContent);
|
|
32
|
+
const normalized = normalizeToLF(content);
|
|
33
|
+
const result = applyHashlineEdits(normalized, parseHashline(section.diff), options);
|
|
34
|
+
if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
|
|
35
|
+
return generateDiffString(normalized, result.lines);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function computeHashlineDiff(
|
|
42
|
+
input: { input: string; path?: string },
|
|
43
|
+
cwd: string,
|
|
44
|
+
options: HashlineApplyOptions = {},
|
|
45
|
+
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
|
46
|
+
let sections: HashlineInputSection[];
|
|
47
|
+
try {
|
|
48
|
+
sections = splitHashlineInputs(input.input, { cwd, path: input.path });
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
51
|
+
}
|
|
52
|
+
if (sections.length !== 1) {
|
|
53
|
+
return { error: "Streaming diff preview supports exactly one hashline section." };
|
|
54
|
+
}
|
|
55
|
+
return computeHashlineSectionDiff(sections[0], cwd, options);
|
|
56
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
2
|
+
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
3
|
+
import { generateDiffString } from "../edit/diff";
|
|
4
|
+
import { getFileReadCache } from "../edit/file-read-cache";
|
|
5
|
+
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../edit/normalize";
|
|
6
|
+
import { readEditFileText, serializeEditFileText } from "../edit/read-file";
|
|
7
|
+
import type { EditToolDetails } from "../edit/renderer";
|
|
8
|
+
import type { ToolSession } from "../tools";
|
|
9
|
+
import { assertEditableFileContent } from "../tools/auto-generated-guard";
|
|
10
|
+
import { invalidateFsScanAfterWrite } from "../tools/fs-cache-invalidation";
|
|
11
|
+
import { outputMeta } from "../tools/output-meta";
|
|
12
|
+
import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard";
|
|
13
|
+
import { HashlineMismatchError } from "./anchors";
|
|
14
|
+
import { applyHashlineEdits, type HashlineApplyResult } from "./apply";
|
|
15
|
+
import { buildCompactHashlineDiffPreview } from "./diff-preview";
|
|
16
|
+
import { type HashlineInputSection, splitHashlineInputs } from "./input";
|
|
17
|
+
import { parseHashlineWithWarnings } from "./parser";
|
|
18
|
+
import { tryRecoverHashlineWithCache } from "./recovery";
|
|
19
|
+
import type {
|
|
20
|
+
ExecuteHashlineSingleOptions,
|
|
21
|
+
HashlineApplyOptions,
|
|
22
|
+
HashlineEdit,
|
|
23
|
+
hashlineEditParamsSchema,
|
|
24
|
+
} from "./types";
|
|
25
|
+
|
|
26
|
+
interface ReadHashlineFileResult {
|
|
27
|
+
exists: boolean;
|
|
28
|
+
rawContent: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readHashlineFile(absolutePath: string, pathText: string): Promise<ReadHashlineFileResult> {
|
|
32
|
+
try {
|
|
33
|
+
return { exists: true, rawContent: await readEditFileText(absolutePath, pathText) };
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (isEnoent(error)) return { exists: false, rawContent: "" };
|
|
36
|
+
if (error instanceof Error && error.message === `File not found: ${pathText}`)
|
|
37
|
+
return { exists: false, rawContent: "" };
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasAnchorScopedEdit(edits: HashlineEdit[]): boolean {
|
|
43
|
+
return edits.some(edit => {
|
|
44
|
+
if (edit.kind === "delete") return true;
|
|
45
|
+
if (edit.kind === "modify") return true;
|
|
46
|
+
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatNoChangeDiagnostic(pathText: string): string {
|
|
51
|
+
return `Edits to ${pathText} resulted in no changes being made.`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getHashlineApplyOptions(session: ToolSession): HashlineApplyOptions {
|
|
55
|
+
return {
|
|
56
|
+
autoDropPureInsertDuplicates: session.settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getTextContent(result: AgentToolResult<EditToolDetails>): string {
|
|
61
|
+
return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getEditDetails(result: AgentToolResult<EditToolDetails>): EditToolDetails {
|
|
65
|
+
return result.details ?? { diff: "" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Apply hashline edits with anchor-stale recovery: on `HashlineMismatchError`,
|
|
70
|
+
* consult the read-snapshot cache for the file and 3-way-merge the edits onto
|
|
71
|
+
* the current text. If recovery succeeds, return the merged result with a
|
|
72
|
+
* synthetic warning. Otherwise re-throw the original mismatch error.
|
|
73
|
+
*/
|
|
74
|
+
function applyHashlineEditsWithRecovery(
|
|
75
|
+
session: ToolSession,
|
|
76
|
+
absolutePath: string,
|
|
77
|
+
text: string,
|
|
78
|
+
edits: HashlineEdit[],
|
|
79
|
+
options: HashlineApplyOptions,
|
|
80
|
+
): HashlineApplyResult {
|
|
81
|
+
try {
|
|
82
|
+
return applyHashlineEdits(text, edits, options);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (!(err instanceof HashlineMismatchError)) throw err;
|
|
85
|
+
const recovered = tryRecoverHashlineWithCache({
|
|
86
|
+
cache: getFileReadCache(session),
|
|
87
|
+
absolutePath,
|
|
88
|
+
currentText: text,
|
|
89
|
+
edits,
|
|
90
|
+
options,
|
|
91
|
+
});
|
|
92
|
+
if (!recovered) throw err;
|
|
93
|
+
return {
|
|
94
|
+
lines: recovered.lines,
|
|
95
|
+
firstChangedLine: recovered.firstChangedLine,
|
|
96
|
+
warnings: recovered.warnings,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run all the front-end checks (notebook guard, parse, plan-mode check, file
|
|
103
|
+
* load, edit application) without writing. Used to fail fast before applying
|
|
104
|
+
* any changes in a multi-section batch.
|
|
105
|
+
*/
|
|
106
|
+
async function preflightHashlineSection(options: ExecuteHashlineSingleOptions & HashlineInputSection): Promise<void> {
|
|
107
|
+
const { session, path: sectionPath, diff } = options;
|
|
108
|
+
|
|
109
|
+
const absolutePath = resolvePlanPath(session, sectionPath);
|
|
110
|
+
const { edits } = parseHashlineWithWarnings(diff);
|
|
111
|
+
enforcePlanModeWrite(session, sectionPath, { op: "update" });
|
|
112
|
+
|
|
113
|
+
const source = await readHashlineFile(absolutePath, sectionPath);
|
|
114
|
+
if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sectionPath}`);
|
|
115
|
+
if (source.exists) assertEditableFileContent(source.rawContent, sectionPath);
|
|
116
|
+
|
|
117
|
+
const { text } = stripBom(source.rawContent);
|
|
118
|
+
const normalized = normalizeToLF(text);
|
|
119
|
+
const result = applyHashlineEditsWithRecovery(
|
|
120
|
+
session,
|
|
121
|
+
absolutePath,
|
|
122
|
+
normalized,
|
|
123
|
+
edits,
|
|
124
|
+
getHashlineApplyOptions(session),
|
|
125
|
+
);
|
|
126
|
+
if (normalized === result.lines) throw new Error(formatNoChangeDiagnostic(sectionPath));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function executeHashlineSection(
|
|
130
|
+
options: ExecuteHashlineSingleOptions & HashlineInputSection,
|
|
131
|
+
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
|
|
132
|
+
const {
|
|
133
|
+
session,
|
|
134
|
+
path: sourcePath,
|
|
135
|
+
diff,
|
|
136
|
+
signal,
|
|
137
|
+
batchRequest,
|
|
138
|
+
writethrough,
|
|
139
|
+
beginDeferredDiagnosticsForPath,
|
|
140
|
+
} = options;
|
|
141
|
+
|
|
142
|
+
const absolutePath = resolvePlanPath(session, sourcePath);
|
|
143
|
+
const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff);
|
|
144
|
+
enforcePlanModeWrite(session, sourcePath, { op: "update" });
|
|
145
|
+
|
|
146
|
+
const source = await readHashlineFile(absolutePath, sourcePath);
|
|
147
|
+
if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sourcePath}`);
|
|
148
|
+
if (source.exists) assertEditableFileContent(source.rawContent, sourcePath);
|
|
149
|
+
|
|
150
|
+
const { bom, text } = stripBom(source.rawContent);
|
|
151
|
+
const originalEnding = detectLineEnding(text);
|
|
152
|
+
const originalNormalized = normalizeToLF(text);
|
|
153
|
+
const result = applyHashlineEditsWithRecovery(
|
|
154
|
+
session,
|
|
155
|
+
absolutePath,
|
|
156
|
+
originalNormalized,
|
|
157
|
+
edits,
|
|
158
|
+
getHashlineApplyOptions(session),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
if (originalNormalized === result.lines) {
|
|
162
|
+
return {
|
|
163
|
+
content: [{ type: "text", text: formatNoChangeDiagnostic(sourcePath) }],
|
|
164
|
+
details: { diff: "", op: "update", meta: outputMeta().get() },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const finalContent = await serializeEditFileText(
|
|
169
|
+
absolutePath,
|
|
170
|
+
sourcePath,
|
|
171
|
+
bom + restoreLineEndings(result.lines, originalEnding),
|
|
172
|
+
);
|
|
173
|
+
const diagnostics = await writethrough(
|
|
174
|
+
absolutePath,
|
|
175
|
+
finalContent,
|
|
176
|
+
signal,
|
|
177
|
+
Bun.file(absolutePath),
|
|
178
|
+
batchRequest,
|
|
179
|
+
dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
|
|
180
|
+
);
|
|
181
|
+
invalidateFsScanAfterWrite(absolutePath);
|
|
182
|
+
// The post-edit content is the freshest, most authoritative "model view"
|
|
183
|
+
// of the file: the model just received it back as the diff/preview. Cache
|
|
184
|
+
// it so a follow-up edit anchored against this state can still recover
|
|
185
|
+
// if the file is touched out-of-band before the next edit lands.
|
|
186
|
+
getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n"));
|
|
187
|
+
|
|
188
|
+
const diffResult = generateDiffString(originalNormalized, result.lines);
|
|
189
|
+
const meta = outputMeta()
|
|
190
|
+
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
|
191
|
+
.get();
|
|
192
|
+
const preview = buildCompactHashlineDiffPreview(diffResult.diff);
|
|
193
|
+
|
|
194
|
+
const warnings = [...parseWarnings, ...(result.warnings ?? [])];
|
|
195
|
+
const warningsBlock = warnings.length > 0 ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
|
|
196
|
+
const previewBlock = preview.preview ? `\n${preview.preview}` : "";
|
|
197
|
+
const headline = preview.preview
|
|
198
|
+
? `${sourcePath}:`
|
|
199
|
+
: source.exists
|
|
200
|
+
? `Updated ${sourcePath}`
|
|
201
|
+
: `Created ${sourcePath}`;
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
content: [{ type: "text", text: `${headline}${previewBlock}${warningsBlock}` }],
|
|
205
|
+
details: {
|
|
206
|
+
diff: diffResult.diff,
|
|
207
|
+
firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
|
|
208
|
+
diagnostics,
|
|
209
|
+
op: source.exists ? "update" : "create",
|
|
210
|
+
meta,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function executeHashlineSingle(
|
|
216
|
+
options: ExecuteHashlineSingleOptions,
|
|
217
|
+
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
|
|
218
|
+
const sections = mergeSamePathSections(
|
|
219
|
+
splitHashlineInputs(options.input, { cwd: options.session.cwd, path: options.path }),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// Fast path: a single section needs no preflight pass.
|
|
223
|
+
if (sections.length === 1) return executeHashlineSection({ ...options, ...sections[0] });
|
|
224
|
+
|
|
225
|
+
// Multi-section: validate everything up front so we don't apply a partial batch.
|
|
226
|
+
for (const section of sections) await preflightHashlineSection({ ...options, ...section });
|
|
227
|
+
|
|
228
|
+
const results = [];
|
|
229
|
+
for (const section of sections) {
|
|
230
|
+
results.push({ path: section.path, result: await executeHashlineSection({ ...options, ...section }) });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
content: [{ type: "text", text: results.map(({ result }) => getTextContent(result)).join("\n\n") }],
|
|
235
|
+
details: {
|
|
236
|
+
diff: results.map(({ result }) => getEditDetails(result).diff).join("\n"),
|
|
237
|
+
perFileResults: results.map(({ path: resultPath, result }) => {
|
|
238
|
+
const details = getEditDetails(result);
|
|
239
|
+
return {
|
|
240
|
+
path: resultPath,
|
|
241
|
+
diff: details.diff,
|
|
242
|
+
firstChangedLine: details.firstChangedLine,
|
|
243
|
+
diagnostics: details.diagnostics,
|
|
244
|
+
op: details.op,
|
|
245
|
+
move: details.move,
|
|
246
|
+
meta: details.meta,
|
|
247
|
+
};
|
|
248
|
+
}),
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Collapse consecutive or interleaved sections targeting the same path into a
|
|
255
|
+
* single section with concatenated diffs. Anchors authored against the same
|
|
256
|
+
* file snapshot must be applied as one batch; otherwise the first sub-edit
|
|
257
|
+
* shifts line numbers out from under the second's anchors and validation fails.
|
|
258
|
+
* Path order is preserved by first occurrence.
|
|
259
|
+
*/
|
|
260
|
+
function mergeSamePathSections(sections: HashlineInputSection[]): HashlineInputSection[] {
|
|
261
|
+
const byPath = new Map<string, string[]>();
|
|
262
|
+
for (const section of sections) {
|
|
263
|
+
const existing = byPath.get(section.path);
|
|
264
|
+
if (existing) existing.push(section.diff);
|
|
265
|
+
else byPath.set(section.path, [section.diff]);
|
|
266
|
+
}
|
|
267
|
+
return Array.from(byPath, ([path, diffs]) => ({ path, diff: diffs.join("\n") }));
|
|
268
|
+
}
|