@oh-my-pi/pi-coding-agent 14.6.6 → 14.7.1

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 (66) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/examples/hooks/handoff.ts +1 -1
  3. package/examples/hooks/qna.ts +1 -1
  4. package/examples/sdk/03-custom-prompt.ts +7 -4
  5. package/examples/sdk/README.md +1 -1
  6. package/package.json +12 -12
  7. package/src/autoresearch/index.ts +48 -44
  8. package/src/cli/grep-cli.ts +1 -1
  9. package/src/cli/read-cli.ts +58 -0
  10. package/src/cli.ts +1 -0
  11. package/src/commands/read.ts +40 -0
  12. package/src/commit/agentic/agent.ts +1 -1
  13. package/src/commit/analysis/conventional.ts +1 -1
  14. package/src/commit/analysis/summary.ts +1 -1
  15. package/src/commit/changelog/generate.ts +1 -1
  16. package/src/commit/map-reduce/map-phase.ts +1 -1
  17. package/src/commit/map-reduce/reduce-phase.ts +1 -1
  18. package/src/config/settings-schema.ts +49 -0
  19. package/src/config/settings.ts +71 -1
  20. package/src/dap/client.ts +1 -0
  21. package/src/discovery/builtin.ts +34 -9
  22. package/src/edit/line-hash.ts +34 -4
  23. package/src/edit/modes/hashline.ts +352 -8
  24. package/src/edit/streaming.ts +4 -1
  25. package/src/export/html/index.ts +1 -1
  26. package/src/extensibility/extensions/runner.ts +3 -3
  27. package/src/extensibility/extensions/types.ts +4 -4
  28. package/src/internal-urls/docs-index.generated.ts +1 -1
  29. package/src/main.ts +13 -18
  30. package/src/memories/index.ts +1 -1
  31. package/src/modes/components/agent-dashboard.ts +1 -1
  32. package/src/modes/components/read-tool-group.ts +4 -9
  33. package/src/modes/components/tool-execution.ts +4 -0
  34. package/src/modes/controllers/event-controller.ts +2 -0
  35. package/src/modes/interactive-mode.ts +19 -12
  36. package/src/modes/rpc/rpc-types.ts +1 -1
  37. package/src/modes/utils/context-usage.ts +12 -5
  38. package/src/modes/utils/ui-helpers.ts +1 -0
  39. package/src/prompts/system/plan-mode-active.md +7 -3
  40. package/src/prompts/system/plan-mode-approved.md +5 -0
  41. package/src/prompts/system/project-prompt.md +36 -0
  42. package/src/prompts/system/system-prompt.md +0 -29
  43. package/src/prompts/tools/github.md +1 -0
  44. package/src/prompts/tools/read.md +15 -14
  45. package/src/sdk.ts +29 -28
  46. package/src/session/agent-session.ts +20 -12
  47. package/src/session/compaction/branch-summarization.ts +1 -1
  48. package/src/session/compaction/compaction.ts +3 -3
  49. package/src/session/session-dump-format.ts +10 -5
  50. package/src/session/streaming-output.ts +1 -1
  51. package/src/slash-commands/builtin-registry.ts +2 -2
  52. package/src/system-prompt.ts +35 -3
  53. package/src/task/executor.ts +4 -3
  54. package/src/task/isolation-backend.ts +22 -0
  55. package/src/tools/fetch.ts +4 -4
  56. package/src/tools/gh.ts +187 -0
  57. package/src/tools/inspect-image.ts +1 -1
  58. package/src/tools/output-meta.ts +1 -1
  59. package/src/tools/path-utils.ts +11 -0
  60. package/src/tools/read.ts +393 -204
  61. package/src/tools/search.ts +1 -1
  62. package/src/tools/sqlite-reader.ts +1 -1
  63. package/src/utils/commit-message-generator.ts +1 -1
  64. package/src/utils/title-generator.ts +1 -1
  65. package/src/web/search/providers/anthropic.ts +1 -1
  66. package/src/workspace-tree.ts +396 -0
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import * as path from "node:path";
7
7
  import { logger, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils";
8
+ import { YAML } from "bun";
8
9
  import { registerProvider } from "../capability";
9
10
  import { type ContextFile, contextFileCapability } from "../capability/context-file";
10
11
  import { type Extension, type ExtensionManifest, extensionCapability } from "../capability/extension";
@@ -778,22 +779,46 @@ async function loadSettings(ctx: LoadContext): Promise<LoadResult<Settings>> {
778
779
  const items: Settings[] = [];
779
780
  const warnings: string[] = [];
780
781
 
782
+ const parseYamlSettings = (content: string, filePath: string): Record<string, unknown> | null => {
783
+ try {
784
+ const data = YAML.parse(content);
785
+ if (!data || typeof data !== "object" || Array.isArray(data)) return {};
786
+ return data as Record<string, unknown>;
787
+ } catch {
788
+ warnings.push(`Failed to parse ${filePath}`);
789
+ return null;
790
+ }
791
+ };
792
+
781
793
  for (const { dir, level } of await getConfigDirs(ctx)) {
782
794
  const settingsPath = path.join(dir, "settings.json");
783
- const content = await readFile(settingsPath);
784
- if (!content) continue;
785
-
786
- const data = tryParseJson<Record<string, unknown>>(content);
787
- if (!data) {
788
- warnings.push(`Failed to parse ${settingsPath}`);
789
- continue;
795
+ const settingsContent = await readFile(settingsPath);
796
+ if (settingsContent) {
797
+ const data = tryParseJson<Record<string, unknown>>(settingsContent);
798
+ if (data) {
799
+ items.push({
800
+ path: settingsPath,
801
+ data,
802
+ level,
803
+ _source: createSourceMeta(PROVIDER_ID, settingsPath, level),
804
+ });
805
+ } else {
806
+ warnings.push(`Failed to parse ${settingsPath}`);
807
+ }
790
808
  }
791
809
 
810
+ const configPath = path.join(dir, "config.yml");
811
+ const configContent = await readFile(configPath);
812
+ if (!configContent) continue;
813
+
814
+ const data = parseYamlSettings(configContent, configPath);
815
+ if (!data) continue;
816
+
792
817
  items.push({
793
- path: settingsPath,
818
+ path: configPath,
794
819
  data,
795
820
  level,
796
- _source: createSourceMeta(PROVIDER_ID, settingsPath, level),
821
+ _source: createSourceMeta(PROVIDER_ID, configPath, level),
797
822
  });
798
823
  }
799
824
 
@@ -783,18 +783,48 @@ export const HL_BODY_SEP = "|";
783
783
  export const HL_BODY_SEP_RE_RAW = regexEscape(HL_BODY_SEP);
784
784
 
785
785
  const RE_SIGNIFICANT = /[\p{L}\p{N}]/u;
786
+ const RE_STRUCTURAL_STRIP = /[\s{}]/g;
787
+
788
+ /**
789
+ * Bigram returned for lines that contain only whitespace and `{`/`}`.
790
+ * Picks the English ordinal suffix for the line number (`1` → `st`,
791
+ * `2` → `nd`, `3` → `rd`, `11`/`12`/`13` → `th`, else `th`) so the
792
+ * line digits + bigram BPE-merge into a single ordinal token (`1st`, `42nd`,
793
+ * `100th`, …). Brace-only lines therefore cost one token for the whole
794
+ * `LINE+ID` anchor instead of two.
795
+ */
796
+ function structuralBigram(line: number): string {
797
+ const mod100 = line % 100;
798
+ if (mod100 >= 11 && mod100 <= 13) return "th";
799
+ switch (line % 10) {
800
+ case 1:
801
+ return "st";
802
+ case 2:
803
+ return "nd";
804
+ case 3:
805
+ return "rd";
806
+ default:
807
+ return "th";
808
+ }
809
+ }
786
810
 
787
811
  /**
788
812
  * Compute a 2-character hash of a single line via xxHash32 mod 647 over
789
- * {@link HL_BIGRAMS}. Lines with no letter or digit (e.g. bare `}`,
790
- * bare `{`) mix the line number into the seed so adjacent identical
791
- * brace-only lines get distinct hashes; lines with significant content stay
792
- * line-number-independent so a line is identifiable across small shifts.
813
+ * {@link HL_BIGRAMS}. Lines that contain only whitespace and `{`/`}` collapse
814
+ * to an ordinal-suffix bigram (see {@link structuralBigram}) so brace-only
815
+ * structure shares one merged ordinal token (`1st`, `42nd`, `100th`, …).
816
+ * Other lines with no letter or digit mix the line number into the seed so
817
+ * adjacent identical punctuation-only lines get distinct hashes; lines with
818
+ * significant content stay line-number-independent so a line is identifiable
819
+ * across small shifts.
793
820
  *
794
821
  * The line input should not include a trailing newline.
795
822
  */
796
823
  export function computeLineHash(idx: number, line: string): string {
797
824
  line = line.replace(/\r/g, "").trimEnd();
825
+ if (line.replace(RE_STRUCTURAL_STRIP, "").length === 0) {
826
+ return structuralBigram(idx);
827
+ }
798
828
  const seed = RE_SIGNIFICANT.test(line) ? 0 : idx;
799
829
  return HL_BIGRAMS[Bun.hash.xxHash32(line, seed) % HL_BIGRAMS_COUNT];
800
830
  }
@@ -103,6 +103,9 @@ export interface CompactHashlineDiffOptions {
103
103
  /** Maximum entries kept on each side of an unchanged-context truncation (default: 2). */
104
104
  maxUnchangedRun?: number;
105
105
  }
106
+ export interface HashlineApplyOptions {
107
+ autoDropPureInsertDuplicates?: boolean;
108
+ }
106
109
 
107
110
  export interface SplitHashlineOptions {
108
111
  cwd?: string;
@@ -140,7 +143,7 @@ const HL_OUTPUT_PREFIX_SEPARATOR_RE = `[:${HL_BODY_SEP_RE_RAW}]`;
140
143
  const HL_PREFIX_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
141
144
  const HL_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
142
145
  const DIFF_PLUS_RE = /^[+](?![+])/;
143
- const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L?\d+/;
146
+ const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/;
144
147
 
145
148
  const HL_HASH_HINT_RE = /^[a-z]{2}$/i;
146
149
  const HL_ANCHOR_EXAMPLES = describeAnchorExamples("160");
@@ -978,6 +981,110 @@ function countMatchingSuffixBlock(fileLines: string[], endLine: number, replacem
978
981
  return 0;
979
982
  }
980
983
 
984
+ // Single-line duplicate absorption is limited to structural closing delimiters.
985
+ // General one-line context is too easy to delete incorrectly, but duplicated
986
+ // `};` / `)` / `]` boundaries usually indicate a replacement range stopped one
987
+ // line early and would otherwise produce a syntax error.
988
+ const STRUCTURAL_CLOSING_BOUNDARY_RE = /^\s*[\])}]+[;,]?\s*$/;
989
+
990
+ function isStructuralClosingBoundaryLine(line: string): boolean {
991
+ return STRUCTURAL_CLOSING_BOUNDARY_RE.test(line);
992
+ }
993
+
994
+ interface DelimiterBalance {
995
+ paren: number;
996
+ bracket: number;
997
+ brace: number;
998
+ }
999
+
1000
+ const ZERO_DELIMITER_BALANCE: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
1001
+
1002
+ /**
1003
+ * Naive bracket counter — does NOT skip string/template/comment contents. The
1004
+ * single-line structural absorb relies on this being safe-by-asymmetry: the
1005
+ * candidate boundary line is constrained by `STRUCTURAL_CLOSING_BOUNDARY_RE`
1006
+ * to be pure delimiters, so noise in deleted lines or non-boundary kept payload
1007
+ * tends to push `expected !== kept` and biases the heuristic toward NOT
1008
+ * absorbing (the safe direction). If we ever extend this to opening boundaries
1009
+ * or non-structural single lines, swap this for a real tokenizer.
1010
+ */
1011
+ function computeDelimiterBalance(lines: string[]): DelimiterBalance {
1012
+ const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
1013
+ for (const line of lines) {
1014
+ for (const char of line) {
1015
+ switch (char) {
1016
+ case "(":
1017
+ balance.paren++;
1018
+ break;
1019
+ case ")":
1020
+ balance.paren--;
1021
+ break;
1022
+ case "[":
1023
+ balance.bracket++;
1024
+ break;
1025
+ case "]":
1026
+ balance.bracket--;
1027
+ break;
1028
+ case "{":
1029
+ balance.brace++;
1030
+ break;
1031
+ case "}":
1032
+ balance.brace--;
1033
+ break;
1034
+ }
1035
+ }
1036
+ }
1037
+ return balance;
1038
+ }
1039
+
1040
+ function delimiterBalancesEqual(a: DelimiterBalance, b: DelimiterBalance): boolean {
1041
+ return a.paren === b.paren && a.bracket === b.bracket && a.brace === b.brace;
1042
+ }
1043
+
1044
+ /**
1045
+ * Decides whether the structural-boundary candidate should be dropped: the
1046
+ * `keptPayload` (full payload with the boundary line removed) must restore the
1047
+ * caller's `expectedBalance`, while the `fullPayload` (boundary line still
1048
+ * present) must NOT. For replacements `expectedBalance` is the deleted
1049
+ * region's net delimiter balance; for pure inserts it is zero.
1050
+ */
1051
+ function shouldDropSingleStructuralBoundary(
1052
+ fullPayload: string[],
1053
+ keptPayload: string[],
1054
+ expectedBalance: DelimiterBalance,
1055
+ ): boolean {
1056
+ return (
1057
+ delimiterBalancesEqual(computeDelimiterBalance(keptPayload), expectedBalance) &&
1058
+ !delimiterBalancesEqual(computeDelimiterBalance(fullPayload), expectedBalance)
1059
+ );
1060
+ }
1061
+
1062
+ function countMatchingSingleStructuralPrefixBoundary(
1063
+ fileLines: string[],
1064
+ startLine: number,
1065
+ replacement: string[],
1066
+ expectedBalance: DelimiterBalance,
1067
+ ): number {
1068
+ if (replacement.length === 0 || startLine <= 1) return 0;
1069
+ const line = replacement[0];
1070
+ if (!isStructuralClosingBoundaryLine(line)) return 0;
1071
+ if (fileLines[startLine - 2] !== line) return 0;
1072
+ return shouldDropSingleStructuralBoundary(replacement, replacement.slice(1), expectedBalance) ? 1 : 0;
1073
+ }
1074
+
1075
+ function countMatchingSingleStructuralSuffixBoundary(
1076
+ fileLines: string[],
1077
+ endLine: number,
1078
+ replacement: string[],
1079
+ expectedBalance: DelimiterBalance,
1080
+ ): number {
1081
+ if (replacement.length === 0 || endLine >= fileLines.length) return 0;
1082
+ const line = replacement[replacement.length - 1];
1083
+ if (!isStructuralClosingBoundaryLine(line)) return 0;
1084
+ if (fileLines[endLine] !== line) return 0;
1085
+ return shouldDropSingleStructuralBoundary(replacement, replacement.slice(0, -1), expectedBalance) ? 1 : 0;
1086
+ }
1087
+
981
1088
  function hasExternalTargets(lines: Iterable<number>, externalTargetLines: Set<number>): boolean {
982
1089
  for (const line of lines) {
983
1090
  if (externalTargetLines.has(line)) return true;
@@ -1003,10 +1110,181 @@ function deleteEditForAutoAbsorbedLine(
1003
1110
  };
1004
1111
  }
1005
1112
 
1113
+ interface HashlinePureInsertGroup {
1114
+ startIndex: number;
1115
+ endIndex: number;
1116
+ sourceLineNum: number;
1117
+ cursor: HashlineCursor;
1118
+ payload: string[];
1119
+ }
1120
+
1121
+ function cursorMatches(a: HashlineCursor, b: HashlineCursor): boolean {
1122
+ if (a.kind !== b.kind) return false;
1123
+ if (a.kind === "bof" || a.kind === "eof") return true;
1124
+ const aAnchor = (a as { anchor: Anchor }).anchor;
1125
+ const bAnchor = (b as { anchor: Anchor }).anchor;
1126
+ return aAnchor.line === bAnchor.line && aAnchor.hash === bAnchor.hash;
1127
+ }
1128
+
1129
+ /**
1130
+ * Collects a run of consecutive `insert` edits that all share the same
1131
+ * `lineNum` and `cursor`, IFF that run is not immediately followed by a
1132
+ * `delete` at the same `lineNum` (which would make it a replacement group
1133
+ * instead). Returns the contiguous payload so we can check it for boundary
1134
+ * duplicates against the file.
1135
+ */
1136
+ function findPureInsertGroup(edits: HashlineEdit[], startIndex: number): HashlinePureInsertGroup | undefined {
1137
+ const first = edits[startIndex];
1138
+ if (first?.kind !== "insert") return undefined;
1139
+
1140
+ const sourceLineNum = first.lineNum;
1141
+ const cursor = first.cursor;
1142
+ const payload: string[] = [];
1143
+ let index = startIndex;
1144
+ while (index < edits.length) {
1145
+ const edit = edits[index];
1146
+ if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum) break;
1147
+ if (!cursorMatches(edit.cursor, cursor)) break;
1148
+ payload.push(edit.text);
1149
+ index++;
1150
+ }
1151
+
1152
+ // If the run is followed by a delete at the same source lineNum, this is a
1153
+ // replacement group (handled by absorbReplacement…). Decline.
1154
+ if (index < edits.length && edits[index].kind === "delete" && edits[index].lineNum === sourceLineNum) {
1155
+ return undefined;
1156
+ }
1157
+
1158
+ return { startIndex, endIndex: index - 1, sourceLineNum, cursor, payload };
1159
+ }
1160
+
1161
+ /**
1162
+ * For a pure-insert group, locate the file region adjacent to the insertion
1163
+ * point. Returns 0-indexed bounds:
1164
+ * - `aboveEndIdx`: index of the last file line strictly above the insertion
1165
+ * point (-1 if none).
1166
+ * - `belowStartIdx`: index of the first file line strictly below the
1167
+ * insertion point (`fileLines.length` if none).
1168
+ */
1169
+ function pureInsertNeighborhood(
1170
+ cursor: HashlineCursor,
1171
+ fileLines: string[],
1172
+ ): { aboveEndIdx: number; belowStartIdx: number } {
1173
+ if (cursor.kind === "bof") return { aboveEndIdx: -1, belowStartIdx: 0 };
1174
+ if (cursor.kind === "eof") return { aboveEndIdx: fileLines.length - 1, belowStartIdx: fileLines.length };
1175
+ if (cursor.kind === "before_anchor") {
1176
+ return { aboveEndIdx: cursor.anchor.line - 2, belowStartIdx: cursor.anchor.line - 1 };
1177
+ }
1178
+ // after_anchor
1179
+ return { aboveEndIdx: cursor.anchor.line - 1, belowStartIdx: cursor.anchor.line };
1180
+ }
1181
+
1182
+ interface PureInsertAbsorbResult {
1183
+ keptPayload: string[];
1184
+ absorbedLeading: number;
1185
+ absorbedTrailing: number;
1186
+ leadingFileRange?: { start: number; end: number }; // 1-indexed inclusive
1187
+ trailingFileRange?: { start: number; end: number }; // 1-indexed inclusive
1188
+ }
1189
+
1190
+ /**
1191
+ * Mirror of replacement-absorb's prefix/suffix block check, but for pure
1192
+ * inserts: drop payload lines that exactly duplicate the file lines
1193
+ * immediately above (leading) or immediately below (trailing) the insertion
1194
+ * point. Generic context echo absorption requires a minimum run of 2, but a
1195
+ * single structural closing delimiter is absorbed because duplicated `}` /
1196
+ * `});`-style boundaries almost always mean the insert included adjacent
1197
+ * context.
1198
+ */
1199
+ function tryAbsorbPureInsertGroup(
1200
+ group: HashlinePureInsertGroup,
1201
+ fileLines: string[],
1202
+ allowGenericBoundaryAbsorb: boolean,
1203
+ ): PureInsertAbsorbResult {
1204
+ const empty: PureInsertAbsorbResult = { keptPayload: group.payload, absorbedLeading: 0, absorbedTrailing: 0 };
1205
+ if (group.payload.length === 0) return empty;
1206
+
1207
+ const { aboveEndIdx, belowStartIdx } = pureInsertNeighborhood(group.cursor, fileLines);
1208
+
1209
+ // Leading: payload[0..k-1] vs fileLines[aboveEndIdx-k+1 .. aboveEndIdx].
1210
+ let absorbedLeading = 0;
1211
+ if (allowGenericBoundaryAbsorb) {
1212
+ const maxLead = Math.min(group.payload.length, aboveEndIdx + 1);
1213
+ for (let count = maxLead; count >= 2; count--) {
1214
+ let ok = true;
1215
+ for (let offset = 0; offset < count; offset++) {
1216
+ if (group.payload[offset] !== fileLines[aboveEndIdx - count + 1 + offset]) {
1217
+ ok = false;
1218
+ break;
1219
+ }
1220
+ }
1221
+ if (ok) {
1222
+ absorbedLeading = count;
1223
+ break;
1224
+ }
1225
+ }
1226
+ }
1227
+ if (
1228
+ absorbedLeading === 0 &&
1229
+ group.payload.length > 0 &&
1230
+ aboveEndIdx >= 0 &&
1231
+ isStructuralClosingBoundaryLine(group.payload[0]) &&
1232
+ group.payload[0] === fileLines[aboveEndIdx] &&
1233
+ shouldDropSingleStructuralBoundary(group.payload, group.payload.slice(1), ZERO_DELIMITER_BALANCE)
1234
+ ) {
1235
+ absorbedLeading = 1;
1236
+ }
1237
+
1238
+ // Trailing: payload[len-k..len-1] vs fileLines[belowStartIdx..belowStartIdx+k-1].
1239
+ // Don't double-count payload lines already absorbed as leading.
1240
+ let absorbedTrailing = 0;
1241
+ const remainingPayload = group.payload.slice(absorbedLeading);
1242
+ const remaining = remainingPayload.length;
1243
+ if (allowGenericBoundaryAbsorb) {
1244
+ const maxTrail = Math.min(remaining, fileLines.length - belowStartIdx);
1245
+ for (let count = maxTrail; count >= 2; count--) {
1246
+ let ok = true;
1247
+ for (let offset = 0; offset < count; offset++) {
1248
+ if (group.payload[group.payload.length - count + offset] !== fileLines[belowStartIdx + offset]) {
1249
+ ok = false;
1250
+ break;
1251
+ }
1252
+ }
1253
+ if (ok) {
1254
+ absorbedTrailing = count;
1255
+ break;
1256
+ }
1257
+ }
1258
+ }
1259
+ if (
1260
+ absorbedTrailing === 0 &&
1261
+ remaining > 0 &&
1262
+ belowStartIdx < fileLines.length &&
1263
+ isStructuralClosingBoundaryLine(remainingPayload[remainingPayload.length - 1]) &&
1264
+ remainingPayload[remainingPayload.length - 1] === fileLines[belowStartIdx] &&
1265
+ shouldDropSingleStructuralBoundary(remainingPayload, remainingPayload.slice(0, -1), ZERO_DELIMITER_BALANCE)
1266
+ ) {
1267
+ absorbedTrailing = 1;
1268
+ }
1269
+
1270
+ if (absorbedLeading === 0 && absorbedTrailing === 0) return empty;
1271
+
1272
+ return {
1273
+ keptPayload: group.payload.slice(absorbedLeading, group.payload.length - absorbedTrailing),
1274
+ absorbedLeading,
1275
+ absorbedTrailing,
1276
+ leadingFileRange:
1277
+ absorbedLeading > 0 ? { start: aboveEndIdx - absorbedLeading + 2, end: aboveEndIdx + 1 } : undefined,
1278
+ trailingFileRange:
1279
+ absorbedTrailing > 0 ? { start: belowStartIdx + 1, end: belowStartIdx + absorbedTrailing } : undefined,
1280
+ };
1281
+ }
1282
+
1006
1283
  function absorbReplacementBoundaryDuplicates(
1007
1284
  edits: HashlineEdit[],
1008
1285
  fileLines: string[],
1009
1286
  warnings: string[],
1287
+ options: HashlineApplyOptions,
1010
1288
  ): HashlineEdit[] {
1011
1289
  let nextSyntheticIndex = edits.length;
1012
1290
  const absorbed: HashlineEdit[] = [];
@@ -1021,6 +1299,54 @@ function absorbReplacementBoundaryDuplicates(
1021
1299
  for (let index = 0; index < edits.length; index++) {
1022
1300
  const group = findReplacementGroup(edits, index);
1023
1301
  if (!group) {
1302
+ const pureInsert = findPureInsertGroup(edits, index);
1303
+ if (pureInsert) {
1304
+ const result = tryAbsorbPureInsertGroup(
1305
+ pureInsert,
1306
+ fileLines,
1307
+ options.autoDropPureInsertDuplicates === true,
1308
+ );
1309
+ if (result.absorbedLeading > 0 || result.absorbedTrailing > 0) {
1310
+ if (result.leadingFileRange) {
1311
+ const { start, end } = result.leadingFileRange;
1312
+ const key = `pure-insert-leading:${start}..${end}`;
1313
+ if (!emittedAbsorbKeys.has(key)) {
1314
+ emittedAbsorbKeys.add(key);
1315
+ warnings.push(
1316
+ `Auto-dropped ${result.absorbedLeading} duplicate line(s) at the start of insert at line ${pureInsert.sourceLineNum} ` +
1317
+ `(file lines ${start}..${end} already match the payload's leading lines).`,
1318
+ );
1319
+ }
1320
+ }
1321
+ if (result.trailingFileRange) {
1322
+ const { start, end } = result.trailingFileRange;
1323
+ const key = `pure-insert-trailing:${start}..${end}`;
1324
+ if (!emittedAbsorbKeys.has(key)) {
1325
+ emittedAbsorbKeys.add(key);
1326
+ warnings.push(
1327
+ `Auto-dropped ${result.absorbedTrailing} duplicate line(s) at the end of insert at line ${pureInsert.sourceLineNum} ` +
1328
+ `(file lines ${start}..${end} already match the payload's trailing lines).`,
1329
+ );
1330
+ }
1331
+ }
1332
+ for (const text of result.keptPayload) {
1333
+ absorbed.push({
1334
+ kind: "insert",
1335
+ cursor: cloneCursor(pureInsert.cursor),
1336
+ text,
1337
+ lineNum: pureInsert.sourceLineNum,
1338
+ index: nextSyntheticIndex++,
1339
+ });
1340
+ }
1341
+ index = pureInsert.endIndex;
1342
+ continue;
1343
+ }
1344
+ for (let groupIndex = pureInsert.startIndex; groupIndex <= pureInsert.endIndex; groupIndex++) {
1345
+ absorbed.push(edits[groupIndex]);
1346
+ }
1347
+ index = pureInsert.endIndex;
1348
+ continue;
1349
+ }
1024
1350
  absorbed.push(edits[index]);
1025
1351
  continue;
1026
1352
  }
@@ -1028,8 +1354,15 @@ function absorbReplacementBoundaryDuplicates(
1028
1354
  const startLine = group.deletes[0].anchor.line;
1029
1355
  const endLine = group.deletes[group.deletes.length - 1].anchor.line;
1030
1356
 
1031
- const prefixCount = countMatchingPrefixBlock(fileLines, startLine, group.replacement);
1032
- const suffixCount = countMatchingSuffixBlock(fileLines, endLine, group.replacement);
1357
+ const deletedBalance = computeDelimiterBalance(
1358
+ group.deletes.map(deleteEdit => fileLines[deleteEdit.anchor.line - 1] ?? ""),
1359
+ );
1360
+ const prefixCount =
1361
+ countMatchingPrefixBlock(fileLines, startLine, group.replacement) ||
1362
+ countMatchingSingleStructuralPrefixBoundary(fileLines, startLine, group.replacement, deletedBalance);
1363
+ const suffixCount =
1364
+ countMatchingSuffixBlock(fileLines, endLine, group.replacement) ||
1365
+ countMatchingSingleStructuralSuffixBoundary(fileLines, endLine, group.replacement, deletedBalance);
1033
1366
  const prefixLines = contiguousRange(startLine - prefixCount, prefixCount);
1034
1367
  const suffixLines = contiguousRange(endLine + 1, suffixCount);
1035
1368
  const safePrefixCount = hasExternalTargets(prefixLines, allTargetLines) ? 0 : prefixCount;
@@ -1094,7 +1427,11 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
1094
1427
  return byLine;
1095
1428
  }
1096
1429
 
1097
- export function applyHashlineEdits(text: string, edits: HashlineEdit[]): HashlineApplyResult {
1430
+ export function applyHashlineEdits(
1431
+ text: string,
1432
+ edits: HashlineEdit[],
1433
+ options: HashlineApplyOptions = {},
1434
+ ): HashlineApplyResult {
1098
1435
  if (edits.length === 0) return { lines: text, firstChangedLine: undefined };
1099
1436
 
1100
1437
  const fileLines = text.split("\n");
@@ -1109,7 +1446,7 @@ export function applyHashlineEdits(text: string, edits: HashlineEdit[]): Hashlin
1109
1446
  const mismatches = validateHashlineAnchors(edits, fileLines, warnings);
1110
1447
  if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines);
1111
1448
 
1112
- const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings);
1449
+ const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
1113
1450
 
1114
1451
  // Normalize after_anchor inserts to before_anchor of the next line, or EOF
1115
1452
  // when the anchor is the final line. This keeps the bucketing logic below
@@ -1332,6 +1669,7 @@ async function readHashlineFileText(file: { text(): Promise<string> }, pathText:
1332
1669
  export async function computeHashlineDiff(
1333
1670
  input: { input: string; path?: string },
1334
1671
  cwd: string,
1672
+ options: HashlineApplyOptions = {},
1335
1673
  ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
1336
1674
  try {
1337
1675
  const sections = splitHashlineInputs(input.input, { cwd, path: input.path });
@@ -1344,7 +1682,7 @@ export async function computeHashlineDiff(
1344
1682
  const rawContent = await readHashlineFileText(Bun.file(absolutePath), section.path);
1345
1683
  const { text: content } = stripBom(rawContent);
1346
1684
  const normalized = normalizeToLF(content);
1347
- const result = applyHashlineEdits(normalized, parseHashline(section.diff));
1685
+ const result = applyHashlineEdits(normalized, parseHashline(section.diff), options);
1348
1686
  if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
1349
1687
  return generateDiffString(normalized, result.lines);
1350
1688
  } catch (err) {
@@ -1382,6 +1720,12 @@ function formatNoChangeDiagnostic(pathText: string): string {
1382
1720
  return `Edits to ${pathText} resulted in no changes being made.`;
1383
1721
  }
1384
1722
 
1723
+ function getHashlineApplyOptions(session: ToolSession): HashlineApplyOptions {
1724
+ return {
1725
+ autoDropPureInsertDuplicates: session.settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
1726
+ };
1727
+ }
1728
+
1385
1729
  function getTextContent(result: AgentToolResult<EditToolDetails>): string {
1386
1730
  return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
1387
1731
  }
@@ -1408,7 +1752,7 @@ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions &
1408
1752
 
1409
1753
  const { text } = stripBom(source.rawContent);
1410
1754
  const normalized = normalizeToLF(text);
1411
- const result = applyHashlineEdits(normalized, edits);
1755
+ const result = applyHashlineEdits(normalized, edits, getHashlineApplyOptions(session));
1412
1756
  if (normalized === result.lines) throw new Error(formatNoChangeDiagnostic(sectionPath));
1413
1757
  }
1414
1758
 
@@ -1436,7 +1780,7 @@ async function executeHashlineSection(
1436
1780
  const { bom, text } = stripBom(source.rawContent);
1437
1781
  const originalEnding = detectLineEnding(text);
1438
1782
  const originalNormalized = normalizeToLF(text);
1439
- const result = applyHashlineEdits(originalNormalized, edits);
1783
+ const result = applyHashlineEdits(originalNormalized, edits, getHashlineApplyOptions(session));
1440
1784
 
1441
1785
  if (originalNormalized === result.lines) {
1442
1786
  return {
@@ -32,6 +32,7 @@ export interface StreamingDiffContext {
32
32
  signal: AbortSignal;
33
33
  fuzzyThreshold?: number;
34
34
  allowFuzzy?: boolean;
35
+ hashlineAutoDropPureInsertDuplicates?: boolean;
35
36
  }
36
37
 
37
38
  export interface EditStreamingStrategy<Args = unknown> {
@@ -222,7 +223,9 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
222
223
  async computeDiffPreview(args, ctx) {
223
224
  if (typeof args.input !== "string" || args.input.length === 0) return null;
224
225
  ctx.signal.throwIfAborted();
225
- const result = await computeHashlineDiff({ input: args.input, path: args.path }, ctx.cwd);
226
+ const result = await computeHashlineDiff({ input: args.input, path: args.path }, ctx.cwd, {
227
+ autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
228
+ });
226
229
  ctx.signal.throwIfAborted();
227
230
  if ("error" in result && !args.path) return [{ path: "", error: result.error }];
228
231
  return [toPerFilePreview(args.path ?? "", result)];
@@ -124,7 +124,7 @@ export async function exportSessionToHtml(
124
124
  header: sm.getHeader(),
125
125
  entries: sm.getEntries(),
126
126
  leafId: sm.getLeafId(),
127
- systemPrompt: state?.systemPrompt,
127
+ systemPrompt: state?.systemPrompt.join("\n\n"),
128
128
  tools: state?.tools?.map(t => ({ name: t.name, description: t.description })),
129
129
  };
130
130
 
@@ -55,7 +55,7 @@ import type {
55
55
  /** Combined result from all before_agent_start handlers */
56
56
  interface BeforeAgentStartCombinedResult {
57
57
  messages?: NonNullable<BeforeAgentStartEventResult["message"]>[];
58
- systemPrompt?: string;
58
+ systemPrompt?: string[];
59
59
  }
60
60
 
61
61
  export type ExtensionErrorListener = (error: ExtensionError) => void;
@@ -168,7 +168,7 @@ export class ExtensionRunner {
168
168
  #hasPendingMessagesFn: () => boolean = () => false;
169
169
  #getContextUsageFn: () => ContextUsage | undefined = () => undefined;
170
170
  #compactFn: (instructionsOrOptions?: string | CompactOptions) => Promise<void> = async () => {};
171
- #getSystemPromptFn: () => string = () => "";
171
+ #getSystemPromptFn: () => string[] = () => [];
172
172
  #newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
173
173
  #branchHandler: BranchHandler = async () => ({ cancelled: false });
174
174
  #navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
@@ -795,7 +795,7 @@ export class ExtensionRunner {
795
795
  async emitBeforeAgentStart(
796
796
  prompt: string,
797
797
  images: ImageContent[] | undefined,
798
- systemPrompt: string,
798
+ systemPrompt: string[],
799
799
  ): Promise<BeforeAgentStartCombinedResult | undefined> {
800
800
  const ctx = this.createContext();
801
801
  const messages: NonNullable<BeforeAgentStartEventResult["message"]>[] = [];
@@ -240,7 +240,7 @@ export interface ExtensionContext {
240
240
  /** Gracefully shutdown and exit. */
241
241
  shutdown(): void;
242
242
  /** Get the current effective system prompt. */
243
- getSystemPrompt(): string;
243
+ getSystemPrompt(): string[];
244
244
  /** @deprecated Use hasPendingMessages() instead */
245
245
  hasQueuedMessages(): boolean;
246
246
  }
@@ -492,7 +492,7 @@ export interface BeforeAgentStartEvent {
492
492
  type: "before_agent_start";
493
493
  prompt: string;
494
494
  images?: ImageContent[];
495
- systemPrompt: string;
495
+ systemPrompt: string[];
496
496
  }
497
497
 
498
498
  /** Fired when an agent loop starts */
@@ -876,7 +876,7 @@ export interface ToolResultEventResult {
876
876
  export interface BeforeAgentStartEventResult {
877
877
  message?: Pick<CustomMessage, "customType" | "content" | "display" | "details" | "attribution">;
878
878
  /** Replace the system prompt for this turn. If multiple extensions return this, they are chained. */
879
- systemPrompt?: string;
879
+ systemPrompt?: string[];
880
880
  }
881
881
 
882
882
  export interface SessionBeforeSwitchResult {
@@ -1318,7 +1318,7 @@ export interface ExtensionContextActions {
1318
1318
  shutdown: () => void;
1319
1319
  getContextUsage: () => ContextUsage | undefined;
1320
1320
  compact: (instructionsOrOptions?: string | CompactOptions) => Promise<void>;
1321
- getSystemPrompt: () => string;
1321
+ getSystemPrompt: () => string[];
1322
1322
  }
1323
1323
 
1324
1324
  /** Actions for ExtensionCommandContext (ctx.* in command handlers). */