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

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 (88) hide show
  1. package/CHANGELOG.md +55 -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 +35 -0
  6. package/dist/types/edit/hashline/execute.d.ts +28 -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 +11 -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/extensibility/legacy-pi-ai-shim.d.ts +23 -0
  13. package/dist/types/index.d.ts +0 -1
  14. package/dist/types/tools/fetch.d.ts +3 -0
  15. package/dist/types/tools/find.d.ts +7 -0
  16. package/dist/types/tools/index.d.ts +6 -5
  17. package/dist/types/tools/path-utils.d.ts +18 -0
  18. package/dist/types/utils/changelog.d.ts +8 -3
  19. package/package.json +8 -15
  20. package/scripts/build-binary.ts +11 -0
  21. package/src/config/settings-schema.ts +32 -0
  22. package/src/config.ts +42 -15
  23. package/src/edit/diff.ts +5 -3
  24. package/src/edit/file-snapshot-store.ts +22 -0
  25. package/src/edit/hashline/diff.ts +95 -0
  26. package/src/edit/hashline/execute.ts +181 -0
  27. package/src/edit/hashline/filesystem.ts +129 -0
  28. package/src/edit/hashline/index.ts +4 -0
  29. package/src/edit/hashline/params.ts +18 -0
  30. package/src/edit/index.ts +16 -27
  31. package/src/edit/normalize.ts +11 -41
  32. package/src/edit/renderer.ts +15 -8
  33. package/src/edit/streaming.ts +20 -134
  34. package/src/extensibility/legacy-pi-ai-shim.ts +24 -0
  35. package/src/extensibility/plugins/legacy-pi-compat.ts +47 -3
  36. package/src/index.ts +0 -1
  37. package/src/internal-urls/docs-index.generated.ts +1 -1
  38. package/src/main.ts +2 -1
  39. package/src/modes/rpc/rpc-client.ts +3 -1
  40. package/src/prompts/tools/find.md +3 -2
  41. package/src/sdk.ts +8 -1
  42. package/src/session/agent-session.ts +18 -2
  43. package/src/tools/ast-edit.ts +1 -1
  44. package/src/tools/ast-grep.ts +3 -3
  45. package/src/tools/fetch.ts +93 -50
  46. package/src/tools/find.ts +38 -6
  47. package/src/tools/index.ts +6 -5
  48. package/src/tools/path-utils.ts +81 -0
  49. package/src/tools/read.ts +71 -75
  50. package/src/tools/search.ts +136 -17
  51. package/src/tools/write.ts +3 -3
  52. package/src/utils/changelog.ts +11 -3
  53. package/src/utils/file-mentions.ts +1 -1
  54. package/dist/types/edit/file-read-cache.d.ts +0 -36
  55. package/dist/types/hashline/anchors.d.ts +0 -26
  56. package/dist/types/hashline/apply.d.ts +0 -14
  57. package/dist/types/hashline/constants.d.ts +0 -48
  58. package/dist/types/hashline/diff-preview.d.ts +0 -2
  59. package/dist/types/hashline/diff.d.ts +0 -16
  60. package/dist/types/hashline/execute.d.ts +0 -4
  61. package/dist/types/hashline/executor.d.ts +0 -56
  62. package/dist/types/hashline/hash.d.ts +0 -76
  63. package/dist/types/hashline/index.d.ts +0 -14
  64. package/dist/types/hashline/input.d.ts +0 -4
  65. package/dist/types/hashline/prefixes.d.ts +0 -7
  66. package/dist/types/hashline/recovery.d.ts +0 -21
  67. package/dist/types/hashline/stream.d.ts +0 -2
  68. package/dist/types/hashline/tokenizer.d.ts +0 -94
  69. package/dist/types/hashline/types.d.ts +0 -75
  70. package/src/edit/file-read-cache.ts +0 -138
  71. package/src/hashline/anchors.ts +0 -104
  72. package/src/hashline/apply.ts +0 -790
  73. package/src/hashline/bigrams.json +0 -649
  74. package/src/hashline/constants.ts +0 -60
  75. package/src/hashline/diff-preview.ts +0 -42
  76. package/src/hashline/diff.ts +0 -82
  77. package/src/hashline/execute.ts +0 -334
  78. package/src/hashline/executor.ts +0 -347
  79. package/src/hashline/grammar.lark +0 -22
  80. package/src/hashline/hash.ts +0 -131
  81. package/src/hashline/index.ts +0 -14
  82. package/src/hashline/input.ts +0 -137
  83. package/src/hashline/prefixes.ts +0 -111
  84. package/src/hashline/recovery.ts +0 -139
  85. package/src/hashline/stream.ts +0 -123
  86. package/src/hashline/tokenizer.ts +0 -473
  87. package/src/hashline/types.ts +0 -66
  88. package/src/prompts/tools/hashline.md +0 -83
