@oh-my-pi/pi-coding-agent 14.9.7 → 14.9.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/package.json +7 -7
- package/scripts/build-binary.ts +11 -0
- package/scripts/generate-template.ts +4 -3
- package/src/cli/stats-cli.ts +2 -0
- package/src/cli.ts +23 -1
- package/src/config/settings-schema.ts +30 -9
- package/src/config/settings.ts +18 -1
- package/src/edit/streaming.ts +1 -1
- package/src/eval/js/context-manager.ts +9 -8
- package/src/export/html/index.ts +5 -2
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.macro.ts +4 -3
- package/src/hashline/grammar.lark +1 -1
- package/src/hashline/input.ts +11 -5
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/modes/components/read-tool-group.ts +9 -0
- package/src/prompts/tools/hashline.md +15 -15
- package/src/prompts/tools/read.md +1 -0
- package/src/task/index.ts +12 -50
- package/src/task/worktree.ts +170 -239
- package/src/tools/browser/tab-supervisor.ts +13 -13
- package/src/tools/conflict-detect.ts +661 -0
- package/src/tools/index.ts +6 -0
- package/src/tools/path-utils.ts +1 -1
- package/src/tools/read.ts +130 -0
- package/src/tools/write.ts +204 -0
- package/src/utils/git.ts +5 -0
- package/src/task/isolation-backend.ts +0 -94
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect and resolve unresolved git merge conflicts that surface in `read`
|
|
3
|
+
* output.
|
|
4
|
+
*
|
|
5
|
+
* Workflow:
|
|
6
|
+
* 1. `read` collects lines from disk as usual.
|
|
7
|
+
* 2. `scanConflictLines` inspects those lines (no extra I/O) for
|
|
8
|
+
* well-formed `<<<<<<<` / `=======` / `>>>>>>>` blocks.
|
|
9
|
+
* 3. Each completed block is registered with the session's
|
|
10
|
+
* `ConflictHistory`, which assigns it a stable id.
|
|
11
|
+
* 4. The read output is returned verbatim with a short footer naming
|
|
12
|
+
* every conflict id surfaced, and the agent calls
|
|
13
|
+
* `write({ path: "conflict://<id>", content })` to splice the
|
|
14
|
+
* recorded region with the chosen content.
|
|
15
|
+
*
|
|
16
|
+
* Marker shape is strict: only column-0 markers of the exact prefix length
|
|
17
|
+
* followed by either EOL or a single space + label count. Lines that
|
|
18
|
+
* merely start with `<` or `=` never match.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ToolSession } from "./index";
|
|
22
|
+
import { ToolError } from "./tool-errors";
|
|
23
|
+
|
|
24
|
+
const OURS_PREFIX = "<<<<<<<";
|
|
25
|
+
const BASE_PREFIX = "|||||||";
|
|
26
|
+
const SEPARATOR = "=======";
|
|
27
|
+
const THEIRS_PREFIX = ">>>>>>>";
|
|
28
|
+
|
|
29
|
+
export interface ConflictBlock {
|
|
30
|
+
/** 1-indexed line of the `<<<<<<<` marker. */
|
|
31
|
+
startLine: number;
|
|
32
|
+
/** 1-indexed line of the `=======` separator. */
|
|
33
|
+
separatorLine: number;
|
|
34
|
+
/** 1-indexed line of the `>>>>>>>` marker. */
|
|
35
|
+
endLine: number;
|
|
36
|
+
/** 1-indexed line of the `|||||||` base marker (diff3 only). */
|
|
37
|
+
baseLine?: number;
|
|
38
|
+
oursLabel?: string;
|
|
39
|
+
baseLabel?: string;
|
|
40
|
+
theirsLabel?: string;
|
|
41
|
+
oursLines: string[];
|
|
42
|
+
baseLines?: string[];
|
|
43
|
+
theirsLines: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Scan an already-collected array of file lines for completed conflict
|
|
48
|
+
* blocks. `firstLineNumber` is the 1-indexed line number of `lines[0]`
|
|
49
|
+
* (so a windowed read starting at line 200 passes `firstLineNumber: 200`).
|
|
50
|
+
*
|
|
51
|
+
* Only fully-closed blocks (opener + separator + closer all present in
|
|
52
|
+
* the window) are returned. A block whose closer is past the window's
|
|
53
|
+
* tail is dropped — the agent will see the open marker and can widen
|
|
54
|
+
* the read.
|
|
55
|
+
*/
|
|
56
|
+
export function scanConflictLines(lines: readonly string[], firstLineNumber: number): ConflictBlock[] {
|
|
57
|
+
const blocks: ConflictBlock[] = [];
|
|
58
|
+
let phase: "idle" | "ours" | "base" | "theirs" = "idle";
|
|
59
|
+
let partial: {
|
|
60
|
+
startLine: number;
|
|
61
|
+
oursLabel?: string;
|
|
62
|
+
oursLines: string[];
|
|
63
|
+
baseLine?: number;
|
|
64
|
+
baseLabel?: string;
|
|
65
|
+
baseLines?: string[];
|
|
66
|
+
separatorLine?: number;
|
|
67
|
+
theirsLines?: string[];
|
|
68
|
+
} | null = null;
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < lines.length; i++) {
|
|
71
|
+
const line = lines[i];
|
|
72
|
+
const ln = firstLineNumber + i;
|
|
73
|
+
|
|
74
|
+
const oursLabel = matchMarker(line, OURS_PREFIX);
|
|
75
|
+
if (oursLabel !== null) {
|
|
76
|
+
partial = { startLine: ln, oursLabel: oursLabel || undefined, oursLines: [] };
|
|
77
|
+
phase = "ours";
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (phase === "idle" || partial === null) continue;
|
|
82
|
+
|
|
83
|
+
const baseLabel = matchMarker(line, BASE_PREFIX);
|
|
84
|
+
if (baseLabel !== null) {
|
|
85
|
+
if (phase !== "ours") {
|
|
86
|
+
partial = null;
|
|
87
|
+
phase = "idle";
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
partial.baseLine = ln;
|
|
91
|
+
partial.baseLabel = baseLabel || undefined;
|
|
92
|
+
partial.baseLines = [];
|
|
93
|
+
phase = "base";
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (line === SEPARATOR) {
|
|
98
|
+
if (phase === "ours" || phase === "base") {
|
|
99
|
+
partial.separatorLine = ln;
|
|
100
|
+
partial.theirsLines = [];
|
|
101
|
+
phase = "theirs";
|
|
102
|
+
} else {
|
|
103
|
+
partial = null;
|
|
104
|
+
phase = "idle";
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const theirsLabel = matchMarker(line, THEIRS_PREFIX);
|
|
110
|
+
if (theirsLabel !== null) {
|
|
111
|
+
if (phase === "theirs" && partial.separatorLine !== undefined && partial.theirsLines) {
|
|
112
|
+
blocks.push({
|
|
113
|
+
startLine: partial.startLine,
|
|
114
|
+
separatorLine: partial.separatorLine,
|
|
115
|
+
endLine: ln,
|
|
116
|
+
baseLine: partial.baseLine,
|
|
117
|
+
oursLabel: partial.oursLabel,
|
|
118
|
+
baseLabel: partial.baseLabel,
|
|
119
|
+
theirsLabel: theirsLabel || undefined,
|
|
120
|
+
oursLines: partial.oursLines,
|
|
121
|
+
baseLines: partial.baseLines,
|
|
122
|
+
theirsLines: partial.theirsLines,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
partial = null;
|
|
126
|
+
phase = "idle";
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (phase === "ours") partial.oursLines.push(line);
|
|
131
|
+
else if (phase === "base" && partial.baseLines) partial.baseLines.push(line);
|
|
132
|
+
else if (phase === "theirs" && partial.theirsLines) partial.theirsLines.push(line);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return blocks;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const SCAN_FILE_DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Scan a whole file for unresolved conflict blocks.
|
|
142
|
+
*
|
|
143
|
+
* Reads at most `maxBytes` (default 10 MB) so this stays cheap on
|
|
144
|
+
* pathological files. Files truncated by the cap report
|
|
145
|
+
* `scanTruncated: true`; only complete blocks within the scanned prefix
|
|
146
|
+
* are returned, so trailing partial markers never invent fake blocks.
|
|
147
|
+
*/
|
|
148
|
+
export async function scanFileForConflicts(
|
|
149
|
+
absolutePath: string,
|
|
150
|
+
options: { maxBytes?: number } = {},
|
|
151
|
+
): Promise<{ blocks: ConflictBlock[]; scanTruncated: boolean }> {
|
|
152
|
+
const maxBytes = options.maxBytes ?? SCAN_FILE_DEFAULT_MAX_BYTES;
|
|
153
|
+
const file = Bun.file(absolutePath);
|
|
154
|
+
const size = file.size;
|
|
155
|
+
const truncated = size > maxBytes;
|
|
156
|
+
const bytes = truncated ? new Uint8Array(await file.slice(0, maxBytes).arrayBuffer()) : await file.bytes();
|
|
157
|
+
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
158
|
+
// `split("\n")` over a truncated read may leave a partial last line; the
|
|
159
|
+
// scanner already tolerates an unclosed opener, so no extra trimming.
|
|
160
|
+
const lines = text.split("\n");
|
|
161
|
+
return { blocks: scanConflictLines(lines, 1), scanTruncated: truncated };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Return the label after a marker prefix when the line is a valid
|
|
166
|
+
* column-0 marker, or `null` when it isn't. Strict shape: prefix alone,
|
|
167
|
+
* or prefix + single space + label.
|
|
168
|
+
*/
|
|
169
|
+
function matchMarker(line: string, prefix: string): string | null {
|
|
170
|
+
if (!line.startsWith(prefix)) return null;
|
|
171
|
+
if (line.length === prefix.length) return "";
|
|
172
|
+
if (line.charCodeAt(prefix.length) !== 32 /* space */) return null;
|
|
173
|
+
return line.slice(prefix.length + 1);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Recorded conflict block keyed by a session-stable id. The history is
|
|
178
|
+
* append-only; ids stay valid even after later writes resolve other
|
|
179
|
+
* blocks in the same file, so retries don't depend on re-reading.
|
|
180
|
+
*/
|
|
181
|
+
export interface ConflictEntry extends ConflictBlock {
|
|
182
|
+
id: number;
|
|
183
|
+
absolutePath: string;
|
|
184
|
+
displayPath: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Per-session log of conflict regions surfaced by `read`. */
|
|
188
|
+
export class ConflictHistory {
|
|
189
|
+
#nextId = 1;
|
|
190
|
+
#entries = new Map<number, ConflictEntry>();
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Register a conflict block. Returns the (possibly pre-existing) entry
|
|
194
|
+
* — if the same `absolutePath`+`startLine` was registered before, the
|
|
195
|
+
* earlier id is reused so a re-read does not inflate the counter or
|
|
196
|
+
* orphan the prior id. The recorded region is overwritten on re-read
|
|
197
|
+
* so the splice always reflects the current marker positions on disk.
|
|
198
|
+
*/
|
|
199
|
+
register(input: Omit<ConflictEntry, "id">): ConflictEntry {
|
|
200
|
+
for (const existing of this.#entries.values()) {
|
|
201
|
+
if (existing.absolutePath === input.absolutePath && existing.startLine === input.startLine) {
|
|
202
|
+
const merged: ConflictEntry = { ...input, id: existing.id };
|
|
203
|
+
this.#entries.set(existing.id, merged);
|
|
204
|
+
return merged;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const id = this.#nextId++;
|
|
208
|
+
const entry: ConflictEntry = { ...input, id };
|
|
209
|
+
this.#entries.set(id, entry);
|
|
210
|
+
return entry;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
get(id: number): ConflictEntry | undefined {
|
|
214
|
+
return this.#entries.get(id);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Snapshot every registered entry in insertion (id) order. */
|
|
218
|
+
entries(): ConflictEntry[] {
|
|
219
|
+
return [...this.#entries.values()];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Drop a single entry by id. Used after a successful resolve. */
|
|
223
|
+
invalidate(id: number): void {
|
|
224
|
+
this.#entries.delete(id);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Drop every entry referencing `absolutePath`. Used after a successful resolve. */
|
|
228
|
+
invalidatePath(absolutePath: string): void {
|
|
229
|
+
for (const [id, entry] of this.#entries) {
|
|
230
|
+
if (entry.absolutePath === absolutePath) {
|
|
231
|
+
this.#entries.delete(id);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Lazily attach a `ConflictHistory` to the session and return it. */
|
|
238
|
+
export function getConflictHistory(session: ToolSession): ConflictHistory {
|
|
239
|
+
if (!session.conflictHistory) session.conflictHistory = new ConflictHistory();
|
|
240
|
+
return session.conflictHistory;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** A side of a conflict block that `read conflict://N/<scope>` can render. */
|
|
244
|
+
export type ConflictScope = "ours" | "theirs" | "base";
|
|
245
|
+
|
|
246
|
+
const CONFLICT_SCOPES = new Set<ConflictScope>(["ours", "theirs", "base"]);
|
|
247
|
+
|
|
248
|
+
/** Parsed `conflict://<N>` / `conflict://<N>/<scope>` / `conflict://*` URI. */
|
|
249
|
+
export interface ParsedConflictUri {
|
|
250
|
+
/** `"*"` selects every currently-registered conflict (bulk write only). */
|
|
251
|
+
id: number | "*";
|
|
252
|
+
scope?: ConflictScope;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const CONFLICT_URI_RE = /^conflict:\/\/(.+)$/;
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Parse a `conflict://<N>`, `conflict://<N>/<scope>`, or `conflict://*` URI.
|
|
259
|
+
*
|
|
260
|
+
* Returns `null` for non-conflict paths; throws `ToolError` for a
|
|
261
|
+
* well-formed scheme with an invalid id or scope so the agent gets a
|
|
262
|
+
* clear actionable message rather than a confusing "not found" later.
|
|
263
|
+
*
|
|
264
|
+
* `*` is the bulk-write wildcard — only valid as `conflict://*` (no
|
|
265
|
+
* scope segment). Use it with `write({ path: "conflict://*", content })`
|
|
266
|
+
* to apply `content` (with optional `@ours` / `@theirs` / `@base` /
|
|
267
|
+
* `@both` shorthand) to every currently-registered conflict in one shot.
|
|
268
|
+
*/
|
|
269
|
+
export function parseConflictUri(raw: string): ParsedConflictUri | null {
|
|
270
|
+
const match = raw.match(CONFLICT_URI_RE);
|
|
271
|
+
if (!match) return null;
|
|
272
|
+
const tail = match[1];
|
|
273
|
+
const slashIdx = tail.indexOf("/");
|
|
274
|
+
const idPart = slashIdx === -1 ? tail : tail.slice(0, slashIdx);
|
|
275
|
+
const scopePart = slashIdx === -1 ? undefined : tail.slice(slashIdx + 1);
|
|
276
|
+
|
|
277
|
+
if (idPart === "*") {
|
|
278
|
+
if (scopePart !== undefined) {
|
|
279
|
+
throw new ToolError(
|
|
280
|
+
`Invalid conflict URI '${raw}': wildcard 'conflict://*' does not accept a scope segment. Drop '/${scopePart}' or use a numeric id.`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return { id: "*" };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!/^\d+$/.test(idPart)) {
|
|
287
|
+
throw new ToolError(
|
|
288
|
+
`Invalid conflict URI '${raw}': must be 'conflict://<N>', 'conflict://<N>/<scope>', or 'conflict://*' where N is a positive integer surfaced by a prior \`read\`.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const id = Number.parseInt(idPart, 10);
|
|
292
|
+
if (!Number.isFinite(id) || id < 1) {
|
|
293
|
+
throw new ToolError(`Invalid conflict URI '${raw}': id must be ≥ 1.`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let scope: ConflictScope | undefined;
|
|
297
|
+
if (scopePart !== undefined) {
|
|
298
|
+
if (!CONFLICT_SCOPES.has(scopePart as ConflictScope)) {
|
|
299
|
+
throw new ToolError(
|
|
300
|
+
`Invalid conflict URI '${raw}': scope must be one of 'ours', 'theirs', 'base', or omitted (e.g. 'conflict://${id}/theirs').`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
scope = scopePart as ConflictScope;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { id, scope };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Splice the conflict region recorded in `entry` out of `originalText`
|
|
311
|
+
* and replace it with `replacement` (markers and all sides included).
|
|
312
|
+
*
|
|
313
|
+
* Works like the edit tool's patch infra: locates the recorded marker
|
|
314
|
+
* block by content (anchored to `entry.startLine` as the preferred
|
|
315
|
+
* match), so out-of-band edits earlier in the file that shift line
|
|
316
|
+
* numbers don't break resolution. Throws clearly when the marker block
|
|
317
|
+
* has actually been altered or removed.
|
|
318
|
+
*/
|
|
319
|
+
export function spliceConflict(originalText: string, entry: ConflictEntry, replacement: string): string {
|
|
320
|
+
const lines = originalText.split("\n");
|
|
321
|
+
const expected = buildRecordedRegion(entry);
|
|
322
|
+
const match = locateRegion(lines, expected, entry.startLine - 1);
|
|
323
|
+
if (!match) {
|
|
324
|
+
throw new ToolError(
|
|
325
|
+
`Conflict #${entry.id} no longer present in '${entry.displayPath}': the recorded marker block can't be located. The file changed since the conflict was registered — re-read it to re-register conflicts.`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const trimmed = normalizeTrailingNewline(replacement);
|
|
330
|
+
const replacementLines = trimmed.split("\n");
|
|
331
|
+
const next = [...lines.slice(0, match.startIdx), ...replacementLines, ...lines.slice(match.endIdx + 1)];
|
|
332
|
+
return next.join("\n");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Reconstruct the recorded marker block as it should appear in the file. */
|
|
336
|
+
function buildRecordedRegion(entry: ConflictEntry): string[] {
|
|
337
|
+
const out: string[] = [];
|
|
338
|
+
out.push(entry.oursLabel ? `${OURS_PREFIX} ${entry.oursLabel}` : OURS_PREFIX);
|
|
339
|
+
out.push(...entry.oursLines);
|
|
340
|
+
if (entry.baseLines !== undefined) {
|
|
341
|
+
out.push(entry.baseLabel ? `${BASE_PREFIX} ${entry.baseLabel}` : BASE_PREFIX);
|
|
342
|
+
out.push(...entry.baseLines);
|
|
343
|
+
}
|
|
344
|
+
out.push(SEPARATOR);
|
|
345
|
+
out.push(...entry.theirsLines);
|
|
346
|
+
out.push(entry.theirsLabel ? `${THEIRS_PREFIX} ${entry.theirsLabel}` : THEIRS_PREFIX);
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Find a contiguous match of `expected` inside `lines`, preferring the
|
|
352
|
+
* occurrence closest to `preferredIdx` to disambiguate when an identical
|
|
353
|
+
* block (vanishingly unlikely for real conflicts) appears more than once.
|
|
354
|
+
*/
|
|
355
|
+
function locateRegion(
|
|
356
|
+
lines: readonly string[],
|
|
357
|
+
expected: readonly string[],
|
|
358
|
+
preferredIdx: number,
|
|
359
|
+
): { startIdx: number; endIdx: number } | null {
|
|
360
|
+
if (expected.length === 0 || expected.length > lines.length) return null;
|
|
361
|
+
// Fast path: try the recorded position first.
|
|
362
|
+
if (preferredIdx >= 0 && matchesAt(lines, preferredIdx, expected)) {
|
|
363
|
+
return { startIdx: preferredIdx, endIdx: preferredIdx + expected.length - 1 };
|
|
364
|
+
}
|
|
365
|
+
let best: number | null = null;
|
|
366
|
+
let bestDist = Number.POSITIVE_INFINITY;
|
|
367
|
+
const limit = lines.length - expected.length;
|
|
368
|
+
for (let i = 0; i <= limit; i++) {
|
|
369
|
+
if (!matchesAt(lines, i, expected)) continue;
|
|
370
|
+
const dist = Math.abs(i - preferredIdx);
|
|
371
|
+
if (dist < bestDist) {
|
|
372
|
+
best = i;
|
|
373
|
+
bestDist = dist;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (best === null) return null;
|
|
377
|
+
return { startIdx: best, endIdx: best + expected.length - 1 };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function matchesAt(lines: readonly string[], startIdx: number, expected: readonly string[]): boolean {
|
|
381
|
+
if (startIdx < 0 || startIdx + expected.length > lines.length) return false;
|
|
382
|
+
for (let i = 0; i < expected.length; i++) {
|
|
383
|
+
if (lines[startIdx + i] !== expected[i]) return false;
|
|
384
|
+
}
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function normalizeTrailingNewline(replacement: string): string {
|
|
389
|
+
if (replacement.endsWith("\r\n")) return replacement.slice(0, -2);
|
|
390
|
+
if (replacement.endsWith("\n")) return replacement.slice(0, -1);
|
|
391
|
+
return replacement;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Expand `@ours` / `@theirs` / `@base` / `@both` line tokens against the
|
|
396
|
+
* recorded sections of `entry`. A token only triggers when it is the
|
|
397
|
+
* entire content of a line (after CRLF normalisation), so `@ours` inside
|
|
398
|
+
* actual code is left alone. Other lines pass through verbatim.
|
|
399
|
+
*
|
|
400
|
+
* - `@ours` → expands to the recorded `oursLines` (in order).
|
|
401
|
+
* - `@theirs` → expands to the recorded `theirsLines` (in order).
|
|
402
|
+
* - `@base` → expands to `baseLines`; throws if no base section was
|
|
403
|
+
* recorded (i.e. the conflict was 2-way, not diff3).
|
|
404
|
+
* - `@both` → expands to `oursLines` then `theirsLines`.
|
|
405
|
+
*/
|
|
406
|
+
export function expandContentTokens(content: string, entry: ConflictEntry): string {
|
|
407
|
+
const inputLines = content.split("\n");
|
|
408
|
+
const out: string[] = [];
|
|
409
|
+
for (const rawLine of inputLines) {
|
|
410
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
411
|
+
switch (line) {
|
|
412
|
+
case "@ours":
|
|
413
|
+
out.push(...entry.oursLines);
|
|
414
|
+
break;
|
|
415
|
+
case "@theirs":
|
|
416
|
+
out.push(...entry.theirsLines);
|
|
417
|
+
break;
|
|
418
|
+
case "@base":
|
|
419
|
+
if (!entry.baseLines) {
|
|
420
|
+
throw new ToolError(
|
|
421
|
+
`Conflict #${entry.id} has no base section (2-way merge). \`@base\` is only valid for diff3 conflicts.`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
out.push(...entry.baseLines);
|
|
425
|
+
break;
|
|
426
|
+
case "@both":
|
|
427
|
+
out.push(...entry.oursLines, ...entry.theirsLines);
|
|
428
|
+
break;
|
|
429
|
+
default:
|
|
430
|
+
out.push(rawLine);
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return out.join("\n");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Reconstruct a conflict-marker line from prefix and optional label. */
|
|
438
|
+
function markerLine(prefix: string, label: string | undefined): string {
|
|
439
|
+
return label && label.length > 0 ? `${prefix} ${label}` : prefix;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Materialise a conflict block for `read conflict://<N>` (and its
|
|
444
|
+
* `/ours` / `/theirs` / `/base` scopes).
|
|
445
|
+
*
|
|
446
|
+
* Returns:
|
|
447
|
+
* - `lines`: the lines to render, ordered top-to-bottom.
|
|
448
|
+
* - `startLine`: the 1-indexed file line number `lines[0]` corresponds
|
|
449
|
+
* to, so the read formatter can label hashline anchors with the
|
|
450
|
+
* original file positions.
|
|
451
|
+
*
|
|
452
|
+
* Bare (no scope) returns the full block including marker lines. A
|
|
453
|
+
* scoped view returns only that side's body — `base` throws when the
|
|
454
|
+
* recorded conflict is a 2-way merge with no base section.
|
|
455
|
+
*/
|
|
456
|
+
export function renderConflictRegion(
|
|
457
|
+
entry: ConflictEntry,
|
|
458
|
+
scope: ConflictScope | undefined,
|
|
459
|
+
): { lines: string[]; startLine: number } {
|
|
460
|
+
if (scope === "ours") {
|
|
461
|
+
return { lines: [...entry.oursLines], startLine: entry.startLine + 1 };
|
|
462
|
+
}
|
|
463
|
+
if (scope === "theirs") {
|
|
464
|
+
return { lines: [...entry.theirsLines], startLine: entry.separatorLine + 1 };
|
|
465
|
+
}
|
|
466
|
+
if (scope === "base") {
|
|
467
|
+
if (entry.baseLines === undefined || entry.baseLine === undefined) {
|
|
468
|
+
throw new ToolError(
|
|
469
|
+
`Conflict #${entry.id} has no base section (2-way merge). 'conflict://${entry.id}/base' is only valid for diff3 conflicts.`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
return { lines: [...entry.baseLines], startLine: entry.baseLine + 1 };
|
|
473
|
+
}
|
|
474
|
+
const out: string[] = [];
|
|
475
|
+
out.push(markerLine("<<<<<<<", entry.oursLabel));
|
|
476
|
+
out.push(...entry.oursLines);
|
|
477
|
+
if (entry.baseLines !== undefined) {
|
|
478
|
+
out.push(markerLine("|||||||", entry.baseLabel));
|
|
479
|
+
out.push(...entry.baseLines);
|
|
480
|
+
}
|
|
481
|
+
out.push("=======");
|
|
482
|
+
out.push(...entry.theirsLines);
|
|
483
|
+
out.push(markerLine(">>>>>>>", entry.theirsLabel));
|
|
484
|
+
return { lines: out, startLine: entry.startLine };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const PREVIEW_SIDE_LINES = 6;
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Build a compact diff-style footer describing the conflicts registered
|
|
491
|
+
* during a read. Designed to be appended after the file content.
|
|
492
|
+
*
|
|
493
|
+
* Format:
|
|
494
|
+
*
|
|
495
|
+
* ⚠ N unresolved conflicts detected
|
|
496
|
+
* - ours = HEAD
|
|
497
|
+
* - theirs = feature/x
|
|
498
|
+
* NOTICE: …
|
|
499
|
+
*
|
|
500
|
+
* ──── #1 L42-48 ────
|
|
501
|
+
* <<< ours
|
|
502
|
+
* …ours body…
|
|
503
|
+
* === base ≡ ours
|
|
504
|
+
* >>> theirs
|
|
505
|
+
* …theirs body…
|
|
506
|
+
*
|
|
507
|
+
* Labels are aggregated once at the top from the first entry that has
|
|
508
|
+
* them; when a section body equals another section's body the redundant
|
|
509
|
+
* body is collapsed to `≡ <other>`.
|
|
510
|
+
*/
|
|
511
|
+
export interface FormatConflictWarningOptions {
|
|
512
|
+
/**
|
|
513
|
+
* Total number of conflicts in the underlying file. If greater than
|
|
514
|
+
* `entries.length` the header notes how many are visible vs the total
|
|
515
|
+
* and points at `:conflicts` for the compact list.
|
|
516
|
+
*/
|
|
517
|
+
totalInFile?: number;
|
|
518
|
+
/** Display path used inside the `:conflicts` hint. */
|
|
519
|
+
displayPath?: string;
|
|
520
|
+
/** Whether the underlying file scan hit its byte cap. */
|
|
521
|
+
scanTruncated?: boolean;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function formatConflictWarning(
|
|
525
|
+
entries: readonly ConflictEntry[],
|
|
526
|
+
options: FormatConflictWarningOptions = {},
|
|
527
|
+
): string {
|
|
528
|
+
if (entries.length === 0) return "";
|
|
529
|
+
const total = options.totalInFile ?? entries.length;
|
|
530
|
+
const partial = total > entries.length;
|
|
531
|
+
const out: string[] = [];
|
|
532
|
+
out.push("");
|
|
533
|
+
const word = total === 1 ? "conflict" : "conflicts";
|
|
534
|
+
if (partial) {
|
|
535
|
+
const hintPath = options.displayPath ?? "<file>";
|
|
536
|
+
out.push(
|
|
537
|
+
`⚠ ${entries.length} of ${total} unresolved ${word} visible in this window (run \`read ${hintPath}:conflicts\` for the full list).`,
|
|
538
|
+
);
|
|
539
|
+
} else {
|
|
540
|
+
out.push(`⚠ ${total} unresolved ${word} detected`);
|
|
541
|
+
}
|
|
542
|
+
if (options.scanTruncated) {
|
|
543
|
+
out.push("- note: file scan hit the byte cap; additional conflicts may exist beyond the scanned prefix.");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const oursLabel = pickLabel(entries, e => e.oursLabel);
|
|
547
|
+
const theirsLabel = pickLabel(entries, e => e.theirsLabel);
|
|
548
|
+
const baseLabel = pickLabel(entries, e => (e.baseLines !== undefined ? e.baseLabel : undefined));
|
|
549
|
+
const anyBase = entries.some(e => e.baseLines !== undefined);
|
|
550
|
+
if (oursLabel) out.push(`- ours = ${oursLabel}`);
|
|
551
|
+
if (theirsLabel) out.push(`- theirs = ${theirsLabel}`);
|
|
552
|
+
if (anyBase) out.push(`- base = ${baseLabel ?? "(no label)"}`);
|
|
553
|
+
out.push(
|
|
554
|
+
'NOTICE: Inspect a block with `read conflict://<N>` (add `/ours` / `/theirs` / `/base` to render a single side). Resolve with `write({ path: "conflict://<N>", content })`, or bulk-resolve every registered conflict with `write({ path: "conflict://*", content })`. Writes replace the whole conflict region (markers + all sides).',
|
|
555
|
+
);
|
|
556
|
+
out.push(
|
|
557
|
+
'`content` shorthand: a line that is exactly `@ours` / `@theirs` / `@base` / `@both` expands to that recorded section. `@both` is ours-then-theirs with no separator. Lines that are not a token pass through verbatim, so `"// keep both\\n@ours\\n@theirs"` literally writes the comment, then ours, then theirs.',
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
for (const entry of entries) {
|
|
561
|
+
const range = entry.startLine === entry.endLine ? `L${entry.startLine}` : `L${entry.startLine}-${entry.endLine}`;
|
|
562
|
+
out.push("");
|
|
563
|
+
out.push(`──── #${entry.id} ${range} ────`);
|
|
564
|
+
|
|
565
|
+
const baseEqualsOurs = entry.baseLines !== undefined && sectionsEqual(entry.baseLines, entry.oursLines);
|
|
566
|
+
const baseEqualsTheirs = entry.baseLines !== undefined && sectionsEqual(entry.baseLines, entry.theirsLines);
|
|
567
|
+
const theirsEqualsOurs = sectionsEqual(entry.theirsLines, entry.oursLines);
|
|
568
|
+
|
|
569
|
+
out.push("<<< ours");
|
|
570
|
+
appendBody(out, entry.oursLines);
|
|
571
|
+
|
|
572
|
+
if (entry.baseLines !== undefined) {
|
|
573
|
+
if (baseEqualsOurs) {
|
|
574
|
+
out.push("=== base ≡ ours");
|
|
575
|
+
} else if (baseEqualsTheirs) {
|
|
576
|
+
out.push("=== base ≡ theirs");
|
|
577
|
+
} else {
|
|
578
|
+
out.push("=== base");
|
|
579
|
+
appendBody(out, entry.baseLines);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (theirsEqualsOurs) {
|
|
584
|
+
out.push(">>> theirs ≡ ours");
|
|
585
|
+
} else {
|
|
586
|
+
out.push(">>> theirs");
|
|
587
|
+
appendBody(out, entry.theirsLines);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return out.join("\n");
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Render a single-line-per-block index of every conflict in a file.
|
|
595
|
+
* Used by `read <path>:conflicts` to give the agent a cheap overview
|
|
596
|
+
* of a heavily-conflicted file without dumping every body.
|
|
597
|
+
*/
|
|
598
|
+
export function formatConflictSummary(
|
|
599
|
+
entries: readonly ConflictEntry[],
|
|
600
|
+
options: { displayPath: string; scanTruncated?: boolean } = { displayPath: "" },
|
|
601
|
+
): string {
|
|
602
|
+
const lines: string[] = [];
|
|
603
|
+
const total = entries.length;
|
|
604
|
+
const word = total === 1 ? "conflict" : "conflicts";
|
|
605
|
+
lines.push(`⚠ ${total} unresolved ${word} in ${options.displayPath || "<file>"}`);
|
|
606
|
+
if (options.scanTruncated) {
|
|
607
|
+
lines.push("- note: file scan hit the byte cap; additional conflicts may exist beyond the scanned prefix.");
|
|
608
|
+
}
|
|
609
|
+
const oursLabel = pickLabel(entries, e => e.oursLabel);
|
|
610
|
+
const theirsLabel = pickLabel(entries, e => e.theirsLabel);
|
|
611
|
+
const baseLabel = pickLabel(entries, e => (e.baseLines !== undefined ? e.baseLabel : undefined));
|
|
612
|
+
const anyBase = entries.some(e => e.baseLines !== undefined);
|
|
613
|
+
if (oursLabel) lines.push(`- ours = ${oursLabel}`);
|
|
614
|
+
if (theirsLabel) lines.push(`- theirs = ${theirsLabel}`);
|
|
615
|
+
if (anyBase) lines.push(`- base = ${baseLabel ?? "(no label)"}`);
|
|
616
|
+
lines.push(
|
|
617
|
+
'NOTICE: Bulk-resolve with `write({ path: "conflict://*", content })`, or address a single block with `write({ path: "conflict://<N>", content })`. Inspect a block with `read conflict://<N>` (add `/ours` / `/theirs` / `/base` for a single side).',
|
|
618
|
+
);
|
|
619
|
+
lines.push(
|
|
620
|
+
"`content` shorthand: `@ours` / `@theirs` / `@base` / `@both` lines expand to the recorded sections; `@both` = ours-then-theirs. Non-token lines pass through verbatim.",
|
|
621
|
+
);
|
|
622
|
+
lines.push("");
|
|
623
|
+
const idWidth = String(entries[entries.length - 1]?.id ?? 1).length;
|
|
624
|
+
for (const entry of entries) {
|
|
625
|
+
const range = entry.startLine === entry.endLine ? `L${entry.startLine}` : `L${entry.startLine}-${entry.endLine}`;
|
|
626
|
+
const idCell = `#${String(entry.id).padStart(idWidth, " ")}`;
|
|
627
|
+
const kind = entry.baseLines !== undefined ? " (3-way)" : "";
|
|
628
|
+
lines.push(`${idCell} ${range}${kind}`);
|
|
629
|
+
}
|
|
630
|
+
return lines.join("\n");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function pickLabel(
|
|
634
|
+
entries: readonly ConflictEntry[],
|
|
635
|
+
get: (e: ConflictEntry) => string | undefined,
|
|
636
|
+
): string | undefined {
|
|
637
|
+
for (const e of entries) {
|
|
638
|
+
const label = get(e);
|
|
639
|
+
if (label && label.trim().length > 0) return label;
|
|
640
|
+
}
|
|
641
|
+
return undefined;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function sectionsEqual(a: readonly string[], b: readonly string[]): boolean {
|
|
645
|
+
if (a.length !== b.length) return false;
|
|
646
|
+
for (let i = 0; i < a.length; i++) {
|
|
647
|
+
if (a[i] !== b[i]) return false;
|
|
648
|
+
}
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function appendBody(out: string[], section: readonly string[]): void {
|
|
653
|
+
if (section.length === 0) {
|
|
654
|
+
out.push("(empty)");
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const shown = section.slice(0, PREVIEW_SIDE_LINES);
|
|
658
|
+
for (const line of shown) out.push(line);
|
|
659
|
+
const hidden = section.length - shown.length;
|
|
660
|
+
if (hidden > 0) out.push(`… (${hidden} more line${hidden === 1 ? "" : "s"})`);
|
|
661
|
+
}
|
package/src/tools/index.ts
CHANGED
|
@@ -228,6 +228,12 @@ export interface ToolSession {
|
|
|
228
228
|
* out-of-band. Lazily initialized by `getFileReadCache`. */
|
|
229
229
|
fileReadCache?: import("../edit/file-read-cache").FileReadCache;
|
|
230
230
|
|
|
231
|
+
/** Per-session log of unresolved git merge conflict regions surfaced by
|
|
232
|
+
* `read`. Each entry gets a stable id N referenced by `write conflict://N`
|
|
233
|
+
* to splice the recorded region with replacement content. Lazily initialized
|
|
234
|
+
* by `getConflictHistory`. */
|
|
235
|
+
conflictHistory?: import("./conflict-detect").ConflictHistory;
|
|
236
|
+
|
|
231
237
|
/** Queue a hidden message to be injected at the next agent turn. */
|
|
232
238
|
queueDeferredMessage?(message: CustomMessage): void;
|
|
233
239
|
}
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -5,7 +5,7 @@ import * as url from "node:url";
|
|
|
5
5
|
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
6
6
|
|
|
7
7
|
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
8
|
-
const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+)?|raw)$/i;
|
|
8
|
+
const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+)?|raw|conflicts)$/i;
|
|
9
9
|
const FILE_LINE_RANGE_ONLY_RE = /^L?\d+(?:[-+]L?\d+)?$/i;
|
|
10
10
|
const FILE_RAW_ONLY_RE = /^raw$/i;
|
|
11
11
|
const NARROW_NO_BREAK_SPACE = "\u202F";
|