pi-hashline-edit-pro 0.13.3 → 0.14.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.
package/src/config.ts ADDED
@@ -0,0 +1,97 @@
1
+ import { readFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ import { readFile, writeFile, mkdir } from "fs/promises";
5
+
6
+ export type ReplaceMode = "bulk" | "flat";
7
+
8
+ export interface Config {
9
+ replaceMode: ReplaceMode;
10
+ autoRead: boolean;
11
+ }
12
+
13
+ const DEFAULT_CONFIG: Config = {
14
+ replaceMode: "bulk",
15
+ autoRead: false,
16
+ };
17
+
18
+ /** Compute config path lazily so tests can override HOME before calling. */
19
+ function configPath(): string {
20
+ return join(homedir(), ".config", "pi-hashline-edit-pro", "config.json");
21
+ }
22
+
23
+ function configDir(): string {
24
+ return join(homedir(), ".config", "pi-hashline-edit-pro");
25
+ }
26
+
27
+ export async function readConfig(): Promise<Config> {
28
+ try {
29
+ const content = await readFile(configPath(), "utf-8");
30
+ const parsed = JSON.parse(content) as Partial<Config>;
31
+ return {
32
+ replaceMode: parsed.replaceMode === "flat" ? "flat" : "bulk",
33
+ autoRead: parsed.autoRead === true,
34
+ };
35
+ } catch {
36
+ return { ...DEFAULT_CONFIG };
37
+ }
38
+ }
39
+
40
+ export function readConfigSync(): Config {
41
+ try {
42
+ const content = readFileSync(configPath(), "utf-8");
43
+ const parsed = JSON.parse(content) as Partial<Config>;
44
+ return {
45
+ replaceMode: parsed.replaceMode === "flat" ? "flat" : "bulk",
46
+ autoRead: parsed.autoRead === true,
47
+ };
48
+ } catch {
49
+ return { ...DEFAULT_CONFIG };
50
+ }
51
+ }
52
+
53
+ export async function writeConfig(config: Config): Promise<void> {
54
+ await mkdir(configDir(), { recursive: true });
55
+ await writeFile(configPath(), JSON.stringify(config, null, 2), "utf-8");
56
+ }
57
+
58
+ export async function readReplaceMode(): Promise<ReplaceMode> {
59
+ const config = await readConfig();
60
+ return config.replaceMode;
61
+ }
62
+
63
+ export async function writeReplaceMode(mode: ReplaceMode): Promise<void> {
64
+ const config = await readConfig();
65
+ config.replaceMode = mode;
66
+ await writeConfig(config);
67
+ }
68
+
69
+ export async function toggleReplaceMode(): Promise<ReplaceMode> {
70
+ const config = await readConfig();
71
+ config.replaceMode = config.replaceMode === "bulk" ? "flat" : "bulk";
72
+ await writeConfig(config);
73
+ return config.replaceMode;
74
+ }
75
+
76
+ export async function readAutoRead(): Promise<boolean> {
77
+ const config = await readConfig();
78
+ return config.autoRead;
79
+ }
80
+
81
+ export function readAutoReadSync(): boolean {
82
+ const config = readConfigSync();
83
+ return config.autoRead;
84
+ }
85
+
86
+ export async function writeAutoRead(value: boolean): Promise<void> {
87
+ const config = await readConfig();
88
+ config.autoRead = value;
89
+ await writeConfig(config);
90
+ }
91
+
92
+ export async function toggleAutoRead(): Promise<boolean> {
93
+ const config = await readConfig();
94
+ config.autoRead = !config.autoRead;
95
+ await writeConfig(config);
96
+ return config.autoRead;
97
+ }
@@ -92,11 +92,13 @@ function resToSpan(
92
92
  return null;
93
93
  }
94
94
 
95
+ const label = descEdit(edit);
96
+
95
97
  if (edit.content_lines.length > 0) {
96
98
  return {
97
99
  kind: "replace",
98
100
  index,
99
- label: descEdit(edit),
101
+ label,
100
102
  start: lineStarts[startLine - 1]!,
101
103
  end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
102
104
  replacement: edit.content_lines.join("\n"),
@@ -107,7 +109,7 @@ function resToSpan(
107
109
  return {
108
110
  kind: "replace",
109
111
  index,
110
- label: descEdit(edit),
112
+ label,
111
113
  start: 0,
112
114
  end: content.length,
113
115
  replacement: "",
@@ -118,7 +120,7 @@ function resToSpan(
118
120
  return {
119
121
  kind: "replace",
120
122
  index,
121
- label: descEdit(edit),
123
+ label,
122
124
  start: lineStarts[startLine - 1]!,
123
125
  end: lineStarts[endLine]!,
124
126
  replacement: "",
@@ -128,7 +130,7 @@ function resToSpan(
128
130
  return {
129
131
  kind: "replace",
130
132
  index,
131
- label: descEdit(edit),
133
+ label,
132
134
  start: Math.max(0, lineStarts[startLine - 1]! - 1),
133
135
  end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
134
136
  replacement: "",
package/src/prompts.ts CHANGED
@@ -1,19 +1,25 @@
1
1
  import { readFileSync } from "fs";
2
2
 
3
3
  export function loadP(relativePath: string, replacements?: Record<string, string>): string {
4
- let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
5
- if (replacements) {
6
- for (const [key, value] of Object.entries(replacements)) {
7
- content = content.replaceAll(`{{${key}}}`, value);
8
- }
9
- }
10
- return content;
4
+ let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
5
+ if (replacements) {
6
+ for (const [key, value] of Object.entries(replacements)) {
7
+ content = content.replaceAll(`{{${key}}}`, value);
8
+ }
9
+ }
10
+ return content;
11
11
  }
12
12
 
13
- export function loadGuide(relativePath: string): string[] {
14
- return readFileSync(new URL(relativePath, import.meta.url), "utf-8")
15
- .split("\n")
16
- .map((line) => line.trim())
17
- .filter((line) => line.startsWith("- "))
18
- .map((line) => line.slice(2));
13
+ export function loadGuide(relativePath: string, replacements?: Record<string, string>): string[] {
14
+ let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8");
15
+ if (replacements) {
16
+ for (const [key, value] of Object.entries(replacements)) {
17
+ content = content.replaceAll(`{{${key}}}`, value);
18
+ }
19
+ }
20
+ return content
21
+ .split("\n")
22
+ .map((line) => line.trim())
23
+ .filter((line) => line.startsWith("- "))
24
+ .map((line) => line.slice(2));
19
25
  }
@@ -0,0 +1,313 @@
1
+ import { Markdown, Text } from "@earendil-works/pi-tui";
2
+ import type {
3
+ ExtensionAPI,
4
+ ToolDefinition,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
7
+ import { Type } from "typebox";
8
+ import { constants } from "fs";
9
+ import {
10
+ genDiff,
11
+ restoreEndings,
12
+ } from "./replace-diff";
13
+ import { readNormFile } from "./file-reader";
14
+ import { normReq } from "./replace-normalize";
15
+ import { isRec } from "./utils";
16
+ import { MAX_HASH_LINES } from "./constants";
17
+ import { resolveTarget, writeAtomic } from "./fs-write";
18
+ import {
19
+ applyEdits,
20
+ lineHashes,
21
+ resEdits,
22
+ type HTEdit,
23
+ } from "./hashline";
24
+ import { toCwd } from "./path-utils";
25
+ import { abortIf } from "./runtime";
26
+ import { fileSnap } from "./snapshot";
27
+ import {
28
+ buildChanged,
29
+ buildNoop,
30
+ type RMeta,
31
+ type RMetrics,
32
+ } from "./replace-response";
33
+ import {
34
+ buildAppliedText,
35
+ mkMdTheme,
36
+ fmtCall,
37
+ fmtResultMd,
38
+ getPreviewInput,
39
+ getResultText,
40
+ isApplied,
41
+ type RPreview,
42
+ type RRState,
43
+ } from "./replace-render";
44
+ import { loadP, loadGuide } from "./prompts";
45
+ import { execPipeline, compPreview, type ReplaceDetails } from "./replace";
46
+ import { readAutoReadSync } from "./config";
47
+
48
+ const contentLinesSchema = Type.Array(Type.String(), {
49
+ description:
50
+ "literal replacement file content, one string per line. Must not include the HASH│ prefix from read output.",
51
+ });
52
+
53
+ const hashRangeInclSchema = Type.Array(
54
+ Type.String({ description: "anchor (3-char HASH)" }),
55
+ {
56
+ description: "inclusive hash range to replace [start_hash, end_hash]. Each element must be the 3-character hash anchor only; do not include the │ separator or line content.",
57
+ minItems: 2,
58
+ maxItems: 2,
59
+ },
60
+ );
61
+
62
+ /**
63
+ * Flat-mode schema: hash_range_inclusive and content_lines are at the top
64
+ * level instead of inside a "changes" array. Only a single edit is supported
65
+ * per call (no bulk changes).
66
+ */
67
+ export const flatEditToolSchema = Type.Object(
68
+ {
69
+ path: Type.String({ description: "path" }),
70
+ hash_range_inclusive: hashRangeInclSchema,
71
+ content_lines: contentLinesSchema,
72
+ },
73
+ { additionalProperties: false },
74
+ );
75
+
76
+ type ToolDef = ToolDefinition<
77
+ typeof flatEditToolSchema,
78
+ ReplaceDetails,
79
+ RRState
80
+ > & { renderShell?: "default" | "self" };
81
+
82
+ function reuseText(context: any, content: string): Text {
83
+ const t = context.lastComponent instanceof Text
84
+ ? context.lastComponent
85
+ : new Text("", 0, 0);
86
+ t.setText(content);
87
+ return t;
88
+ }
89
+
90
+ function reuseMarkdown(context: any, content: string, theme: any): Markdown {
91
+ const m = context.lastComponent instanceof Markdown
92
+ ? context.lastComponent
93
+ : new Markdown("", 0, 0, mkMdTheme(theme));
94
+ m.setText(content);
95
+ return m;
96
+ }
97
+
98
+ export function buildToolDef(): ToolDef {
99
+ const autoRead = readAutoReadSync();
100
+ const readGuidance = autoRead
101
+ ? "Anchors are provided automatically after write operations when auto-read is enabled."
102
+ : "Call `read` to get fresh anchors for follow-up edits.";
103
+
104
+ const E_DESC = loadP("../prompts/replace-flat.md", {
105
+ AUTO_READ_GUIDANCE: readGuidance,
106
+ });
107
+ const E_SNIPPET = loadP("../prompts/replace-flat-snippet.md");
108
+ const E_GUIDE = loadGuide("../prompts/replace-flat-guidelines.md", {
109
+ AUTO_READ_GUIDANCE: readGuidance,
110
+ });
111
+
112
+ return {
113
+ name: "replace",
114
+ label: "Replace",
115
+ description: E_DESC,
116
+ parameters: flatEditToolSchema,
117
+ promptSnippet: E_SNIPPET,
118
+ promptGuidelines: E_GUIDE,
119
+ prepareArguments: (args: unknown) => {
120
+ // Minimal normalization: file_path → path, JSON string parsing.
121
+ // The flat-to-canonical conversion happens in execute().
122
+ if (!isRec(args)) return args as any;
123
+ const record = { ...args };
124
+ if (typeof record.path !== "string" && typeof record.file_path === "string") {
125
+ record.path = record.file_path;
126
+ delete record.file_path;
127
+ }
128
+ if (typeof record.hash_range_inclusive === "string") {
129
+ try { record.hash_range_inclusive = JSON.parse(record.hash_range_inclusive as string); } catch { /* keep as-is */ }
130
+ }
131
+ if (typeof record.content_lines === "string") {
132
+ try { record.content_lines = JSON.parse(record.content_lines as string); } catch { /* keep as-is */ }
133
+ }
134
+ return record as any;
135
+ },
136
+ renderShell: "default",
137
+ renderCall(args, theme, context) {
138
+ const previewInput = getPreviewInput(args);
139
+ if (context.executionStarted) {
140
+ context.state.argsKey = undefined;
141
+ context.state.preview = undefined;
142
+ context.state.previewGeneration =
143
+ (context.state.previewGeneration ?? 0) + 1;
144
+ } else if (!context.argsComplete || !previewInput) {
145
+ context.state.argsKey = undefined;
146
+ context.state.preview = undefined;
147
+ context.state.previewGeneration =
148
+ (context.state.previewGeneration ?? 0) + 1;
149
+ } else {
150
+ const argsKey = JSON.stringify(previewInput);
151
+ if (context.state.argsKey !== argsKey) {
152
+ context.state.argsKey = argsKey;
153
+ context.state.preview = undefined;
154
+ const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
155
+ context.state.previewGeneration = previewGeneration;
156
+ compPreview(previewInput, context.cwd)
157
+ .then((preview) => {
158
+ if (
159
+ context.state.argsKey === argsKey &&
160
+ context.state.previewGeneration === previewGeneration
161
+ ) {
162
+ context.state.preview = preview;
163
+ context.invalidate();
164
+ }
165
+ })
166
+ .catch((err: unknown) => {
167
+ if (
168
+ context.state.argsKey === argsKey &&
169
+ context.state.previewGeneration === previewGeneration
170
+ ) {
171
+ context.state.preview = {
172
+ error: err instanceof Error ? err.message : String(err),
173
+ };
174
+ context.invalidate();
175
+ }
176
+ });
177
+ }
178
+ }
179
+ const text =
180
+ (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
181
+ text.setText(
182
+ fmtCall(
183
+ getPreviewInput(args) ?? undefined,
184
+ context.state as RRState,
185
+ context.expanded,
186
+ theme,
187
+ ),
188
+ );
189
+ return text;
190
+ },
191
+
192
+ renderResult(result, { isPartial }, theme, context) {
193
+ if (isPartial) {
194
+ return reuseText(context, theme.fg("warning", "Editing..."));
195
+ }
196
+
197
+ const typedResult = result as {
198
+ content?: Array<{ type: string; text?: string }>;
199
+ details?: ReplaceDetails;
200
+ };
201
+ const renderedText = getResultText(typedResult);
202
+
203
+ const renderState = context.state as RRState | undefined;
204
+ if (renderState) {
205
+ renderState.preview = undefined;
206
+ renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
207
+ }
208
+
209
+ if (context.isError) {
210
+ return renderedText
211
+ ? reuseText(context, `\n${theme.fg("error", renderedText)}`)
212
+ : new Text("", 0, 0);
213
+ }
214
+
215
+ if (isApplied(typedResult.details)) {
216
+ const appliedText = buildAppliedText(renderedText, typedResult.details, theme);
217
+ return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
218
+ }
219
+
220
+ if (!renderedText) return new Text("", 0, 0);
221
+ return reuseMarkdown(context, fmtResultMd(renderedText), theme);
222
+ },
223
+
224
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
225
+ // Wrap flat params into canonical shape for the pipeline
226
+ const canonical = normReq({
227
+ path: params.path,
228
+ changes: [{
229
+ hash_range_inclusive: params.hash_range_inclusive,
230
+ content_lines: params.content_lines,
231
+ }],
232
+ });
233
+ const normalizedParams = canonical as { path: string; changes: HTEdit[] };
234
+ const path = normalizedParams.path;
235
+ const absolutePath = toCwd(path, ctx.cwd);
236
+ const mutationTargetPath = await resolveTarget(absolutePath);
237
+ return withFileMutationQueue(mutationTargetPath, async () => {
238
+ abortIf(signal);
239
+
240
+ const {
241
+ originalNormalized,
242
+ result,
243
+ bom,
244
+ originalEnding,
245
+ hadUtf8DecodeErrors,
246
+ warnings,
247
+ noopEdits,
248
+ firstChangedLine,
249
+ lastChangedLine,
250
+ resultHashes,
251
+ } = await execPipeline(
252
+ normalizedParams,
253
+ ctx.cwd,
254
+ constants.R_OK | constants.W_OK,
255
+ signal,
256
+ );
257
+
258
+ const editsAttempted = 1; // flat mode: exactly one edit per call
259
+
260
+ if (originalNormalized === result) {
261
+ const noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
262
+ return buildNoop({
263
+ path,
264
+ noopEdits,
265
+ snapshotId: noopSnapshotId,
266
+ editMeta: {
267
+ editsAttempted,
268
+ noopEditsCount: noopEdits?.length ?? 0,
269
+ },
270
+ warnings,
271
+ });
272
+ }
273
+
274
+ if (hadUtf8DecodeErrors) {
275
+ warnings.push(
276
+ "Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
277
+ );
278
+ }
279
+
280
+ abortIf(signal);
281
+ await writeAtomic(
282
+ absolutePath,
283
+ bom + restoreEndings(result, originalEnding),
284
+ );
285
+ const updatedSnapshotId = (await fileSnap(absolutePath))
286
+ .snapshotId;
287
+
288
+ const editMeta: RMeta = {
289
+ editsAttempted,
290
+ noopEditsCount: noopEdits?.length ?? 0,
291
+ firstChangedLine,
292
+ lastChangedLine,
293
+ };
294
+
295
+ const successInput = {
296
+ path,
297
+ originalNormalized,
298
+ result,
299
+ resultHashes,
300
+ warnings,
301
+ snapshotId: updatedSnapshotId,
302
+ editMeta,
303
+ };
304
+
305
+ return buildChanged(successInput);
306
+ });
307
+ },
308
+ };
309
+ }
310
+
311
+ export function regReplaceFlat(pi: ExtensionAPI): void {
312
+ pi.registerTool(buildToolDef());
313
+ }
@@ -27,6 +27,32 @@ function coerceEditArray(items: unknown[]): unknown[] {
27
27
  });
28
28
  }
29
29
 
30
+ /**
31
+ * Normalizes a field from `from` to `to`: JSON-string arrays → real arrays,
32
+ * single objects → wrapped in array. Shared by the `changes` and `edits`
33
+ * normalization branches.
34
+ */
35
+ function normalizeField(
36
+ record: Record<string, unknown>,
37
+ from: string,
38
+ to: string,
39
+ ): void {
40
+ if (!has(record, from)) return;
41
+ const raw = tryParseJSON(record[from], Array.isArray) ?? record[from];
42
+ if (Array.isArray(raw)) {
43
+ record[to] = coerceEditArray(raw);
44
+ } else {
45
+ const single =
46
+ typeof raw === "string"
47
+ ? tryParseJSON(raw, isRec)
48
+ : isRec(raw)
49
+ ? raw
50
+ : undefined;
51
+ if (single) record[to] = coerceEditArray([single]);
52
+ }
53
+ if (from !== to) delete record[from];
54
+ }
55
+
30
56
  export function normReq(input: unknown): unknown {
31
57
  if (!isRec(input)) {
32
58
  return input;
@@ -39,32 +65,26 @@ export function normReq(input: unknown): unknown {
39
65
  delete record.file_path;
40
66
  }
41
67
 
42
- const hasChangesField = has(record, "changes");
43
- const hasEditsField = has(record, "edits");
68
+ normalizeField(record, "changes", "changes");
69
+ normalizeField(record, "edits", "changes");
44
70
 
45
- if (hasChangesField) {
46
- const raw = tryParseJSON(record.changes, Array.isArray) ?? record.changes;
47
- if (Array.isArray(raw)) {
48
- record.changes = coerceEditArray(raw);
49
- } else {
50
- // Single object (JSON string or literal) wrap in array
51
- const single = typeof raw === "string"
52
- ? tryParseJSON(raw, isRec)
53
- : isRec(raw) ? raw : undefined;
54
- if (single) record.changes = coerceEditArray([single]);
55
- }
56
- } else if (hasEditsField) {
57
- const raw = tryParseJSON(record.edits, Array.isArray) ?? record.edits;
58
- if (Array.isArray(raw)) {
59
- record.changes = coerceEditArray(raw);
60
- } else {
61
- const single = typeof raw === "string"
62
- ? tryParseJSON(raw, isRec)
63
- : isRec(raw) ? raw : undefined;
64
- if (single) record.changes = coerceEditArray([single]);
71
+ // Handle flat format: hash_range_inclusive and content_lines at top level
72
+ // (no changes array). Wrap them into a single-element changes array.
73
+ if (!Array.isArray(record.changes) && has(record, "hash_range_inclusive") && has(record, "content_lines")) {
74
+ const hri = tryParseJSON(record.hash_range_inclusive, (v): v is string[] =>
75
+ Array.isArray(v) && v.length === 2 && v.every((i) => typeof i === "string")
76
+ ) ?? record.hash_range_inclusive;
77
+
78
+ const cl = tryParseJSON(record.content_lines, (v): v is string[] =>
79
+ Array.isArray(v) && v.every((i) => typeof i === "string")
80
+ ) ?? record.content_lines;
81
+
82
+ if (Array.isArray(hri) && Array.isArray(cl)) {
83
+ record.changes = [{ hash_range_inclusive: hri, content_lines: cl }];
84
+ delete record.hash_range_inclusive;
85
+ delete record.content_lines;
65
86
  }
66
- delete record.edits;
67
- }
87
+ }
68
88
 
69
89
  return record;
70
90
  }
@@ -142,7 +142,7 @@ export function buildNoop(input: NoopInput): TResult {
142
142
  }
143
143
 
144
144
  export function buildChanged(input: SuccessInput): TResult {
145
- const { result, warnings, snapshotId, originalNormalized, editMeta } = input;
145
+ const { path, result, warnings, snapshotId, originalNormalized, editMeta } = input;
146
146
 
147
147
  const resultLines = visLines(result);
148
148
  const resultHashes = input.resultHashes ?? lineHashes(result);
@@ -150,9 +150,12 @@ export function buildChanged(input: SuccessInput): TResult {
150
150
  const addedLines = cntDiff(diffResult.diff, "+");
151
151
  const removedLines = cntDiff(diffResult.diff, "-");
152
152
  const warningsBlock = warnBlock(warnings);
153
+ const successPrefix = `Successfully replaced in ${path}.`;
153
154
  const text = resultLines.length === 0
154
155
  ? "File is empty. Use replace to insert content."
155
- : warningsBlock.trimStart();
156
+ : warningsBlock
157
+ ? `${successPrefix}${warningsBlock}`
158
+ : successPrefix;
156
159
 
157
160
  const metrics = buildM({
158
161
  classification: "applied",