package/src/config.ts CHANGED
@@ -18,30 +18,57 @@ const priorityList = [
18
18
  // =============================================================================
19
19
 
20
20
  /**
21
- * Get the base directory for resolving optional package assets (docs, examples).
22
- * Walk up from import.meta.dir until we find package.json, or fall back to cwd.
21
+ * Walk up from `startDir` looking for a `package.json`. Returns the directory
22
+ * containing the marker, or `undefined` when the walk hits the filesystem root
23
+ * without finding one.
24
+ *
25
+ * Exported for unit-testing the resolution contract from arbitrary start
26
+ * directories (notably the `bun --compile` case where `import.meta.dir`
27
+ * resolves to `/$bunfs/root` and no owning package is locatable — issue
28
+ * #1423). Production callers should use {@link getPackageDir} instead.
23
29
  */
24
- export function getPackageDir(): string {
25
- // Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly)
26
- const envDir = process.env.PI_PACKAGE_DIR;
27
- if (envDir) {
28
- return expandTilde(envDir);
29
- }
30
-
31
- let dir = import.meta.dir;
30
+ export function walkUpForPackageDir(startDir: string): string | undefined {
31
+ let dir = startDir;
32
32
  while (dir !== path.dirname(dir)) {
33
33
  if (fs.existsSync(path.join(dir, "package.json"))) {
34
34
  return dir;
35
35
  }
36
36
  dir = path.dirname(dir);
37
37
  }
38
- // Fallback to project dir (docs/examples won't be found, but that's fine)
39
- return getProjectDir();
38
+ return undefined;
40
39
  }
41
40
 
42
- /** Get path to CHANGELOG.md (optional, may not exist in binary) */
43
- export function getChangelogPath(): string {
44
- return path.resolve(path.join(getPackageDir(), "CHANGELOG.md"));
41
+ /**
42
+ * Get the base directory for resolving optional package assets (docs, examples, CHANGELOG.md).
43
+ *
44
+ * Honors the `PI_PACKAGE_DIR` override (useful for Nix/Guix store paths);
45
+ * otherwise walks up from `import.meta.dir` looking for a `package.json`.
46
+ * Returns `undefined` when no owning package is locatable — notably inside
47
+ * `bun --compile` binaries where `import.meta.dir` resolves to `/$bunfs/root`
48
+ * and the walk hits the filesystem root with nothing found.
49
+ *
50
+ * Callers MUST treat `undefined` as "no package assets available" and skip the
51
+ * lookup. NEVER fall back to the user's `cwd` here: that conflates the host
52
+ * project with omp's own assets and was the source of issue #1423 (the host
53
+ * project's `CHANGELOG.md` rendered as omp's startup changelog).
54
+ */
55
+ export function getPackageDir(): string | undefined {
56
+ const envDir = process.env.PI_PACKAGE_DIR;
57
+ if (envDir) {
58
+ return expandTilde(envDir);
59
+ }
60
+ return walkUpForPackageDir(import.meta.dir);
61
+ }
62
+
63
+ /**
64
+ * Path to omp's own `CHANGELOG.md`, or `undefined` when the package directory
65
+ * cannot be resolved (e.g. inside `bun --compile` binaries that don't bundle
66
+ * package assets). Callers MUST skip changelog parsing when this is undefined;
67
+ * see issue #1423.
68
+ */
69
+ export function getChangelogPath(): string | undefined {
70
+ const packageDir = getPackageDir();
71
+ return packageDir ? path.resolve(packageDir, "CHANGELOG.md") : undefined;
45
72
  }
46
73
 
47
74
  // =============================================================================
package/src/edit/diff.ts CHANGED
@@ -58,7 +58,7 @@ function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, conten
58
58
  * Generate a unified diff string with line numbers and context.
59
59
  * Returns both the diff string and the first changed line number (in the new file).
60
60
  */
61
- export function generateDiffString(oldContent: string, newContent: string, contextLines = 4): DiffResult {
61
+ export function generateDiffString(oldContent: string, newContent: string, contextLines = 2): DiffResult {
62
62
  const parts = Diff.diffLines(oldContent, newContent);
63
63
  const output: string[] = [];
64
64
 
@@ -119,8 +119,9 @@ export function generateDiffString(oldContent: string, newContent: string, conte
119
119
  linesToShow = raw.slice(0, contextLimit);
120
120
  }
121
121
 
122
+ // Leading-skip placeholder is omitted: the first emitted line's
123
+ // number already conveys that earlier lines were trimmed.
122
124
  if (leadingSkip > 0) {
123
- output.push(formatNumberedDiffLine(" ", oldLineNum, "..."));
124
125
  oldLineNum += leadingSkip;
125
126
  newLineNum += leadingSkip;
126
127
  }
@@ -143,8 +144,9 @@ export function generateDiffString(oldContent: string, newContent: string, conte
143
144
  }
144
145
  }
145
146
 
147
+ // Trailing-skip placeholder is omitted for the same reason: the
148
+ // final emitted line's number tells the reader the file continues.
146
149
  if (trailingSkip > 0) {
147
- output.push(formatNumberedDiffLine(" ", oldLineNum, "..."));
148
150
  oldLineNum += trailingSkip;
149
151
  newLineNum += trailingSkip;
150
152
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Session-bound file snapshot store.
3
+ *
4
+ * Used by `read` and `search` to record exactly what the model saw, and by
5
+ * the hashline patcher to recover from stale section hashes (file changed
6
+ * externally between read and edit, or a prior in-session edit advanced
7
+ * the hash). The store is the {@link InMemorySnapshotStore} implementation
8
+ * from `@oh-my-pi/hashline`; the only coding-agent-specific concern here
9
+ * is wiring it onto the per-session {@link ToolSession} object.
10
+ */
11
+ import { InMemorySnapshotStore } from "@oh-my-pi/hashline";
12
+ import type { ToolSession } from "../tools";
13
+
14
+ /**
15
+ * Look up (or lazily create) the file snapshot store attached to a session.
16
+ * Storage lives on `session.fileSnapshotStore` so it ages out exactly with
17
+ * the session itself.
18
+ */
19
+ export function getFileSnapshotStore(session: ToolSession): InMemorySnapshotStore {
20
+ if (!session.fileSnapshotStore) session.fileSnapshotStore = new InMemorySnapshotStore();
21
+ return session.fileSnapshotStore;
22
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Read-only hashline diff preview helpers used by the streaming edit
3
+ * renderer. Reads the target file, parses + applies the section's edits in
4
+ * memory (no FS write, no LSP writethrough), then hands the before/after
5
+ * pair to {@link generateDiffString} so the renderer can show the diff
6
+ * while the tool call is still streaming.
7
+ *
8
+ * Validation is intentionally light: only the section file hash is checked
9
+ * (so the preview goes red when anchors are stale), no plan-mode guards
10
+ * and no auto-generated-file refusal — those belong on the write path.
11
+ */
12
+ import {
13
+ computeFileHash,
14
+ Patch as HashlinePatch,
15
+ normalizeToLF,
16
+ type Patch,
17
+ type PatchSection,
18
+ stripBom,
19
+ } from "@oh-my-pi/hashline";
20
+ import { resolveToCwd } from "../../tools/path-utils";
21
+ import { generateDiffString } from "../diff";
22
+ import { readEditFileText } from "../read-file";
23
+
24
+ export interface HashlineDiffOptions {
25
+ autoDropPureInsertDuplicates?: boolean;
26
+ /**
27
+ * Use the streaming-tolerant applier ({@link PatchSection.applyPartialTo})
28
+ * so trailing in-flight ops do not throw or emit phantom edits. Streaming
29
+ * preview path only.
30
+ */
31
+ streaming?: boolean;
32
+ }
33
+
34
+ async function readSectionText(absolutePath: string, sectionPath: string): Promise<string> {
35
+ try {
36
+ return await readEditFileText(absolutePath, sectionPath);
37
+ } catch (error) {
38
+ const message = error instanceof Error ? error.message : String(error);
39
+ throw new Error(message || `Unable to read ${sectionPath}`);
40
+ }
41
+ }
42
+
43
+ function hasAnchorScoped(section: PatchSection): boolean {
44
+ return section.hasAnchorScopedEdit;
45
+ }
46
+
47
+ function validateSectionHash(section: PatchSection, text: string): string | null {
48
+ if (section.fileHash === undefined) {
49
+ return hasAnchorScoped(section)
50
+ ? `Missing hashline file hash for anchored edit to ${section.path}; use \`¶${section.path}#hash\` from your latest read.`
51
+ : null;
52
+ }
53
+ const currentHash = computeFileHash(text);
54
+ if (currentHash === section.fileHash) return null;
55
+ 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.`;
56
+ }
57
+
58
+ export async function computeHashlineSectionDiff(
59
+ section: PatchSection,
60
+ cwd: string,
61
+ options: HashlineDiffOptions = {},
62
+ ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
63
+ try {
64
+ const absolutePath = resolveToCwd(section.path, cwd);
65
+ const rawContent = await readSectionText(absolutePath, section.path);
66
+ const { text: content } = stripBom(rawContent);
67
+ const normalized = normalizeToLF(content);
68
+ const hashError = validateSectionHash(section, normalized);
69
+ if (hashError) return { error: hashError };
70
+ const result = options.streaming
71
+ ? section.applyPartialTo(normalized, options)
72
+ : section.applyTo(normalized, options);
73
+ if (normalized === result.text) return { error: `No changes would be made to ${section.path}.` };
74
+ return generateDiffString(normalized, result.text);
75
+ } catch (err) {
76
+ return { error: err instanceof Error ? err.message : String(err) };
77
+ }
78
+ }
79
+
80
+ export async function computeHashlineDiff(
81
+ input: { input: string },
82
+ cwd: string,
83
+ options: HashlineDiffOptions = {},
84
+ ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
85
+ let patch: Patch;
86
+ try {
87
+ patch = HashlinePatch.parse(input.input, { cwd });
88
+ } catch (err) {
89
+ return { error: err instanceof Error ? err.message : String(err) };
90
+ }
91
+ if (patch.sections.length !== 1) {
92
+ return { error: "Streaming diff preview supports exactly one hashline section." };
93
+ }
94
+ return computeHashlineSectionDiff(patch.sections[0], cwd, options);
95
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Coding-agent runner that drives the hashline {@link Patcher} on behalf of
3
+ * the `edit` tool. Converts a `{input}` tool-call payload into a
4
+ * fully-applied patch, wraps the result in the agent's
5
+ * {@link AgentToolResult} shape, and attaches LSP diagnostics + `outputMeta`
6
+ * for the renderer.
7
+ *
8
+ * Multi-section patches are preflighted up front via {@link Patcher.prepare}
9
+ * so a partial batch never lands; the commit loop then narrows the LSP
10
+ * batch's `flush` flag to true only for the final write so diagnostics
11
+ * round-trip once.
12
+ */
13
+ import {
14
+ buildCompactDiffPreview,
15
+ MismatchError as HashlineMismatchError,
16
+ Patch,
17
+ Patcher,
18
+ type PatchSectionResult,
19
+ type PreparedSection,
20
+ } from "@oh-my-pi/hashline";
21
+ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
22
+ import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
23
+ import type { ToolSession } from "../../tools";
24
+ import { outputMeta } from "../../tools/output-meta";
25
+ import { generateDiffString } from "../diff";
26
+ import { getFileSnapshotStore } from "../file-snapshot-store";
27
+ import type { EditToolDetails, EditToolPerFileResult, LspBatchRequest } from "../renderer";
28
+ import { HashlineFilesystem } from "./filesystem";
29
+ import { type HashlineParams, hashlineEditParamsSchema } from "./params";
30
+
31
+ export interface ExecuteHashlineSingleOptions {
32
+ session: ToolSession;
33
+ input: string;
34
+ signal?: AbortSignal;
35
+ batchRequest?: LspBatchRequest;
36
+ writethrough: WritethroughCallback;
37
+ beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
38
+ }
39
+
40
+ function getHashlineApplyOptions(session: ToolSession): { autoDropPureInsertDuplicates: boolean } {
41
+ return {
42
+ autoDropPureInsertDuplicates: session.settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
43
+ };
44
+ }
45
+
46
+ function noChangeDiagnostic(path: string): string {
47
+ return `Edits to ${path} resulted in no changes being made.`;
48
+ }
49
+
50
+ function assertUniqueCanonicalPaths(prepared: readonly PreparedSection[]): void {
51
+ const seen = new Map<string, string>();
52
+ for (const entry of prepared) {
53
+ const previous = seen.get(entry.canonicalPath);
54
+ if (previous !== undefined) {
55
+ throw new Error(
56
+ `Multiple hashline sections resolve to the same file (${previous} and ${entry.section.path}). Merge their ops under one header before applying.`,
57
+ );
58
+ }
59
+ seen.set(entry.canonicalPath, entry.section.path);
60
+ }
61
+ }
62
+
63
+ function narrowBatchRequest(outer: LspBatchRequest | undefined, isLast: boolean): LspBatchRequest | undefined {
64
+ if (!outer) return undefined;
65
+ return { id: outer.id, flush: isLast && outer.flush };
66
+ }
67
+
68
+ interface RenderedSection {
69
+ toolResult: AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>;
70
+ perFileResult: EditToolPerFileResult;
71
+ }
72
+
73
+ function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsResult | undefined): RenderedSection {
74
+ if (result.op === "noop") {
75
+ const toolResult: AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema> = {
76
+ content: [{ type: "text", text: noChangeDiagnostic(result.path) }],
77
+ details: { diff: "", op: "update", meta: outputMeta().get() },
78
+ };
79
+ return {
80
+ toolResult,
81
+ perFileResult: { path: result.path, diff: "", op: "update" },
82
+ };
83
+ }
84
+
85
+ const diff = generateDiffString(result.before, result.after);
86
+ const preview = buildCompactDiffPreview(diff.diff);
87
+ const meta = outputMeta()
88
+ .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
89
+ .get();
90
+
91
+ const warningsBlock = result.warnings.length > 0 ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
92
+ const previewBlock = preview.preview ? `\n${preview.preview}` : "";
93
+ const firstChangedLine = result.firstChangedLine ?? diff.firstChangedLine;
94
+ return {
95
+ toolResult: {
96
+ content: [{ type: "text", text: `${result.header}${previewBlock}${warningsBlock}` }],
97
+ details: {
98
+ diff: diff.diff,
99
+ firstChangedLine,
100
+ diagnostics,
101
+ op: result.op,
102
+ meta,
103
+ },
104
+ },
105
+ perFileResult: {
106
+ path: result.path,
107
+ diff: diff.diff,
108
+ firstChangedLine,
109
+ diagnostics,
110
+ op: result.op,
111
+ },
112
+ };
113
+ }
114
+
115
+ export async function executeHashlineSingle(
116
+ options: ExecuteHashlineSingleOptions,
117
+ ): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
118
+ const patch = Patch.parse(options.input, { cwd: options.session.cwd });
119
+ if (patch.sections.length === 0) {
120
+ throw new Error("No hashline sections found in input.");
121
+ }
122
+
123
+ const fs = new HashlineFilesystem({
124
+ session: options.session,
125
+ writethrough: options.writethrough,
126
+ beginDeferredDiagnosticsForPath: options.beginDeferredDiagnosticsForPath,
127
+ signal: options.signal,
128
+ batchRequest: options.batchRequest,
129
+ });
130
+ const snapshots = getFileSnapshotStore(options.session);
131
+ const applyOptions = getHashlineApplyOptions(options.session);
132
+ const patcher = new Patcher({ fs, snapshots, applyOptions });
133
+
134
+ // Single-section fast path: prepare, commit, render.
135
+ if (patch.sections.length === 1) {
136
+ fs.setBatchRequest(narrowBatchRequest(options.batchRequest, true));
137
+ const prepared = await patcher.prepare(patch.sections[0]);
138
+ const sectionResult = await patcher.commit(prepared);
139
+ if (sectionResult.op === "noop") {
140
+ return renderSection(sectionResult, undefined).toolResult;
141
+ }
142
+ return renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path)).toolResult;
143
+ }
144
+
145
+ // Multi-section: prepare every section up front so we fail fast before
146
+ // any write hits the filesystem.
147
+ const prepared: PreparedSection[] = [];
148
+ for (const section of patch.sections) prepared.push(await patcher.prepare(section));
149
+ assertUniqueCanonicalPaths(prepared);
150
+ for (const entry of prepared) {
151
+ if (entry.isNoop) throw new Error(noChangeDiagnostic(entry.section.path));
152
+ }
153
+ // Then commit each one, narrowing the LSP batch flush flag to the final
154
+ // section only. A no-op apply mid-batch is treated as a hard failure —
155
+ // the model authored anchors that match the current file content.
156
+ const rendered: RenderedSection[] = [];
157
+ for (let i = 0; i < prepared.length; i++) {
158
+ const isLast = i === prepared.length - 1;
159
+ fs.setBatchRequest(narrowBatchRequest(options.batchRequest, isLast));
160
+ const sectionResult = await patcher.commit(prepared[i]);
161
+ if (sectionResult.op === "noop") throw new Error(noChangeDiagnostic(sectionResult.path));
162
+ rendered.push(renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path)));
163
+ }
164
+
165
+ return {
166
+ content: [
167
+ {
168
+ type: "text",
169
+ text: rendered
170
+ .map(r => r.toolResult.content.map(part => (part.type === "text" ? part.text : "")).join("\n"))
171
+ .join("\n\n"),
172
+ },
173
+ ],
174
+ details: {
175
+ diff: rendered.map(r => r.toolResult.details?.diff ?? "").join("\n"),
176
+ perFileResults: rendered.map(r => r.perFileResult),
177
+ },
178
+ };
179
+ }
180
+
181
+ export { HashlineMismatchError, type HashlineParams, hashlineEditParamsSchema };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Coding-agent specific {@link Filesystem} adapter for the hashline patcher.
3
+ *
4
+ * Wires hashline's storage abstraction to the agent runtime:
5
+ *
6
+ * - Section paths are resolved through the plan-mode redirect so a bare
7
+ * `PLAN.md` lands at the canonical session artifact location.
8
+ * - Reads go through `readEditFileText` (notebook-aware) and the
9
+ * auto-generated-file guard.
10
+ * - Writes go through `serializeEditFileText` (notebook-aware) and the
11
+ * LSP writethrough, with FS-scan cache invalidation on success. The
12
+ * resulting `FileDiagnosticsResult` is captured per-path so the
13
+ * orchestrator can attach it to the tool result.
14
+ *
15
+ * Construct one per `executeHashlineSingle` call: per-section state
16
+ * (batch request, diagnostics) lives on the instance and isn't safe to
17
+ * share across concurrent edit tools.
18
+ */
19
+ import { Filesystem, NotFoundError, type WriteResult } from "@oh-my-pi/hashline";
20
+ import { isEnoent } from "@oh-my-pi/pi-utils";
21
+ import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
22
+ import type { ToolSession } from "../../tools";
23
+ import { assertEditableFileContent } from "../../tools/auto-generated-guard";
24
+ import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
25
+ import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
26
+ import { readEditFileText, serializeEditFileText } from "../read-file";
27
+ import type { LspBatchRequest } from "../renderer";
28
+
29
+ export interface HashlineFilesystemOptions {
30
+ session: ToolSession;
31
+ writethrough: WritethroughCallback;
32
+ beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
33
+ signal?: AbortSignal;
34
+ /**
35
+ * Outer LSP batch request inherited from the tool-call context. The
36
+ * orchestrator narrows this per-section (flush only on the final write)
37
+ * via {@link HashlineFilesystem.setBatchRequest}.
38
+ */
39
+ batchRequest?: LspBatchRequest;
40
+ }
41
+
42
+ export class HashlineFilesystem extends Filesystem {
43
+ readonly session: ToolSession;
44
+ readonly #writethrough: WritethroughCallback;
45
+ readonly #beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
46
+ readonly #signal: AbortSignal | undefined;
47
+ #batchRequest: LspBatchRequest | undefined;
48
+ #diagnosticsByPath = new Map<string, FileDiagnosticsResult | undefined>();
49
+
50
+ constructor(options: HashlineFilesystemOptions) {
51
+ super();
52
+ this.session = options.session;
53
+ this.#writethrough = options.writethrough;
54
+ this.#beginDeferredDiagnosticsForPath = options.beginDeferredDiagnosticsForPath;
55
+ this.#signal = options.signal;
56
+ this.#batchRequest = options.batchRequest;
57
+ }
58
+
59
+ /**
60
+ * Set the LSP batch request used for the next {@link writeText} call.
61
+ * Multi-section orchestrators flip the `flush` flag to true before the
62
+ * final section so LSP diagnostics flush in one round-trip.
63
+ */
64
+ setBatchRequest(batchRequest: LspBatchRequest | undefined): void {
65
+ this.#batchRequest = batchRequest;
66
+ }
67
+
68
+ /**
69
+ * Look up (and clear) the diagnostics captured by the most-recent
70
+ * {@link writeText} call for `path`. Returns `undefined` if no write
71
+ * has happened or the writethrough returned no diagnostics.
72
+ */
73
+ consumeDiagnostics(path: string): FileDiagnosticsResult | undefined {
74
+ const value = this.#diagnosticsByPath.get(path);
75
+ this.#diagnosticsByPath.delete(path);
76
+ return value;
77
+ }
78
+
79
+ resolveAbsolute(relativePath: string): string {
80
+ return resolvePlanPath(this.session, relativePath);
81
+ }
82
+
83
+ canonicalPath(relativePath: string): string {
84
+ return this.resolveAbsolute(relativePath);
85
+ }
86
+
87
+ async readText(relativePath: string): Promise<string> {
88
+ const absolutePath = this.resolveAbsolute(relativePath);
89
+ let content: string;
90
+ try {
91
+ content = await readEditFileText(absolutePath, relativePath);
92
+ } catch (error) {
93
+ if (isEnoent(error)) throw new NotFoundError(relativePath, error);
94
+ if (error instanceof Error && error.message === `File not found: ${relativePath}`) {
95
+ throw new NotFoundError(relativePath, error);
96
+ }
97
+ throw error;
98
+ }
99
+ // Refuse edits against generated files (lockfiles, models.json, …).
100
+ assertEditableFileContent(content, relativePath);
101
+ return content;
102
+ }
103
+
104
+ async preflightWrite(relativePath: string): Promise<void> {
105
+ enforcePlanModeWrite(this.session, relativePath, { op: "update" });
106
+ }
107
+
108
+ async writeText(relativePath: string, content: string): Promise<WriteResult> {
109
+ await this.preflightWrite(relativePath);
110
+ const absolutePath = this.resolveAbsolute(relativePath);
111
+ const finalContent = await serializeEditFileText(absolutePath, relativePath, content);
112
+ const diagnostics = await this.#writethrough(
113
+ absolutePath,
114
+ finalContent,
115
+ this.#signal,
116
+ Bun.file(absolutePath),
117
+ this.#batchRequest,
118
+ dst => (dst === absolutePath ? this.#beginDeferredDiagnosticsForPath(absolutePath) : undefined),
119
+ );
120
+ invalidateFsScanAfterWrite(absolutePath);
121
+ this.#diagnosticsByPath.set(relativePath, diagnostics);
122
+ return { text: finalContent };
123
+ }
124
+
125
+ async exists(relativePath: string): Promise<boolean> {
126
+ const absolutePath = this.resolveAbsolute(relativePath);
127
+ return Bun.file(absolutePath).exists();
128
+ }
129
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./diff";
2
+ export * from "./execute";
3
+ export * from "./filesystem";
4
+ export * from "./params";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Zod schema for the `edit` tool's hashline mode payload. The schema is
3
+ * deliberately permissive (`.passthrough()`) so providers can attach extra
4
+ * keys without rejection; only `input` is required. `_input` is accepted as a
5
+ * provider-emitted alias for `input`.
6
+ */
7
+ import * as z from "zod/v4";
8
+
9
+ export const hashlineEditParamsSchema = z.preprocess(raw => {
10
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
11
+
12
+ const record = raw as Record<string, unknown>;
13
+ if (typeof record.input === "string" || typeof record._input !== "string") return raw;
14
+
15
+ return { ...record, input: record._input };
16
+ }, z.object({ input: z.string() }).passthrough());
17
+
18
+ export type HashlineParams = z.infer<typeof hashlineEditParamsSchema>;
package/src/edit/index.ts CHANGED
@@ -1,13 +1,8 @@
1
+ import { MismatchError as HashlineMismatchError } from "@oh-my-pi/hashline";
2
+ import hashlineGrammar from "@oh-my-pi/hashline/grammar.lark" with { type: "text" };
3
+ import hashlineDescription from "@oh-my-pi/hashline/prompt.md" with { type: "text" };
1
4
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
5
  import { prompt } from "@oh-my-pi/pi-utils";
3
- import {
4
- executeHashlineSingle,
5
- HashlineMismatchError,
6
- type HashlineParams,
7
- hashlineEditParamsSchema,
8
- } from "../hashline";
9
- import hashlineGrammarTemplate from "../hashline/grammar.lark" with { type: "text" };
10
- import { resolveHashlineGrammarPlaceholders } from "../hashline/hash";
11
6
  import {
12
7
  createLspWritethrough,
13
8
  type FileDiagnosticsResult,
@@ -16,28 +11,25 @@ import {
16
11
  writethroughNoop,
17
12
  } from "../lsp";
18
13
  import applyPatchDescription from "../prompts/tools/apply-patch.md" with { type: "text" };
19
- import hashlineDescription from "../prompts/tools/hashline.md" with { type: "text" };
20
14
  import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
21
15
  import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
22
16
  import type { ToolSession } from "../tools";
23
17
  import { truncateForPrompt } from "../tools/approval";
24
18
  import { isInternalUrlPath } from "../tools/path-utils";
25
19
  import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
20
+ import { executeHashlineSingle, type HashlineParams, hashlineEditParamsSchema } from "./hashline";
26
21
  import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch";
27
22
  import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
28
23
  import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch";
29
24
  import { executeReplaceSingle, type ReplaceEditEntry, type ReplaceParams, replaceEditSchema } from "./modes/replace";
30
25
  import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
31
26
 
27
+ export * from "@oh-my-pi/hashline";
32
28
  export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/edit-mode";
33
29
  export * from "./apply-patch";
34
30
  export * from "./diff";
35
- export * from "./file-read-cache";
36
-
37
- // Resolve hashline grammar placeholders from the TypeScript constants.
38
- const hashlineGrammar = resolveHashlineGrammarPlaceholders(hashlineGrammarTemplate);
39
-
40
- export * from "../hashline";
31
+ export * from "./file-snapshot-store";
32
+ export * from "./hashline";
41
33
  export * from "./modes/apply-patch";
42
34
  export * from "./modes/patch";
43
35
  export * from "./modes/replace";
@@ -270,19 +262,17 @@ async function executeSinglePathEntries(
270
262
 
271
263
  function extractApprovalPath(args: unknown): string {
272
264
  const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
273
- const targetPath = record.path;
274
- if (typeof targetPath === "string" && targetPath.length > 0) {
275
- return targetPath;
276
- }
277
-
278
265
  const input = typeof record.input === "string" ? record.input : undefined;
279
- if (!input) return "(unknown)";
266
+ if (input) {
267
+ const hashlineMatch = /^(?:¶|§|@)([^\s#]+)/m.exec(input);
268
+ if (hashlineMatch?.[1]) return hashlineMatch[1];
280
269
 
281
- const hashlineMatch = /^(?:¶|§|@)([^\s#]+)/m.exec(input);
282
- if (hashlineMatch?.[1]) return hashlineMatch[1];
270
+ const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input);
271
+ if (applyPatchMatch?.[1]) return applyPatchMatch[1].trim();
272
+ }
283
273
 
284
- const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input);
285
- return applyPatchMatch?.[1]?.trim() || "(unknown)";
274
+ const targetPath = record.path;
275
+ return typeof targetPath === "string" && targetPath.length > 0 ? targetPath : "(unknown)";
286
276
  }
287
277
 
288
278
  export class EditTool implements AgentTool<TInput> {
@@ -438,11 +428,10 @@ export class EditTool implements AgentTool<TInput> {
438
428
  batchRequest: LspBatchRequest | undefined,
439
429
  _onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
440
430
  ) => {
441
- const { input, path } = params as HashlineParams & { path?: string };
431
+ const { input } = params as HashlineParams;
442
432
  return executeHashlineSingle({
443
433
  session: tool.session,
444
434
  input,
445
- path,
446
435
  signal,
447
436
  batchRequest,
448
437
  writethrough: tool.#writethrough,