@oh-my-pi/pi-coding-agent 14.6.6 → 14.7.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +41 -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 +7 -7
  7. package/src/autoresearch/index.ts +48 -44
  8. package/src/cli/read-cli.ts +58 -0
  9. package/src/cli.ts +1 -0
  10. package/src/commands/read.ts +40 -0
  11. package/src/commit/agentic/agent.ts +1 -1
  12. package/src/commit/analysis/conventional.ts +1 -1
  13. package/src/commit/analysis/summary.ts +1 -1
  14. package/src/commit/changelog/generate.ts +1 -1
  15. package/src/commit/map-reduce/map-phase.ts +1 -1
  16. package/src/commit/map-reduce/reduce-phase.ts +1 -1
  17. package/src/config/settings-schema.ts +39 -0
  18. package/src/edit/line-hash.ts +34 -4
  19. package/src/edit/modes/hashline.ts +201 -6
  20. package/src/edit/streaming.ts +4 -1
  21. package/src/export/html/index.ts +1 -1
  22. package/src/extensibility/extensions/runner.ts +3 -3
  23. package/src/extensibility/extensions/types.ts +4 -4
  24. package/src/main.ts +3 -3
  25. package/src/memories/index.ts +1 -1
  26. package/src/modes/components/agent-dashboard.ts +1 -1
  27. package/src/modes/components/read-tool-group.ts +4 -9
  28. package/src/modes/components/tool-execution.ts +4 -0
  29. package/src/modes/controllers/event-controller.ts +2 -0
  30. package/src/modes/rpc/rpc-types.ts +1 -1
  31. package/src/modes/utils/context-usage.ts +12 -5
  32. package/src/modes/utils/ui-helpers.ts +1 -0
  33. package/src/prompts/system/project-prompt.md +36 -0
  34. package/src/prompts/system/system-prompt.md +0 -29
  35. package/src/prompts/tools/github.md +1 -0
  36. package/src/prompts/tools/read.md +15 -14
  37. package/src/sdk.ts +29 -28
  38. package/src/session/agent-session.ts +20 -12
  39. package/src/session/compaction/branch-summarization.ts +1 -1
  40. package/src/session/compaction/compaction.ts +3 -3
  41. package/src/session/session-dump-format.ts +10 -5
  42. package/src/session/streaming-output.ts +1 -1
  43. package/src/system-prompt.ts +35 -3
  44. package/src/task/executor.ts +4 -3
  45. package/src/tools/fetch.ts +4 -4
  46. package/src/tools/gh.ts +187 -0
  47. package/src/tools/inspect-image.ts +1 -1
  48. package/src/tools/output-meta.ts +1 -1
  49. package/src/tools/path-utils.ts +11 -0
  50. package/src/tools/read.ts +388 -204
  51. package/src/tools/search.ts +1 -1
  52. package/src/tools/sqlite-reader.ts +1 -1
  53. package/src/utils/commit-message-generator.ts +1 -1
  54. package/src/utils/title-generator.ts +1 -1
  55. package/src/web/search/providers/anthropic.ts +1 -1
  56. package/src/workspace-tree.ts +396 -0
@@ -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");
@@ -1003,10 +1006,150 @@ function deleteEditForAutoAbsorbedLine(
1003
1006
  };
1004
1007
  }
1005
1008
 
