@oh-my-pi/pi-coding-agent 12.13.0 → 12.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.
@@ -34,7 +34,14 @@ import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard"
34
34
  import { applyPatch } from "./applicator";
35
35
  import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff";
36
36
  import { findMatch } from "./fuzzy";
37
- import { applyHashlineEdits, computeLineHash, parseLineRef } from "./hashline";
37
+ import {
38
+ applyHashlineEdits,
39
+ computeLineHash,
40
+ type HashlineEdit,
41
+ type LineTag,
42
+ parseTag,
43
+ type ReplaceTextEdit,
44
+ } from "./hashline";
38
45
  import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
39
46
  import { buildNormativeUpdateInput } from "./normative";
40
47
  import { type EditToolDetails, getLspBatchRequest } from "./shared";
@@ -66,7 +73,7 @@ export {
66
73
  computeLineHash,
67
74
  formatHashLines,
68
75
  HashlineMismatchError,
69
- parseLineRef,
76
+ parseTag,
70
77
  streamHashLinesFromLines,
71
78
  streamHashLinesFromUtf8,
72
79
  validateLineRef,
@@ -129,63 +136,160 @@ const patchEditSchema = Type.Object({
129
136
  export type ReplaceParams = Static<typeof replaceEditSchema>;
130
137
  export type PatchParams = Static<typeof patchEditSchema>;
131
138
 
132
- const hashlineSetSchema = Type.Object(
139
+ /** Pattern matching hashline display format: `LINE#ID:CONTENT` */
140
+ const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[0-9a-zA-Z]{1,16}:/;
141
+
142
+ /** Pattern matching a unified-diff `+` prefix (but not `++`) */
143
+ const DIFF_PLUS_RE = /^[+-](?![+-])/;
144
+
145
+ /**
146
+ * Strip hashline display prefixes and diff `+` markers from replacement lines.
147
+ *
148
+ * Models frequently copy the `LINE#ID ` prefix from read output into their
149
+ * replacement content, or include unified-diff `+` prefixes. Both corrupt the
150
+ * output file. This strips them heuristically before application.
151
+ */
152
+ function stripNewLinePrefixes(lines: string[]): string[] {
153
+ // Detect whether the *majority* of non-empty lines carry a prefix —
154
+ // if only one line out of many has a match it's likely real content.
155
+ let hashPrefixCount = 0;
156
+ let diffPlusCount = 0;
157
+ let nonEmpty = 0;
158
+ for (const l of lines) {
159
+ if (l.length === 0) continue;
160
+ nonEmpty++;
161
+ if (HASHLINE_PREFIX_RE.test(l)) hashPrefixCount++;
162
+ if (DIFF_PLUS_RE.test(l)) diffPlusCount++;
163
+ }
164
+ if (nonEmpty === 0) return lines;
165
+
166
+ const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5;
167
+ const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
168
+
169
+ if (!stripHash && !stripPlus) return lines;
170
+
171
+ return lines.map(l => {
172
+ if (stripHash) return l.replace(HASHLINE_PREFIX_RE, "");
173
+ if (stripPlus) return l.replace(DIFF_PLUS_RE, "");
174
+ return l;
175
+ });
176
+ }
177
+
178
+ const hashlineReplaceContentFormat = (kind: string) =>
179
+ Type.Union([
180
+ Type.Null(),
181
+ Type.Array(Type.String(), { description: `${kind} lines` }),
182
+ Type.String({ description: `${kind} line` }),
183
+ ]);
184
+
185
+ const hashlineInsertContentFormat = (kind: string) =>
186
+ Type.Union([
187
+ Type.Array(Type.String(), { description: `${kind} lines`, minItems: 1 }),
188
+ Type.String({ description: `${kind} line`, minLength: 1 }),
189
+ ]);
190
+
191
+ const hashlineTagFormat = (what: string) =>
192
+ Type.String({
193
+ description: `Tag identifying the ${what} in "LINE#ID" format`,
194
+ });
195
+
196
+ function hashlineParseContent(edit: string | string[] | null): string[] {
197
+ if (edit === null) return [];
198
+ if (Array.isArray(edit)) return edit;
199
+ const lines = stripNewLinePrefixes(edit.split("\n"));
200
+ if (lines.length === 0) return [];
201
+ if (lines[lines.length - 1].trim() === "") return lines.slice(0, -1);
202
+ return lines;
203
+ }
204
+
205
+ function hashlineParseContentString(edit: string | string[] | null): string {
206
+ if (edit === null) return "";
207
+ if (Array.isArray(edit)) return edit.join("\n");
208
+ return edit;
209
+ }
210
+
211
+ const hashlineTargetEditSchema = Type.Object(
133
212
  {
134
- set: Type.Object({
135
- ref: Type.String({ description: 'Line reference "LINE#ID"' }),
136
- body: Type.Array(Type.String(), { description: "Replacement lines (empty array to delete)" }),
137
- }),
213
+ op: Type.Literal("set"),
214
+ tag: hashlineTagFormat("line being replaced"),
215
+ content: hashlineReplaceContentFormat("Replacement"),
138
216
  },
139
- { additionalProperties: true },
217
+ { additionalProperties: false },
140
218
  );
141
219
 
142
- const hashlineSetRangeSchema = Type.Object(
220
+ const hashlineAppendEditSchema = Type.Object(
143
221
  {
144
- set_range: Type.Object({
145
- beg: Type.String({ description: 'Start line ref "LINE#ID"' }),
146
- end: Type.String({ description: 'End line ref "LINE#ID"' }),
147
- body: Type.Array(Type.String(), { description: "Replacement lines (empty array to delete)" }),
148
- }),
222
+ op: Type.Literal("append"),
223
+ after: Type.Optional(hashlineTagFormat("line after which to append")),
224
+ content: hashlineInsertContentFormat("Appended"),
149
225
  },
150
- { additionalProperties: true },
226
+ { additionalProperties: false },
151
227
  );
152
- const hashlineInsertSchema = Type.Union([
153
- Type.Object(
154
- {
155
- insert: Type.Object({
156
- before: Type.Optional(Type.String({ minLength: 1, description: 'Insert before this line "LINE#ID"' })),
157
- after: Type.Optional(Type.String({ minLength: 1, description: 'Insert after this line "LINE#ID"' })),
158
- body: Type.Array(Type.String(), { description: "Lines to insert; must be non-empty" }),
159
- }),
160
- },
161
- { additionalProperties: true },
162
- ),
163
- ]);
164
- const hashlineReplaceSchema = Type.Object(
228
+
229
+ const hashlinePrependEditSchema = Type.Object(
165
230
  {
166
- replace: Type.Object({
167
- old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }),
168
- new_text: Type.String({ description: "Replacement text" }),
169
- all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })),
170
- }),
231
+ op: Type.Literal("prepend"),
232
+ before: Type.Optional(hashlineTagFormat("line before which to prepend")),
233
+ content: hashlineInsertContentFormat("Prepended"),
234
+ },
235
+ { additionalProperties: false },
236
+ );
237
+
238
+ const hashlineRangeEditSchema = Type.Object(
239
+ {
240
+ op: Type.Literal("replace"),
241
+ first: hashlineTagFormat("first line"),
242
+ last: hashlineTagFormat("last line"),
243
+ content: hashlineReplaceContentFormat("Replacement"),
244
+ },
245
+ { additionalProperties: false },
246
+ );
247
+
248
+ const hashlineInsertEditSchema = Type.Object(
249
+ {
250
+ op: Type.Literal("insert"),
251
+ before: Type.Optional(hashlineTagFormat("line before which to insert")),
252
+ after: Type.Optional(hashlineTagFormat("line after which to insert")),
253
+ content: hashlineInsertContentFormat("Inserted"),
254
+ },
255
+ { additionalProperties: false },
256
+ );
257
+
258
+ const hashlineReplaceTextEditSchema = Type.Object(
259
+ {
260
+ op: Type.Literal("replaceText"),
261
+ old_text: Type.String({ description: "Text to find", minLength: 1 }),
262
+ new_text: hashlineReplaceContentFormat("Replacement"),
263
+ all: Type.Optional(Type.Boolean({ description: "Replace all occurrences" })),
171
264
  },
172
- { additionalProperties: true },
265
+ { additionalProperties: false },
173
266
  );
174
- const hashlineEditItemSchema = Type.Union([
175
- hashlineSetSchema,
176
- hashlineSetRangeSchema,
177
- hashlineInsertSchema,
178
- hashlineReplaceSchema,
267
+
268
+ const HL_REPLACE_ENABLED = Bun.env.PI_HL_REPLACETXT === "1";
269
+
270
+ const hashlineEditSpecSchema = Type.Union([
271
+ hashlineTargetEditSchema,
272
+ hashlineRangeEditSchema,
273
+ hashlineAppendEditSchema,
274
+ hashlinePrependEditSchema,
275
+ hashlineInsertEditSchema,
276
+ ...(HL_REPLACE_ENABLED ? [hashlineReplaceTextEditSchema] : []),
179
277
  ]);
278
+
180
279
  const hashlineEditSchema = Type.Object(
181
280
  {
182
281
  path: Type.String({ description: "File path (relative or absolute)" }),
183
- edits: Type.Array(hashlineEditItemSchema, { description: "Array of edit operations" }),
282
+ edits: Type.Array(hashlineEditSpecSchema, {
283
+ description: "Changes to apply to the file at `path`",
284
+ minItems: 0,
285
+ }),
286
+ delete: Type.Optional(Type.Boolean({ description: "Delete the file when true" })),
287
+ rename: Type.Optional(Type.String({ description: "New path if moving" })),
184
288
  },
185
- { additionalProperties: true },
289
+ { additionalProperties: false },
186
290
  );
187
291
 
188
- export type HashlineEdit = Static<typeof hashlineEditItemSchema>;
292
+ export type HashlineToolEdit = Static<typeof hashlineEditSpecSchema>;
189
293
  export type HashlineParams = Static<typeof hashlineEditSchema>;
190
294
 
191
295
  // ═══════════════════════════════════════════════════════════════════════════
@@ -382,7 +486,7 @@ export class EditTool implements AgentTool<TInput> {
382
486
  case "patch":
383
487
  return renderPromptTemplate(patchDescription);
384
488
  case "hashline":
385
- return renderPromptTemplate(hashlineDescription);
489
+ return renderPromptTemplate(hashlineDescription, { allowReplaceText: HL_REPLACE_ENABLED });
386
490
  default:
387
491
  return renderPromptTemplate(replaceDescription);
388
492
  }
@@ -415,46 +519,143 @@ export class EditTool implements AgentTool<TInput> {
415
519
  // Hashline mode execution
416
520
  // ─────────────────────────────────────────────────────────────────
417
521
  if (this.mode === "hashline") {
418
- const { path, edits } = params as HashlineParams;
522
+ const { path, edits, delete: deleteFile, rename } = params as HashlineParams;
419
523
 
420
- enforcePlanModeWrite(this.session, path);
524
+ enforcePlanModeWrite(this.session, path, { op: deleteFile ? "delete" : "update", rename });
421
525
 
422
- if (path.endsWith(".ipynb")) {
526
+ if (path.endsWith(".ipynb") && edits?.length > 0) {
423
527
  throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
424
528
  }
425
529
 
426
- // Detect wrong-format fields from models confusing edit modes
427
- for (let i = 0; i < edits.length; i++) {
428
- const edit = edits[i] as Record<string, unknown>;
429
- if (("old_text" in edit || "new_text" in edit) && !("replace" in edit)) {
430
- throw new Error(
431
- `edits[${i}] contains 'old_text'/'new_text' at top level (replace mode). ` +
432
- `Use {replace: {old_text, new_text}} for hashline content replace, or {set}, {set_range}, {insert}.`,
433
- );
434
- }
435
- if ("diff" in edit) {
436
- throw new Error(
437
- `edits[${i}] contains 'diff' field from patch mode. ` +
438
- `Hashline edits use: {set}, {set_range}, {insert}, or {replace}.`,
439
- );
440
- }
441
- if (!("set" in edit) && !("set_range" in edit) && !("insert" in edit) && !("replace" in edit)) {
442
- throw new Error(
443
- `edits[${i}] must contain exactly one of: 'set', 'set_range', 'insert', or 'replace'. Got keys: [${Object.keys(edit).join(", ")}].`,
444
- );
445
- }
446
- }
447
-
448
- const anchorEdits = edits.filter((e): e is HashlineEdit => "set" in e || "set_range" in e || "insert" in e);
449
- const replaceEdits = edits.filter(
450
- (e): e is { replace: { old_text: string; new_text: string; all?: boolean } } => "replace" in e,
451
- );
452
-
453
530
  const absolutePath = resolvePlanPath(this.session, path);
531
+ const resolvedRename = rename ? resolvePlanPath(this.session, rename) : undefined;
454
532
  const file = Bun.file(absolutePath);
455
533
 
534
+ if (deleteFile) {
535
+ if (await file.exists()) {
536
+ await file.unlink();
537
+ }
538
+ invalidateFsScanAfterDelete(absolutePath);
539
+ return {
540
+ content: [{ type: "text", text: `Deleted ${path}` }],
541
+ details: {
542
+ diff: "",
543
+ op: "delete",
544
+ meta: outputMeta().get(),
545
+ },
546
+ };
547
+ }
548
+
456
549
  if (!(await file.exists())) {
457
- throw new Error(`File not found: ${path}`);
550
+ const content: string[] = [];
551
+ for (const edit of edits) {
552
+ switch (edit.op) {
553
+ case "append": {
554
+ if (edit.after) {
555
+ throw new Error(`File not found: ${path}`);
556
+ }
557
+ content.push(...hashlineParseContent(edit.content));
558
+ break;
559
+ }
560
+ case "prepend": {
561
+ if (edit.before) {
562
+ throw new Error(`File not found: ${path}`);
563
+ }
564
+ content.unshift(...hashlineParseContent(edit.content));
565
+ break;
566
+ }
567
+ default: {
568
+ throw new Error(`File not found: ${path}`);
569
+ }
570
+ }
571
+ }
572
+ await file.write(content.join("\n"));
573
+ return {
574
+ content: [{ type: "text", text: `Created ${path}` }],
575
+ details: {
576
+ diff: "",
577
+ op: "create",
578
+ meta: outputMeta().get(),
579
+ },
580
+ };
581
+ }
582
+
583
+ const anchorEdits: HashlineEdit[] = [];
584
+ const replaceEdits: ReplaceTextEdit[] = [];
585
+ for (const edit of edits) {
586
+ switch (edit.op) {
587
+ case "set": {
588
+ const { tag, content } = edit;
589
+ anchorEdits.push({ op: "set", tag: parseTag(tag), content: hashlineParseContent(content) });
590
+ break;
591
+ }
592
+ case "replace": {
593
+ const { first, last, content } = edit;
594
+ anchorEdits.push({
595
+ op: "replace",
596
+ first: parseTag(first),
597
+ last: parseTag(last),
598
+ content: hashlineParseContent(content),
599
+ });
600
+ break;
601
+ }
602
+ case "append": {
603
+ const { after, content } = edit;
604
+ anchorEdits.push({
605
+ op: "append",
606
+ ...(after ? { after: parseTag(after) } : {}),
607
+ content: hashlineParseContent(content),
608
+ });
609
+ break;
610
+ }
611
+ case "prepend": {
612
+ const { before, content } = edit;
613
+ anchorEdits.push({
614
+ op: "prepend",
615
+ ...(before ? { before: parseTag(before) } : {}),
616
+ content: hashlineParseContent(content),
617
+ });
618
+ break;
619
+ }
620
+ case "insert": {
621
+ const { before, after, content } = edit;
622
+ if (before && !after) {
623
+ anchorEdits.push({
624
+ op: "prepend",
625
+ before: parseTag(before),
626
+ content: hashlineParseContent(content),
627
+ });
628
+ } else if (after && !before) {
629
+ anchorEdits.push({
630
+ op: "append",
631
+ after: parseTag(after),
632
+ content: hashlineParseContent(content),
633
+ });
634
+ } else if (before && after) {
635
+ anchorEdits.push({
636
+ op: "insert",
637
+ before: parseTag(before),
638
+ after: parseTag(after),
639
+ content: hashlineParseContent(content),
640
+ });
641
+ } else {
642
+ throw new Error(`Insert must have both before and after tags.`);
643
+ }
644
+ break;
645
+ }
646
+ case "replaceText": {
647
+ const { old_text, new_text, all } = edit;
648
+ replaceEdits.push({
649
+ op: "replaceText",
650
+ old_text: old_text,
651
+ new_text: hashlineParseContentString(new_text),
652
+ all: all ?? false,
653
+ });
654
+ break;
655
+ }
656
+ default:
657
+ throw new Error(`Invalid edit operation: ${JSON.stringify(edit)}`);
658
+ }
458
659
  }
459
660
 
460
661
  const rawContent = await file.text();
@@ -469,12 +670,12 @@ export class EditTool implements AgentTool<TInput> {
469
670
 
470
671
  // Apply content-replace edits (substr-style fuzzy replace)
471
672
  for (const r of replaceEdits) {
472
- if (r.replace.old_text.length === 0) {
473
- throw new Error("replace.old_text must not be empty.");
673
+ if (r.old_text.length === 0) {
674
+ throw new Error("old_text must not be empty.");
474
675
  }
475
- const rep = replaceText(normalizedContent, r.replace.old_text, r.replace.new_text, {
676
+ const rep = replaceText(normalizedContent, r.old_text, r.new_text, {
476
677
  fuzzy: this.#allowFuzzy,
477
- all: r.replace.all ?? false,
678
+ all: r.all ?? false,
478
679
  threshold: this.#fuzzyThreshold,
479
680
  });
480
681
  normalizedContent = rep.content;
@@ -486,7 +687,7 @@ export class EditTool implements AgentTool<TInput> {
486
687
  warnings: anchorResult.warnings,
487
688
  noopEdits: anchorResult.noopEdits,
488
689
  };
489
- if (originalNormalized === result.content) {
690
+ if (originalNormalized === result.content && !rename) {
490
691
  let diagnostic = `No changes made to ${path}. The edits produced identical content.`;
491
692
  if (result.noopEdits && result.noopEdits.length > 0) {
492
693
  const details = result.noopEdits
@@ -502,21 +703,35 @@ export class EditTool implements AgentTool<TInput> {
502
703
  // Edits were not literally identical but heuristics normalized them back
503
704
  const lines = result.content.split("\n");
504
705
  const targetLines: string[] = [];
505
- for (const edit of edits) {
506
- const refs: string[] = [];
507
- if ("set" in edit) refs.push(edit.set.ref);
508
- else if ("set_range" in edit) refs.push(edit.set_range.beg, edit.set_range.end);
509
- else if ("insert" in edit) {
510
- if (edit.insert.after) refs.push(edit.insert.after);
511
- if (edit.insert.before) refs.push(edit.insert.before);
706
+ const refs: LineTag[] = [];
707
+ for (const edit of anchorEdits) {
708
+ refs.length = 0;
709
+ switch (edit.op) {
710
+ case "set":
711
+ refs.push(edit.tag);
712
+ break;
713
+ case "replace":
714
+ refs.push(edit.first, edit.last);
715
+ break;
716
+ case "append":
717
+ if (edit.after) refs.push(edit.after);
718
+ break;
719
+ case "prepend":
720
+ if (edit.before) refs.push(edit.before);
721
+ break;
722
+ case "insert":
723
+ refs.push(edit.after, edit.before);
724
+ break;
725
+ default:
726
+ break;
512
727
  }
728
+
513
729
  for (const ref of refs) {
514
730
  try {
515
- const parsed = parseLineRef(ref);
516
- if (parsed.line >= 1 && parsed.line <= lines.length) {
517
- const lineContent = lines[parsed.line - 1];
518
- const hash = computeLineHash(parsed.line, lineContent);
519
- targetLines.push(`${parsed.line}#${hash}|${lineContent}`);
731
+ if (ref.line >= 1 && ref.line <= lines.length) {
732
+ const lineContent = lines[ref.line - 1];
733
+ const hash = computeLineHash(ref.line, lineContent);
734
+ targetLines.push(`${ref.line}#${hash}:${lineContent}`);
520
735
  }
521
736
  } catch {
522
737
  /* skip malformed refs */
@@ -532,12 +747,25 @@ export class EditTool implements AgentTool<TInput> {
532
747
  }
533
748
 
534
749
  const finalContent = bom + restoreLineEndings(result.content, originalEnding);
535
- const diagnostics = await this.#writethrough(absolutePath, finalContent, signal, file, batchRequest);
536
- invalidateFsScanAfterWrite(absolutePath);
750
+ const writePath = resolvedRename ?? absolutePath;
751
+ const diagnostics = await this.#writethrough(
752
+ writePath,
753
+ finalContent,
754
+ signal,
755
+ Bun.file(writePath),
756
+ batchRequest,
757
+ );
758
+ if (resolvedRename && resolvedRename !== absolutePath) {
759
+ await file.unlink();
760
+ invalidateFsScanAfterRename(absolutePath, resolvedRename);
761
+ } else {
762
+ invalidateFsScanAfterWrite(absolutePath);
763
+ }
537
764
  const diffResult = generateDiffString(originalNormalized, result.content);
538
765
 
539
766
  const normative = buildNormativeUpdateInput({
540
767
  path,
768
+ ...(rename ? { rename } : {}),
541
769
  oldContent: rawContent,
542
770
  newContent: finalContent,
543
771
  });
@@ -546,17 +774,20 @@ export class EditTool implements AgentTool<TInput> {
546
774
  .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
547
775
  .get();
548
776
 
777
+ const resultText = rename ? `Updated and moved ${path} to ${rename}` : `Updated ${path}`;
549
778
  return {
550
779
  content: [
551
780
  {
552
781
  type: "text",
553
- text: `Updated ${path}${result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : ""}`,
782
+ text: `${resultText}${result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : ""}`,
554
783
  },
555
784
  ],
556
785
  details: {
557
786
  diff: diffResult.diff,
558
787
  firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
559
788
  diagnostics,
789
+ op: "update",
790
+ rename,
560
791
  meta,
561
792
  },
562
793
  $normative: normative,
@@ -602,17 +833,10 @@ export class EditTool implements AgentTool<TInput> {
602
833
 
603
834
  // Generate diff for display
604
835
  let diffResult = { diff: "", firstChangedLine: undefined as number | undefined };
605
- let normative: PatchInput | undefined;
606
836
  if (result.change.type === "update" && result.change.oldContent && result.change.newContent) {
607
837
  const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text);
608
838
  const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text);
609
839
  diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew);
610
- normative = buildNormativeUpdateInput({
611
- path,
612
- rename: effRename,
613
- oldContent: result.change.oldContent,
614
- newContent: result.change.newContent,
615
- });
616
840
  }
617
841
 
618
842
  let resultText: string;
@@ -650,7 +874,6 @@ export class EditTool implements AgentTool<TInput> {
650
874
  rename: effRename,
651
875
  meta,
652
876
  },
653
- $normative: normative,
654
877
  };
655
878
  }
656
879
 
@@ -86,10 +86,10 @@ interface EditRenderArgs {
86
86
  }
87
87
 
88
88
  type HashlineEditPreview =
89
- | { set: { ref: string; body: string[] } }
90
- | { set_range: { beg: string; end: string; body: string[] } }
91
- | { insert: { before?: string; after?: string; body: string[] } }
92
- | { replace: { old_text: string; new_text: string; all?: boolean } };
89
+ | { target: string; new_content: string[] }
90
+ | { first: string; last: string; new_content: string[] }
91
+ | { before?: string; after?: string; inserted_lines: string[] }
92
+ | { old_text: string; new_text: string; all?: boolean };
93
93
 
94
94
  /** Extended context for edit tool rendering */
95
95
  export interface EditRenderContext {
@@ -168,52 +168,35 @@ function formatStreamingHashlineEdits(edits: unknown[], uiTheme: Theme, ui: Tool
168
168
  dst: "",
169
169
  };
170
170
  }
171
- if ("set" in editRecord) {
172
- const setLine = asRecord(editRecord.set);
171
+ if ("target" in editRecord) {
172
+ const target = typeof editRecord.target === "string" ? editRecord.target : "…";
173
+ const newContent = editRecord.new_content;
173
174
  return {
174
- srcLabel: `• set ${typeof setLine?.ref === "string" ? setLine.ref : "…"}`,
175
- dst: Array.isArray(setLine?.body)
176
- ? (setLine.body as string[]).join("\n")
177
- : typeof setLine?.body === "string"
178
- ? setLine.body
179
- : "",
175
+ srcLabel: `• line ${target}`,
176
+ dst: Array.isArray(newContent) ? (newContent as string[]).join("\n") : "",
180
177
  };
181
178
  }
182
- if ("set_range" in editRecord) {
183
- const setRange = asRecord(editRecord.set_range);
184
- const start = typeof setRange?.beg === "string" ? setRange.beg : "…";
185
- const end = typeof setRange?.end === "string" ? setRange.end : "…";
179
+ if ("first" in editRecord || "last" in editRecord) {
180
+ const first = typeof editRecord.first === "string" ? editRecord.first : "…";
181
+ const last = typeof editRecord.last === "string" ? editRecord.last : "…";
182
+ const newContent = editRecord.new_content;
186
183
  return {
187
- srcLabel: `• set_range ${start}..${end}`,
188
- dst: Array.isArray(setRange?.body)
189
- ? (setRange.body as string[]).join("\n")
190
- : typeof setRange?.body === "string"
191
- ? setRange.body
192
- : "",
184
+ srcLabel: `• range ${first}..${last}`,
185
+ dst: Array.isArray(newContent) ? (newContent as string[]).join("\n") : "",
193
186
  };
194
187
  }
195
- if ("replace" in editRecord) {
196
- const replace = asRecord(editRecord.replace);
197
- const all = typeof replace?.all === "boolean" ? replace.all : false;
188
+ if ("old_text" in editRecord || "new_text" in editRecord) {
189
+ const all = typeof editRecord.all === "boolean" ? editRecord.all : false;
198
190
  return {
199
191
  srcLabel: `• replace old_text→new_text${all ? " (all)" : ""}`,
200
- dst: typeof replace?.new_text === "string" ? replace.new_text : "",
192
+ dst: typeof editRecord.new_text === "string" ? editRecord.new_text : "",
201
193
  };
202
194
  }
203
- if ("insert" in editRecord) {
204
- const insertOp = asRecord(editRecord.insert);
205
- const after = typeof insertOp?.after === "string" ? insertOp.after : undefined;
206
- const before = typeof insertOp?.before === "string" ? insertOp.before : undefined;
207
- const body = insertOp?.body;
208
- const text = Array.isArray(body)
209
- ? (body as string[]).join("\n")
210
- : typeof body === "string"
211
- ? body
212
- : typeof insertOp?.text === "string"
213
- ? insertOp.text
214
- : typeof insertOp?.content === "string"
215
- ? (insertOp.content as string)
216
- : "";
195
+ if ("inserted_lines" in editRecord || "before" in editRecord || "after" in editRecord) {
196
+ const after = typeof editRecord.after === "string" ? editRecord.after : undefined;
197
+ const before = typeof editRecord.before === "string" ? editRecord.before : undefined;
198
+ const insertedLines = editRecord.inserted_lines;
199
+ const text = Array.isArray(insertedLines) ? (insertedLines as string[]).join("\n") : "";
217
200
  const refs = [after, before].filter(Boolean).join("..") || "…";
218
201
  return {
219
202
  srcLabel: `• insert ${refs}`,
@@ -1,5 +1,5 @@
1
1
  <identity>
2
- Distinguished Staff Engineer.
2
+ You are a distinguished staff engineer operating inside Oh My Pi, a Pi-based coding harness.
3
3
 
4
4
  High-agency. Principled. Decisive.
5
5
  Expertise: debugging, refactoring, system design.
@@ -172,6 +172,17 @@ Main branch: {{git.mainBranch}}
172
172
  {{/if}}
173
173
  </project>
174
174
 
175
+ <harness>
176
+ Oh My Pi ships internal documentation accessible via `docs://` URLs (resolved by tools like read/grep).
177
+ - Read `docs://` to list all available documentation files
178
+ - Read `docs://<file>.md` to read a specific doc
179
+
180
+ <critical>
181
+ - **ONLY** read docs when the user asks about omp/pi itself: its SDK, extensions, themes, skills, TUI, keybindings, or configuration.
182
+ - When working on omp/pi topics, read the relevant docs and follow .md cross-references before implementing.
183
+ </critical>
184
+ </harness>
185
+
175
186
  {{#if skills.length}}
176
187
  <skills>
177
188
  Scan descriptions vs task domain. Skill covers output? Read `skill://<name>` first.
@@ -221,7 +232,7 @@ Notice the sequential habit:
221
232
  - Comfort in doing one thing at a time
222
233
  - Illusion that order = correctness
223
234
  - Assumption that B depends on A
224
- **Use Task tool when:**
235
+ **Use Task tool when:**
225
236
  - Editing 4+ files with no dependencies between edits
226
237
  - Investigating 2+ independent subsystems
227
238
  - Work decomposes into pieces not needing each other's results