@oh-my-pi/pi-coding-agent 15.5.3 → 15.5.4

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