@oh-my-pi/pi-coding-agent 14.8.1 → 14.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +16 -7
  3. package/src/config/model-resolver.ts +92 -35
  4. package/src/config/prompt-templates.ts +1 -1
  5. package/src/debug/index.ts +21 -0
  6. package/src/debug/raw-sse-buffer.ts +229 -0
  7. package/src/debug/raw-sse.ts +213 -0
  8. package/src/edit/index.ts +9 -10
  9. package/src/edit/streaming.ts +6 -5
  10. package/src/eval/js/context-manager.ts +91 -47
  11. package/src/extensibility/extensions/loader.ts +9 -3
  12. package/src/extensibility/plugins/legacy-pi-compat.ts +99 -20
  13. package/src/hashline/anchors.ts +113 -0
  14. package/src/hashline/apply.ts +732 -0
  15. package/src/hashline/bigrams.json +649 -0
  16. package/src/hashline/constants.ts +8 -0
  17. package/src/hashline/diff-preview.ts +43 -0
  18. package/src/hashline/diff.ts +56 -0
  19. package/src/hashline/execute.ts +268 -0
  20. package/src/{edit/modes/hashline.lark → hashline/grammar.lark} +1 -1
  21. package/src/{edit/line-hash.ts → hashline/hash.ts} +5 -651
  22. package/src/hashline/index.ts +14 -0
  23. package/src/hashline/input.ts +110 -0
  24. package/src/hashline/parser.ts +220 -0
  25. package/src/hashline/prefixes.ts +101 -0
  26. package/src/hashline/recovery.ts +72 -0
  27. package/src/hashline/stream.ts +123 -0
  28. package/src/hashline/types.ts +69 -0
  29. package/src/hashline/utils.ts +3 -0
  30. package/src/index.ts +1 -1
  31. package/src/lsp/index.ts +1 -1
  32. package/src/lsp/render.ts +4 -0
  33. package/src/memories/index.ts +13 -4
  34. package/src/modes/components/assistant-message.ts +55 -9
  35. package/src/modes/components/welcome.ts +114 -38
  36. package/src/modes/controllers/event-controller.ts +3 -1
  37. package/src/modes/controllers/input-controller.ts +8 -1
  38. package/src/modes/interactive-mode.ts +9 -9
  39. package/src/modes/rpc/rpc-client.ts +53 -2
  40. package/src/modes/rpc/rpc-mode.ts +67 -1
  41. package/src/modes/rpc/rpc-types.ts +17 -2
  42. package/src/modes/utils/ui-helpers.ts +3 -1
  43. package/src/prompts/agents/reviewer.md +14 -0
  44. package/src/prompts/tools/hashline.md +57 -10
  45. package/src/sdk.ts +4 -3
  46. package/src/session/agent-session.ts +195 -30
  47. package/src/session/compaction/branch-summarization.ts +4 -2
  48. package/src/session/compaction/compaction.ts +22 -3
  49. package/src/task/executor.ts +21 -2
  50. package/src/task/index.ts +4 -1
  51. package/src/tools/ast-edit.ts +1 -1
  52. package/src/tools/match-line-format.ts +1 -1
  53. package/src/tools/read.ts +1 -1
  54. package/src/utils/file-mentions.ts +1 -1
  55. package/src/utils/title-generator.ts +11 -0
  56. 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
+ }
@@ -26,7 +26,7 @@ payload: $HSEP$ line_text? LF
26
26
  line_text: /[^\r\n]+/
27
27
 
28
28
  insert_target: LID | "EOF" | "BOF"
29
- range: LID (".." LID)?
29
+ range: LID ".." LID
30
30
 
31
31
  path: /(?:[^\s\r\n]+|"[^"\r\n]+"|'[^'\r\n]+')/
32
32
  LID: /[1-9][0-9]*$HFMT$/