1009
+ interface HashlinePureInsertGroup {
1010
+ startIndex: number;
1011
+ endIndex: number;
1012
+ sourceLineNum: number;
1013
+ cursor: HashlineCursor;
1014
+ payload: string[];
1015
+ }
1016
+
1017
+ function cursorMatches(a: HashlineCursor, b: HashlineCursor): boolean {
1018
+ if (a.kind !== b.kind) return false;
1019
+ if (a.kind === "bof" || a.kind === "eof") return true;
1020
+ const aAnchor = (a as { anchor: Anchor }).anchor;
1021
+ const bAnchor = (b as { anchor: Anchor }).anchor;
1022
+ return aAnchor.line === bAnchor.line && aAnchor.hash === bAnchor.hash;
1023
+ }
1024
+
1025
+ /**
1026
+ * Collects a run of consecutive `insert` edits that all share the same
1027
+ * `lineNum` and `cursor`, IFF that run is not immediately followed by a
1028
+ * `delete` at the same `lineNum` (which would make it a replacement group
1029
+ * instead). Returns the contiguous payload so we can check it for boundary
1030
+ * duplicates against the file.
1031
+ */
1032
+ function findPureInsertGroup(edits: HashlineEdit[], startIndex: number): HashlinePureInsertGroup | undefined {
1033
+ const first = edits[startIndex];
1034
+ if (first?.kind !== "insert") return undefined;
1035
+
1036
+ const sourceLineNum = first.lineNum;
1037
+ const cursor = first.cursor;
1038
+ const payload: string[] = [];
1039
+ let index = startIndex;
1040
+ while (index < edits.length) {
1041
+ const edit = edits[index];
1042
+ if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum) break;
1043
+ if (!cursorMatches(edit.cursor, cursor)) break;
1044
+ payload.push(edit.text);
1045
+ index++;
1046
+ }
1047
+
1048
+ // If the run is followed by a delete at the same source lineNum, this is a
1049
+ // replacement group (handled by absorbReplacement…). Decline.
1050
+ if (index < edits.length && edits[index].kind === "delete" && edits[index].lineNum === sourceLineNum) {
1051
+ return undefined;
1052
+ }
1053
+
1054
+ return { startIndex, endIndex: index - 1, sourceLineNum, cursor, payload };
1055
+ }
1056
+
1057
+ /**
1058
+ * For a pure-insert group, locate the file region adjacent to the insertion
1059
+ * point. Returns 0-indexed bounds:
1060
+ * - `aboveEndIdx`: index of the last file line strictly above the insertion
1061
+ * point (-1 if none).
1062
+ * - `belowStartIdx`: index of the first file line strictly below the
1063
+ * insertion point (`fileLines.length` if none).
1064
+ */
1065
+ function pureInsertNeighborhood(
1066
+ cursor: HashlineCursor,
1067
+ fileLines: string[],
1068
+ ): { aboveEndIdx: number; belowStartIdx: number } {
1069
+ if (cursor.kind === "bof") return { aboveEndIdx: -1, belowStartIdx: 0 };
1070
+ if (cursor.kind === "eof") return { aboveEndIdx: fileLines.length - 1, belowStartIdx: fileLines.length };
1071
+ if (cursor.kind === "before_anchor") {
1072
+ return { aboveEndIdx: cursor.anchor.line - 2, belowStartIdx: cursor.anchor.line - 1 };
1073
+ }
1074
+ // after_anchor
1075
+ return { aboveEndIdx: cursor.anchor.line - 1, belowStartIdx: cursor.anchor.line };
1076
+ }
1077
+
1078
+ interface PureInsertAbsorbResult {
1079
+ keptPayload: string[];
1080
+ absorbedLeading: number;
1081
+ absorbedTrailing: number;
1082
+ leadingFileRange?: { start: number; end: number }; // 1-indexed inclusive
1083
+ trailingFileRange?: { start: number; end: number }; // 1-indexed inclusive
1084
+ }
1085
+
1086
+ /**
1087
+ * Mirror of replacement-absorb's prefix/suffix block check, but for pure
1088
+ * inserts: drop payload lines that exactly duplicate the file lines
1089
+ * immediately above (leading) or immediately below (trailing) the insertion
1090
+ * point. Requires a minimum run of 2 to avoid spurious single-line matches,
1091
+ * matching the existing replacement-absorb threshold.
1092
+ */
1093
+ function tryAbsorbPureInsertGroup(group: HashlinePureInsertGroup, fileLines: string[]): PureInsertAbsorbResult {
1094
+ const empty: PureInsertAbsorbResult = { keptPayload: group.payload, absorbedLeading: 0, absorbedTrailing: 0 };
1095
+ if (group.payload.length < 2) return empty;
1096
+
1097
+ const { aboveEndIdx, belowStartIdx } = pureInsertNeighborhood(group.cursor, fileLines);
1098
+
1099
+ // Leading: payload[0..k-1] vs fileLines[aboveEndIdx-k+1 .. aboveEndIdx].
1100
+ let absorbedLeading = 0;
1101
+ const maxLead = Math.min(group.payload.length, aboveEndIdx + 1);
1102
+ for (let count = maxLead; count >= 2; count--) {
1103
+ let ok = true;
1104
+ for (let offset = 0; offset < count; offset++) {
1105
+ if (group.payload[offset] !== fileLines[aboveEndIdx - count + 1 + offset]) {
1106
+ ok = false;
1107
+ break;
1108
+ }
1109
+ }
1110
+ if (ok) {
1111
+ absorbedLeading = count;
1112
+ break;
1113
+ }
1114
+ }
1115
+
1116
+ // Trailing: payload[len-k..len-1] vs fileLines[belowStartIdx..belowStartIdx+k-1].
1117
+ // Don't double-count payload lines already absorbed as leading.
1118
+ let absorbedTrailing = 0;
1119
+ const remaining = group.payload.length - absorbedLeading;
1120
+ const maxTrail = Math.min(remaining, fileLines.length - belowStartIdx);
1121
+ for (let count = maxTrail; count >= 2; count--) {
1122
+ let ok = true;
1123
+ for (let offset = 0; offset < count; offset++) {
1124
+ if (group.payload[group.payload.length - count + offset] !== fileLines[belowStartIdx + offset]) {
1125
+ ok = false;
1126
+ break;
1127
+ }
1128
+ }
1129
+ if (ok) {
1130
+ absorbedTrailing = count;
1131
+ break;
1132
+ }
1133
+ }
1134
+
1135
+ if (absorbedLeading === 0 && absorbedTrailing === 0) return empty;
1136
+
1137
+ return {
1138
+ keptPayload: group.payload.slice(absorbedLeading, group.payload.length - absorbedTrailing),
1139
+ absorbedLeading,
1140
+ absorbedTrailing,
1141
+ leadingFileRange:
1142
+ absorbedLeading > 0 ? { start: aboveEndIdx - absorbedLeading + 2, end: aboveEndIdx + 1 } : undefined,
1143
+ trailingFileRange:
1144
+ absorbedTrailing > 0 ? { start: belowStartIdx + 1, end: belowStartIdx + absorbedTrailing } : undefined,
1145
+ };
1146
+ }
1147
+
1006
1148
  function absorbReplacementBoundaryDuplicates(
1007
1149
  edits: HashlineEdit[],
1008
1150
  fileLines: string[],
1009
1151
  warnings: string[],
1152
+ options: HashlineApplyOptions,
1010
1153
  ): HashlineEdit[] {
1011
1154
  let nextSyntheticIndex = edits.length;
1012
1155
  const absorbed: HashlineEdit[] = [];
@@ -1021,6 +1164,47 @@ function absorbReplacementBoundaryDuplicates(
1021
1164
  for (let index = 0; index < edits.length; index++) {
1022
1165
  const group = findReplacementGroup(edits, index);
1023
1166
  if (!group) {
1167
+ if (options.autoDropPureInsertDuplicates) {
1168
+ const pureInsert = findPureInsertGroup(edits, index);
1169
+ if (pureInsert) {
1170
+ const result = tryAbsorbPureInsertGroup(pureInsert, fileLines);
1171
+ if (result.absorbedLeading > 0 || result.absorbedTrailing > 0) {
1172
+ if (result.leadingFileRange) {
1173
+ const { start, end } = result.leadingFileRange;
1174
+ const key = `pure-insert-leading:${start}..${end}`;
1175
+ if (!emittedAbsorbKeys.has(key)) {
1176
+ emittedAbsorbKeys.add(key);
1177
+ warnings.push(
1178
+ `Auto-dropped ${result.absorbedLeading} duplicate line(s) at the start of insert at line ${pureInsert.sourceLineNum} ` +
1179
+ `(file lines ${start}..${end} already match the payload's leading lines).`,
1180
+ );
1181
+ }
1182
+ }
1183
+ if (result.trailingFileRange) {
1184
+ const { start, end } = result.trailingFileRange;
1185
+ const key = `pure-insert-trailing:${start}..${end}`;
1186
+ if (!emittedAbsorbKeys.has(key)) {
1187
+ emittedAbsorbKeys.add(key);
1188
+ warnings.push(
1189
+ `Auto-dropped ${result.absorbedTrailing} duplicate line(s) at the end of insert at line ${pureInsert.sourceLineNum} ` +
1190
+ `(file lines ${start}..${end} already match the payload's trailing lines).`,
1191
+ );
1192
+ }
1193
+ }
1194
+ for (const text of result.keptPayload) {
1195
+ absorbed.push({
1196
+ kind: "insert",
1197
+ cursor: cloneCursor(pureInsert.cursor),
1198
+ text,
1199
+ lineNum: pureInsert.sourceLineNum,
1200
+ index: nextSyntheticIndex++,
1201
+ });
1202
+ }
1203
+ index = pureInsert.endIndex;
1204
+ continue;
1205
+ }
1206
+ }
1207
+ }
1024
1208
  absorbed.push(edits[index]);
1025
1209
  continue;
1026
1210
  }
@@ -1094,7 +1278,11 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
1094
1278
  return byLine;
1095
1279
  }
1096
1280
 
1097
- export function applyHashlineEdits(text: string, edits: HashlineEdit[]): HashlineApplyResult {
1281
+ export function applyHashlineEdits(
1282
+ text: string,
1283
+ edits: HashlineEdit[],
1284
+ options: HashlineApplyOptions = {},
1285
+ ): HashlineApplyResult {
1098
1286
  if (edits.length === 0) return { lines: text, firstChangedLine: undefined };
1099
1287
 
1100
1288
  const fileLines = text.split("\n");
@@ -1109,7 +1297,7 @@ export function applyHashlineEdits(text: string, edits: HashlineEdit[]): Hashlin
1109
1297
  const mismatches = validateHashlineAnchors(edits, fileLines, warnings);
1110
1298
  if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines);
1111
1299
 
1112
- const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings);
1300
+ const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
1113
1301
 
1114
1302
  // Normalize after_anchor inserts to before_anchor of the next line, or EOF
1115
1303
  // when the anchor is the final line. This keeps the bucketing logic below
@@ -1332,6 +1520,7 @@ async function readHashlineFileText(file: { text(): Promise<string> }, pathText:
1332
1520
  export async function computeHashlineDiff(
1333
1521
  input: { input: string; path?: string },
1334
1522
  cwd: string,
1523
+ options: HashlineApplyOptions = {},
1335
1524
  ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
1336
1525
  try {
1337
1526
  const sections = splitHashlineInputs(input.input, { cwd, path: input.path });
@@ -1344,7 +1533,7 @@ export async function computeHashlineDiff(
1344
1533
  const rawContent = await readHashlineFileText(Bun.file(absolutePath), section.path);
1345
1534
  const { text: content } = stripBom(rawContent);
1346
1535
  const normalized = normalizeToLF(content);
1347
- const result = applyHashlineEdits(normalized, parseHashline(section.diff));
1536
+ const result = applyHashlineEdits(normalized, parseHashline(section.diff), options);
1348
1537
  if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
1349
1538
  return generateDiffString(normalized, result.lines);
1350
1539
  } catch (err) {
@@ -1382,6 +1571,12 @@ function formatNoChangeDiagnostic(pathText: string): string {
1382
1571
  return `Edits to ${pathText} resulted in no changes being made.`;
1383
1572
  }
1384
1573
 
1574
+ function getHashlineApplyOptions(session: ToolSession): HashlineApplyOptions {
1575
+ return {
1576
+ autoDropPureInsertDuplicates: session.settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
1577
+ };
1578
+ }
1579
+
1385
1580
  function getTextContent(result: AgentToolResult<EditToolDetails>): string {
1386
1581
  return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
1387
1582
  }
@@ -1408,7 +1603,7 @@ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions &
1408
1603
 
1409
1604
  const { text } = stripBom(source.rawContent);
1410
1605
  const normalized = normalizeToLF(text);
1411
- const result = applyHashlineEdits(normalized, edits);
1606
+ const result = applyHashlineEdits(normalized, edits, getHashlineApplyOptions(session));
1412
1607
  if (normalized === result.lines) throw new Error(formatNoChangeDiagnostic(sectionPath));
1413
1608
  }
1414
1609
 
@@ -1436,7 +1631,7 @@ async function executeHashlineSection(
1436
1631
  const { bom, text } = stripBom(source.rawContent);
1437
1632
  const originalEnding = detectLineEnding(text);
1438
1633
  const originalNormalized = normalizeToLF(text);
1439
- const result = applyHashlineEdits(originalNormalized, edits);
1634
+ const result = applyHashlineEdits(originalNormalized, edits, getHashlineApplyOptions(session));
1440
1635
 
1441
1636
  if (originalNormalized === result.lines) {
1442
1637
  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). */
package/src/main.ts CHANGED
@@ -540,11 +540,11 @@ async function buildSessionOptions(
540
540
 
541
541
  // System prompt
542
542
  if (resolvedSystemPrompt && resolvedAppendPrompt) {
543
- options.systemPrompt = `${resolvedSystemPrompt}\n\n${resolvedAppendPrompt}`;
543
+ options.systemPrompt = defaultPrompt => [resolvedSystemPrompt, resolvedAppendPrompt, ...defaultPrompt.slice(1)];
544
544
  } else if (resolvedSystemPrompt) {
545
- options.systemPrompt = resolvedSystemPrompt;
545
+ options.systemPrompt = defaultPrompt => [resolvedSystemPrompt, ...defaultPrompt.slice(1)];
546
546
  } else if (resolvedAppendPrompt) {
547
- options.systemPrompt = defaultPrompt => `${defaultPrompt}\n\n${resolvedAppendPrompt}`;
547
+ options.systemPrompt = defaultPrompt => [...defaultPrompt, resolvedAppendPrompt];
548
548
  }
549
549
 
550
550
  // Tools
@@ -602,7 +602,7 @@ async function runStage1Job(options: {
602
602
  const response = await completeSimple(
603
603
  model,
604
604
  {
605
- systemPrompt: stageOneSystemTemplate,
605
+ systemPrompt: [stageOneSystemTemplate],
606
606
  messages: [{ role: "user", content: [{ type: "text", text: inputPrompt }], timestamp: Date.now() }],
607
607
  },
608
608
  {
@@ -635,7 +635,7 @@ export class AgentDashboard extends Container {
635
635
  modelRegistry,
636
636
  settings,
637
637
  model: selectedModel,
638
- systemPrompt,
638
+ systemPrompt: [systemPrompt],
639
639
  hasUI: false,
640
640
  enableLsp: false,
641
641
  enableMCP: false,
@@ -8,6 +8,7 @@ import type { ToolExecutionHandle } from "./tool-execution";
8
8
  type ReadRenderArgs = {
9
9
  path?: string;
10
10
  file_path?: string;
11
+ // Legacy field from the old schema; tolerated for rebuilt transcripts.
11
12
  sel?: string;
12
13
  };
13
14
 
@@ -37,7 +38,6 @@ function getSuffixResolution(details: ReadToolResultDetails | undefined): ReadTo
37
38
  type ReadEntry = {
38
39
  toolCallId: string;
39
40
  path: string;
40
- sel?: string;
41
41
  status: "pending" | "success" | "warning" | "error";
42
42
  correctedFrom?: string;
43
43
  contentText?: string;
@@ -62,15 +62,14 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
62
62
 
63
63
  updateArgs(args: ReadRenderArgs, toolCallId?: string): void {
64
64
  if (!toolCallId) return;
65
- const rawPath = args.file_path || args.path || "";
65
+ const basePath = args.file_path || args.path || "";
66
+ const rawPath = args.sel ? `${basePath}:${args.sel}` : basePath;
66
67
  const entry: ReadEntry = this.#entries.get(toolCallId) ?? {
67
68
  toolCallId,
68
69
  path: rawPath,
69
- sel: args.sel,
70
70
  status: "pending",
71
71
  };
72
72
  entry.path = rawPath;
73
- entry.sel = args.sel;
74
73
  this.#entries.set(toolCallId, entry);
75
74
  this.#updateDisplay();
76
75
  }
@@ -172,9 +171,8 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
172
171
  #addContentPreview(entry: ReadEntry): void {
173
172
  const lang = getLanguageFromPath(entry.path);
174
173
  const filePath = shortenPath(entry.path);
175
- const selectionSuffix = entry.sel ? `:${entry.sel}` : "";
176
174
  const correctionSuffix = entry.correctedFrom ? ` (corrected from ${shortenPath(entry.correctedFrom)})` : "";
177
- const title = filePath ? `Read ${filePath}${selectionSuffix}${correctionSuffix}` : `Read${selectionSuffix}`;
175
+ const title = filePath ? `Read ${filePath}${correctionSuffix}` : "Read";
178
176
  let cachedWidth: number | undefined;
179
177
  let cachedLines: string[] | undefined;
180
178
  const expanded = this.#expanded;
@@ -211,9 +209,6 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
211
209
  #formatPath(entry: ReadEntry): string {
212
210
  const filePath = shortenPath(entry.path);
213
211
  let pathDisplay = filePath ? theme.fg("accent", filePath) : theme.fg("toolOutput", "…");
214
- if (entry.sel) {
215
- pathDisplay += theme.fg("warning", `:${entry.sel}`);
216
- }
217
212
  if (entry.correctedFrom) {
218
213
  pathDisplay += theme.fg("dim", ` (corrected from ${shortenPath(entry.correctedFrom)})`);
219
214
  }
@@ -67,6 +67,7 @@ export interface ToolExecutionOptions {
67
67
  showImages?: boolean; // default: true (only used if terminal supports images)
68
68
  editFuzzyThreshold?: number;
69
69
  editAllowFuzzy?: boolean;
70
+ hashlineAutoDropPureInsertDuplicates?: boolean;
70
71
  }
71
72
 
72
73
  export interface ToolExecutionHandle {
@@ -100,6 +101,7 @@ export class ToolExecutionComponent extends Container {
100
101
  #showImages: boolean;
101
102
  #editFuzzyThreshold: number | undefined;
102
103
  #editAllowFuzzy: boolean | undefined;
104
+ #hashlineAutoDropPureInsertDuplicates: boolean | undefined;
103
105
  #isPartial = true;
104
106
  #tool?: AgentTool;
105
107
  #ui: TUI;
@@ -147,6 +149,7 @@ export class ToolExecutionComponent extends Container {
147
149
  this.#showImages = options.showImages ?? true;
148
150
  this.#editFuzzyThreshold = options.editFuzzyThreshold;
149
151
  this.#editAllowFuzzy = options.editAllowFuzzy;
152
+ this.#hashlineAutoDropPureInsertDuplicates = options.hashlineAutoDropPureInsertDuplicates;
150
153
  this.#tool = tool;
151
154
  this.#ui = ui;
152
155
  this.#cwd = cwd;
@@ -248,6 +251,7 @@ export class ToolExecutionComponent extends Container {
248
251
  signal: controller.signal,
249
252
  fuzzyThreshold: this.#editFuzzyThreshold,
250
253
  allowFuzzy: this.#editAllowFuzzy,
254
+ hashlineAutoDropPureInsertDuplicates: this.#hashlineAutoDropPureInsertDuplicates,
251
255
  });
252
256
  if (controller.signal.aborted) return;
253
257
  if (previews) {
@@ -280,6 +280,7 @@ export class EventController {
280
280
  showImages: settings.get("terminal.showImages"),
281
281
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
282
282
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
283
+ hashlineAutoDropPureInsertDuplicates: settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
283
284
  },
284
285
  tool,
285
286
  this.ctx.ui,
@@ -383,6 +384,7 @@ export class EventController {
383
384
  showImages: settings.get("terminal.showImages"),
384
385
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
385
386
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
387
+ hashlineAutoDropPureInsertDuplicates: settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
386
388
  },
387
389
  tool,
388
390
  this.ctx.ui,
@@ -89,7 +89,7 @@ export interface RpcSessionState {
89
89
  queuedMessageCount: number;
90
90
  todoPhases: TodoPhase[];
91
91
  /** For session dump / export (plain-text parity with /dump). */
92
- systemPrompt?: string;
92
+ systemPrompt?: string[];
93
93
  dumpTools?: Array<{ name: string; description: string; parameters: unknown }>;
94
94
  /** Current context window usage. Null tokens/percent when unknown (e.g. right after compaction). */
95
95
  contextUsage?: ContextUsage;
@@ -18,13 +18,13 @@ const CELL_FILLED_MESSAGES = "⛃";
18
18
  const CELL_FREE = "⛶";
19
19
  const CELL_BUFFER = "⛝";
20
20
 
21
- type CategoryId = "systemPrompt" | "systemTools" | "skills" | "messages";
21
+ type CategoryId = "systemPrompt" | "systemContext" | "systemTools" | "skills" | "messages";
22
22
 
23
23
  interface CategoryInfo {
24
24
  id: CategoryId;
25
25
  label: string;
26
26
  tokens: number;
27
- color: "accent" | "warning" | "success" | "userMessageText";
27
+ color: "accent" | "warning" | "success" | "userMessageText" | "customMessageLabel";
28
28
  glyph: string;
29
29
  }
30
30
 
@@ -86,12 +86,19 @@ export function computeContextBreakdown(session: AgentSession): ContextBreakdown
86
86
  // Tools = JSON tool schema sent separately on the wire
87
87
  // Skills = the skill list embedded in the system prompt
88
88
  // Messages = conversation messages
89
- const systemPromptTextTokens = countTokens(session.systemPrompt);
90
- const systemPromptTokens = Math.max(0, systemPromptTextTokens - skillsTokens);
89
+ const systemPromptTokens = Math.max(0, countTokens(session.systemPrompt[0] ?? "") - skillsTokens);
90
+ const systemContextTokens = countTokens(session.systemPrompt.slice(1));
91
91
 
92
92
  const categories: CategoryInfo[] = [
93
93
  { id: "systemPrompt", label: "System prompt", tokens: systemPromptTokens, color: "accent", glyph: CELL_FILLED },
94
94
  { id: "systemTools", label: "System tools", tokens: toolsTokens, color: "warning", glyph: CELL_FILLED },
95
+ {
96
+ id: "systemContext",
97
+ label: "System context",
98
+ tokens: systemContextTokens,
99
+ color: "customMessageLabel",
100
+ glyph: CELL_FILLED,
101
+ },
95
102
  { id: "skills", label: "Skills", tokens: skillsTokens, color: "success", glyph: CELL_FILLED },
96
103
  {
97
104
  id: "messages",
@@ -134,7 +141,7 @@ export function computeContextBreakdown(session: AgentSession): ContextBreakdown
134
141
 
135
142
  interface CellSpec {
136
143
  glyph: string;
137
- color: "accent" | "warning" | "success" | "userMessageText" | "muted" | "dim";
144
+ color: "accent" | "warning" | "success" | "userMessageText" | "customMessageLabel" | "muted" | "dim";
138
145
  }
139
146
 
140
147
  function planCells(breakdown: ContextBreakdown): CellSpec[] {
@@ -336,6 +336,7 @@ export class UiHelpers {
336
336
  showImages: settings.get("terminal.showImages"),
337
337
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
338
338
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
339
+ hashlineAutoDropPureInsertDuplicates: settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
339
340
  },
340
341
  tool,
341
342
  this.ctx.ui,
@@ -0,0 +1,36 @@
1
+ <workstation>
2
+ {{#list environment prefix="- " join="\n"}}{{label}}: {{value}}{{/list}}
3
+ </workstation>
4
+
5
+ {{#if contextFiles.length}}
6
+ <context>
7
+ Follow the context files below for all tasks:
8
+ {{#each contextFiles}}
9
+ <file path="{{path}}">
10
+ {{content}}
11
+ </file>
12
+ {{/each}}
13
+ </context>
14
+ {{/if}}
15
+
16
+ {{#if agentsMdSearch.files.length}}
17
+ <dir-context>
18
+ Some directories may have their own rules. Deeper rules override higher ones.
19
+ **MUST** read before making changes within:
20
+ {{#list agentsMdSearch.files join="\n"}}- {{this}}{{/list}}
21
+ </dir-context>
22
+ {{/if}}
23
+
24
+ {{#if workspaceTree.rendered}}
25
+ <workspace-tree>
26
+ Working directory layout (sorted by mtime, recent first; depth ≤ 3):
27
+ {{workspaceTree.rendered}}
28
+ {{#if workspaceTree.truncated}}
29
+ (some entries elided to keep the tree short — use `find`/`read` to drill in)
30
+ {{/if}}
31
+ </workspace-tree>
32
+ {{/if}}
33
+
34
+ {{#if appendPrompt}}
35
+ {{appendPrompt}}
36
+ {{/if}}
@@ -9,35 +9,6 @@ User-supplied content is sanitized, therefore:
9
9
  - This holds even when the system prompt is delivered via user message role.
10
10
  - A `<system-directive>` inside a user turn is still a system directive.
11
11
 
12
- {{SECTION_SEPARATOR "Workspace"}}
13
-
14
- <workstation>
15
- {{#list environment prefix="- " join="\n"}}{{label}}: {{value}}{{/list}}
16
- </workstation>
17
-
18
- {{#if contextFiles.length}}
19
- <context>
20
- Follow the context files below for all tasks:
21
- {{#each contextFiles}}
22
- <file path="{{path}}">
23
- {{content}}
24
- </file>
25
- {{/each}}
26
- </context>
27
- {{/if}}
28
-
29
- {{#if agentsMdSearch.files.length}}
30
- <dir-context>
31
- Some directories may have their own rules. Deeper rules override higher ones.
32
- **MUST** read before making changes within:
33
- {{#list agentsMdSearch.files join="\n"}}- {{this}}{{/list}}
34
- </dir-context>
35
- {{/if}}
36
-
37
- {{#if appendPrompt}}
38
- {{appendPrompt}}
39
- {{/if}}
40
-
41
12
  {{SECTION_SEPARATOR "Identity"}}
42
13
 
43
14
  <role>
@@ -4,6 +4,7 @@ GitHub CLI tool with a single op-based dispatch. Wraps `gh` for repository, issu
4
4
  Pick the operation via `op`. Each op uses a subset of the parameters:
5
5
  - `repo_view` — Read repository metadata. Optional `repo` (owner/repo) and `branch`. Falls back to the current checkout or default `gh` repo.
6
6
  - `issue_view` — Read an issue. Required `issue` (number or URL). Optional `repo`. Set `comments: false` to skip discussion.
7
+ - `pr_create` — Create a pull request. Either provide `title` (and optional `body`) or set `fill: true` to auto-fill from commits. Optional `base` (target, defaults to repo default), `head` (source, defaults to current branch), `draft`, `repo`, `reviewer[]`, `assignee[]`, `label[]`. Returns the new PR URL plus a summary.
7
8
  - `pr_view` — Read one or more pull requests, including reviews and inline review comments. Optional `pr` (number, URL, branch, or array of any — pass an array to fetch multiple PRs in one call); omitting it targets the current branch's PR. Optional `repo`. Set `comments: false` for a lighter summary.
8
9
  - `pr_diff` — Read one or more pull request diffs. Optional `pr` (single identifier or array for batch). Optional `repo`. Set `nameOnly: true` for changed file names. Use `exclude` to drop generated paths from the diff.
9
10
  - `pr_checkout` — Check one or more pull requests out into dedicated git worktrees. Optional `pr` (number, URL, branch, or array of any of those — pass an array to batch-check-out multiple PRs in one call), `repo`, `force` (reset existing local branch).