@oh-my-pi/pi-coding-agent 15.5.2 → 15.5.4
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 +38 -0
- package/dist/types/config/settings-schema.d.ts +27 -0
- package/dist/types/config.d.ts +31 -5
- package/dist/types/edit/file-snapshot-store.d.ts +18 -0
- package/dist/types/edit/hashline/diff.d.ts +30 -0
- package/dist/types/edit/hashline/execute.d.ts +29 -0
- package/dist/types/edit/hashline/filesystem.d.ts +57 -0
- package/dist/types/edit/hashline/index.d.ts +4 -0
- package/dist/types/edit/hashline/params.d.ts +12 -0
- package/dist/types/edit/index.d.ts +4 -3
- package/dist/types/edit/normalize.d.ts +4 -16
- package/dist/types/index.d.ts +0 -1
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/index.d.ts +6 -5
- package/dist/types/tools/path-utils.d.ts +18 -0
- package/dist/types/utils/changelog.d.ts +8 -3
- package/package.json +8 -15
- package/src/config/settings-schema.ts +32 -0
- package/src/config.ts +42 -15
- package/src/edit/file-snapshot-store.ts +22 -0
- package/src/edit/hashline/diff.ts +88 -0
- package/src/edit/hashline/execute.ts +188 -0
- package/src/edit/hashline/filesystem.ts +129 -0
- package/src/edit/hashline/index.ts +4 -0
- package/src/edit/hashline/params.ts +11 -0
- package/src/edit/index.ts +7 -15
- package/src/edit/normalize.ts +11 -41
- package/src/edit/renderer.ts +1 -1
- package/src/edit/streaming.ts +8 -9
- package/src/index.ts +0 -1
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/sdk.ts +8 -1
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/ast-grep.ts +3 -3
- package/src/tools/bash.ts +74 -10
- package/src/tools/index.ts +6 -5
- package/src/tools/path-utils.ts +81 -0
- package/src/tools/read.ts +14 -72
- package/src/tools/search.ts +136 -17
- package/src/tools/write.ts +3 -3
- package/src/utils/changelog.ts +11 -3
- package/src/utils/file-mentions.ts +1 -1
- package/dist/types/edit/file-read-cache.d.ts +0 -36
- package/dist/types/hashline/anchors.d.ts +0 -26
- package/dist/types/hashline/apply.d.ts +0 -14
- package/dist/types/hashline/constants.d.ts +0 -40
- package/dist/types/hashline/diff-preview.d.ts +0 -2
- package/dist/types/hashline/diff.d.ts +0 -16
- package/dist/types/hashline/execute.d.ts +0 -4
- package/dist/types/hashline/executor.d.ts +0 -56
- package/dist/types/hashline/hash.d.ts +0 -76
- package/dist/types/hashline/index.d.ts +0 -14
- package/dist/types/hashline/input.d.ts +0 -4
- package/dist/types/hashline/prefixes.d.ts +0 -7
- package/dist/types/hashline/recovery.d.ts +0 -21
- package/dist/types/hashline/stream.d.ts +0 -2
- package/dist/types/hashline/tokenizer.d.ts +0 -94
- package/dist/types/hashline/types.d.ts +0 -75
- package/src/edit/file-read-cache.ts +0 -138
- package/src/hashline/anchors.ts +0 -104
- package/src/hashline/apply.ts +0 -790
- package/src/hashline/bigrams.json +0 -649
- package/src/hashline/constants.ts +0 -51
- package/src/hashline/diff-preview.ts +0 -42
- package/src/hashline/diff.ts +0 -82
- package/src/hashline/execute.ts +0 -334
- package/src/hashline/executor.ts +0 -334
- package/src/hashline/grammar.lark +0 -23
- package/src/hashline/hash.ts +0 -131
- package/src/hashline/index.ts +0 -14
- package/src/hashline/input.ts +0 -137
- package/src/hashline/prefixes.ts +0 -111
- package/src/hashline/recovery.ts +0 -139
- package/src/hashline/stream.ts +0 -123
- package/src/hashline/tokenizer.ts +0 -473
- package/src/hashline/types.ts +0 -66
- package/src/prompts/tools/hashline.md +0 -63
package/src/tools/path-utils.ts
CHANGED
|
@@ -133,6 +133,87 @@ export function expandPath(filePath: string): string {
|
|
|
133
133
|
const normalized = stripFileUrl(normalizeUnicodeSpaces(normalizeAtPrefix(filePath)));
|
|
134
134
|
return expandTilde(normalized);
|
|
135
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Inclusive line range describing one selector segment (e.g. `50-100`,
|
|
138
|
+
* `301-`, or `50+10`). `endLine` is `undefined` for open-ended ranges.
|
|
139
|
+
*/
|
|
140
|
+
export interface LineRange {
|
|
141
|
+
startLine: number;
|
|
142
|
+
endLine: number | undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const LINE_RANGE_CHUNK_RE = /^L?(\d+)(?:([-+])L?(\d+)?)?$/i;
|
|
146
|
+
|
|
147
|
+
/** Parse a single `N`, `N-M`, `N-`, or `N+K` chunk. Throws via {@link ToolError} on invalid bounds. */
|
|
148
|
+
export function parseLineRangeChunk(sel: string): LineRange | null {
|
|
149
|
+
const lineMatch = LINE_RANGE_CHUNK_RE.exec(sel);
|
|
150
|
+
if (!lineMatch) return null;
|
|
151
|
+
const rawStart = Number.parseInt(lineMatch[1]!, 10);
|
|
152
|
+
if (rawStart < 1) {
|
|
153
|
+
throw new ToolError("Line selector 0 is invalid; lines are 1-indexed. Use :1.");
|
|
154
|
+
}
|
|
155
|
+
const sep = lineMatch[2];
|
|
156
|
+
const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
|
|
157
|
+
let rawEnd: number | undefined;
|
|
158
|
+
if (sep === "+") {
|
|
159
|
+
if (rhs === undefined || rhs < 1) {
|
|
160
|
+
throw new ToolError(`Invalid range ${rawStart}+${rhs ?? 0}: count must be >= 1.`);
|
|
161
|
+
}
|
|
162
|
+
rawEnd = rawStart + rhs - 1;
|
|
163
|
+
} else if (sep === "-") {
|
|
164
|
+
// `301-` is shorthand for "from 301 onward" — equivalent to bare `301`.
|
|
165
|
+
if (rhs !== undefined) {
|
|
166
|
+
if (rhs < rawStart) {
|
|
167
|
+
throw new ToolError(`Invalid range ${rawStart}-${rhs}: end must be >= start.`);
|
|
168
|
+
}
|
|
169
|
+
rawEnd = rhs;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { startLine: rawStart, endLine: rawEnd };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Parse a comma-separated list of line ranges (e.g. `5-16,960-973`). Returns
|
|
177
|
+
* the ranges in ascending order with overlapping/adjacent ranges merged so
|
|
178
|
+
* downstream consumers can stream the file in a single forward pass per range.
|
|
179
|
+
*/
|
|
180
|
+
export function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null {
|
|
181
|
+
const chunks = sel.split(",");
|
|
182
|
+
const parsed: LineRange[] = [];
|
|
183
|
+
for (const chunk of chunks) {
|
|
184
|
+
const range = parseLineRangeChunk(chunk);
|
|
185
|
+
if (!range) return null;
|
|
186
|
+
parsed.push(range);
|
|
187
|
+
}
|
|
188
|
+
if (parsed.length === 0) return null;
|
|
189
|
+
parsed.sort((a, b) => a.startLine - b.startLine);
|
|
190
|
+
|
|
191
|
+
const merged: LineRange[] = [parsed[0]];
|
|
192
|
+
for (let i = 1; i < parsed.length; i++) {
|
|
193
|
+
const current = parsed[i];
|
|
194
|
+
const last = merged[merged.length - 1];
|
|
195
|
+
// Open-ended (endLine undefined) means "to EOF" — any later range is absorbed.
|
|
196
|
+
if (last.endLine === undefined) continue;
|
|
197
|
+
// Merge when current starts within (or immediately after) the last range.
|
|
198
|
+
if (current.startLine <= last.endLine + 1) {
|
|
199
|
+
if (current.endLine === undefined || current.endLine > last.endLine) {
|
|
200
|
+
merged[merged.length - 1] = { startLine: last.startLine, endLine: current.endLine };
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
merged.push(current);
|
|
205
|
+
}
|
|
206
|
+
return merged as [LineRange, ...LineRange[]];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Return `true` when `lineNumber` (1-indexed) falls in any of the supplied ranges. */
|
|
210
|
+
export function isLineInRanges(lineNumber: number, ranges: readonly LineRange[]): boolean {
|
|
211
|
+
for (const range of ranges) {
|
|
212
|
+
if (lineNumber < range.startLine) continue;
|
|
213
|
+
if (range.endLine === undefined || lineNumber <= range.endLine) return true;
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
136
217
|
|
|
137
218
|
export function splitPathAndSel(rawPath: string): { path: string; sel?: string } {
|
|
138
219
|
const colon = rawPath.lastIndexOf(":");
|
package/src/tools/read.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { computeFileHash, formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "@oh-my-pi/hashline";
|
|
4
5
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
5
6
|
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
|
6
7
|
import { glob, type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives";
|
|
@@ -8,11 +9,10 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
|
|
8
9
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
9
10
|
import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
|
|
10
11
|
import * as z from "zod/v4";
|
|
11
|
-
import {
|
|
12
|
+
import { getFileSnapshotStore } from "../edit/file-snapshot-store";
|
|
12
13
|
import { normalizeToLF } from "../edit/normalize";
|
|
13
14
|
import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
|
|
14
15
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
15
|
-
import { computeFileHash, formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "../hashline/hash";
|
|
16
16
|
import { InternalUrlRouter } from "../internal-urls";
|
|
17
17
|
import { parseInternalUrl } from "../internal-urls/parse";
|
|
18
18
|
import type { InternalUrl } from "../internal-urls/types";
|
|
@@ -66,6 +66,8 @@ import {
|
|
|
66
66
|
import {
|
|
67
67
|
expandPath,
|
|
68
68
|
formatPathRelativeToCwd,
|
|
69
|
+
type LineRange,
|
|
70
|
+
parseLineRanges,
|
|
69
71
|
resolveReadPath,
|
|
70
72
|
splitInternalUrlSel,
|
|
71
73
|
splitPathAndSel,
|
|
@@ -145,7 +147,7 @@ function recordHashlineSnapshot(
|
|
|
145
147
|
context: HashlineHeaderContext | undefined,
|
|
146
148
|
): void {
|
|
147
149
|
if (!context || !absolutePath || !path.isAbsolute(absolutePath)) return;
|
|
148
|
-
|
|
150
|
+
getFileSnapshotStore(session).recordContiguous(absolutePath, 1, context.fullText.split("\n"), {
|
|
149
151
|
fullText: context.fullText,
|
|
150
152
|
fileHash: context.fileHash,
|
|
151
153
|
});
|
|
@@ -568,16 +570,12 @@ export interface ReadToolDetails {
|
|
|
568
570
|
type ReadParams = ReadToolInput;
|
|
569
571
|
|
|
570
572
|
/** Parsed representation of a path-embedded selector. */
|
|
571
|
-
type LineRange = { startLine: number; endLine: number | undefined };
|
|
572
|
-
|
|
573
573
|
type ParsedSelector =
|
|
574
574
|
| { kind: "none" }
|
|
575
575
|
| { kind: "raw" }
|
|
576
576
|
| { kind: "conflicts" }
|
|
577
577
|
| { kind: "lines"; ranges: [LineRange, ...LineRange[]]; raw?: boolean };
|
|
578
578
|
|
|
579
|
-
const LINE_RANGE_RE = /^L?(\d+)(?:([-+])L?(\d+)?)?$/i;
|
|
580
|
-
|
|
581
579
|
/** Returns true when the selector requested verbatim/raw output (alone or combined with a range). */
|
|
582
580
|
function isRawSelector(parsed: ParsedSelector): boolean {
|
|
583
581
|
return parsed.kind === "raw" || (parsed.kind === "lines" && parsed.raw === true);
|
|
@@ -588,67 +586,6 @@ function isMultiRange(parsed: ParsedSelector): boolean {
|
|
|
588
586
|
return parsed.kind === "lines" && parsed.ranges.length > 1;
|
|
589
587
|
}
|
|
590
588
|
|
|
591
|
-
function parseLineRangeChunk(sel: string): LineRange | null {
|
|
592
|
-
const lineMatch = LINE_RANGE_RE.exec(sel);
|
|
593
|
-
if (!lineMatch) return null;
|
|
594
|
-
const rawStart = Number.parseInt(lineMatch[1]!, 10);
|
|
595
|
-
if (rawStart < 1) {
|
|
596
|
-
throw new ToolError("Line selector 0 is invalid; lines are 1-indexed. Use :1.");
|
|
597
|
-
}
|
|
598
|
-
const sep = lineMatch[2];
|
|
599
|
-
const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
|
|
600
|
-
let rawEnd: number | undefined;
|
|
601
|
-
if (sep === "+") {
|
|
602
|
-
if (rhs === undefined || rhs < 1) {
|
|
603
|
-
throw new ToolError(`Invalid range ${rawStart}+${rhs ?? 0}: count must be >= 1.`);
|
|
604
|
-
}
|
|
605
|
-
rawEnd = rawStart + rhs - 1;
|
|
606
|
-
} else if (sep === "-") {
|
|
607
|
-
// `301-` is shorthand for "from 301 onward" — equivalent to bare `301`.
|
|
608
|
-
if (rhs !== undefined) {
|
|
609
|
-
if (rhs < rawStart) {
|
|
610
|
-
throw new ToolError(`Invalid range ${rawStart}-${rhs}: end must be >= start.`);
|
|
611
|
-
}
|
|
612
|
-
rawEnd = rhs;
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
return { startLine: rawStart, endLine: rawEnd };
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
/**
|
|
619
|
-
* Parse a comma-separated list of line ranges (e.g. `5-16,960-973`). Returns
|
|
620
|
-
* the ranges in ascending order with overlapping/adjacent ranges merged so
|
|
621
|
-
* downstream consumers can stream the file in a single forward pass per range.
|
|
622
|
-
*/
|
|
623
|
-
function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null {
|
|
624
|
-
const chunks = sel.split(",");
|
|
625
|
-
const parsed: LineRange[] = [];
|
|
626
|
-
for (const chunk of chunks) {
|
|
627
|
-
const range = parseLineRangeChunk(chunk);
|
|
628
|
-
if (!range) return null;
|
|
629
|
-
parsed.push(range);
|
|
630
|
-
}
|
|
631
|
-
if (parsed.length === 0) return null;
|
|
632
|
-
parsed.sort((a, b) => a.startLine - b.startLine);
|
|
633
|
-
|
|
634
|
-
const merged: LineRange[] = [parsed[0]];
|
|
635
|
-
for (let i = 1; i < parsed.length; i++) {
|
|
636
|
-
const current = parsed[i];
|
|
637
|
-
const last = merged[merged.length - 1];
|
|
638
|
-
// Open-ended (endLine undefined) means "to EOF" — any later range is absorbed.
|
|
639
|
-
if (last.endLine === undefined) continue;
|
|
640
|
-
// Merge when current starts within (or immediately after) the last range.
|
|
641
|
-
if (current.startLine <= last.endLine + 1) {
|
|
642
|
-
if (current.endLine === undefined || current.endLine > last.endLine) {
|
|
643
|
-
merged[merged.length - 1] = { startLine: last.startLine, endLine: current.endLine };
|
|
644
|
-
}
|
|
645
|
-
continue;
|
|
646
|
-
}
|
|
647
|
-
merged.push(current);
|
|
648
|
-
}
|
|
649
|
-
return merged as [LineRange, ...LineRange[]];
|
|
650
|
-
}
|
|
651
|
-
|
|
652
589
|
function parseSel(sel: string | undefined): ParsedSelector {
|
|
653
590
|
if (!sel || sel.length === 0) return { kind: "none" };
|
|
654
591
|
|
|
@@ -1122,7 +1059,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1122
1059
|
}
|
|
1123
1060
|
|
|
1124
1061
|
if (collectedLines.length > 0) {
|
|
1125
|
-
|
|
1062
|
+
getFileSnapshotStore(this.session).recordContiguous(
|
|
1126
1063
|
absolutePath,
|
|
1127
1064
|
range.startLine,
|
|
1128
1065
|
collectedLines,
|
|
@@ -1406,14 +1343,19 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1406
1343
|
? await bridgePromise.catch(() => Bun.file(absolutePath).text())
|
|
1407
1344
|
: await Bun.file(absolutePath).text();
|
|
1408
1345
|
throwIfAborted(signal);
|
|
1409
|
-
|
|
1346
|
+
const lineCount = countTextLines(code);
|
|
1347
|
+
if (lineCount > MAX_SUMMARY_LINES) return null;
|
|
1348
|
+
if (lineCount < this.session.settings.get("read.summarize.minTotalLines")) return null;
|
|
1410
1349
|
|
|
1411
|
-
|
|
1350
|
+
const result = summarizeCode({
|
|
1412
1351
|
code,
|
|
1413
1352
|
path: absolutePath,
|
|
1414
1353
|
minBodyLines: this.session.settings.get("read.summarize.minBodyLines"),
|
|
1415
1354
|
minCommentLines: this.session.settings.get("read.summarize.minCommentLines"),
|
|
1355
|
+
unfoldUntilLines: this.session.settings.get("read.summarize.unfoldUntil"),
|
|
1356
|
+
unfoldLimitLines: this.session.settings.get("read.summarize.unfoldLimit"),
|
|
1416
1357
|
});
|
|
1358
|
+
return result;
|
|
1417
1359
|
} catch {
|
|
1418
1360
|
return null;
|
|
1419
1361
|
}
|
|
@@ -1921,7 +1863,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1921
1863
|
: undefined;
|
|
1922
1864
|
|
|
1923
1865
|
if (collectedLines.length > 0 && !firstLineExceedsLimit) {
|
|
1924
|
-
|
|
1866
|
+
getFileSnapshotStore(this.session).recordContiguous(
|
|
1925
1867
|
absolutePath,
|
|
1926
1868
|
startLineDisplay,
|
|
1927
1869
|
collectedLines,
|
package/src/tools/search.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { computeFileHash, formatHashlineHeader } from "@oh-my-pi/hashline";
|
|
4
5
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
5
6
|
import { type GrepMatch, GrepOutputMode, type GrepResult, grep } from "@oh-my-pi/pi-natives";
|
|
6
7
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
7
8
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
8
9
|
import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
|
9
10
|
import * as z from "zod/v4";
|
|
10
|
-
import {
|
|
11
|
+
import { getFileSnapshotStore } from "../edit/file-snapshot-store";
|
|
11
12
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
12
|
-
import { computeFileHash, formatHashlineHeader } from "../hashline/hash";
|
|
13
13
|
import type { Theme } from "../modes/theme/theme";
|
|
14
14
|
import searchDescription from "../prompts/tools/search.md" with { type: "text" };
|
|
15
15
|
import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output";
|
|
@@ -26,7 +26,15 @@ import { createFileRecorder, formatResultPath } from "./file-recorder";
|
|
|
26
26
|
import { formatGroupedFiles } from "./grouped-file-output";
|
|
27
27
|
import { formatMatchLine } from "./match-line-format";
|
|
28
28
|
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
hasGlobPathChars,
|
|
31
|
+
isLineInRanges,
|
|
32
|
+
type LineRange,
|
|
33
|
+
parseLineRanges,
|
|
34
|
+
resolveReadPath,
|
|
35
|
+
resolveToolSearchScope,
|
|
36
|
+
splitPathAndSel,
|
|
37
|
+
} from "./path-utils";
|
|
30
38
|
import {
|
|
31
39
|
createCachedComponent,
|
|
32
40
|
formatCodeFrameLine,
|
|
@@ -39,13 +47,19 @@ import {
|
|
|
39
47
|
import { ToolError } from "./tool-errors";
|
|
40
48
|
import { toolResult } from "./tool-result";
|
|
41
49
|
|
|
42
|
-
const searchPathEntrySchema = z
|
|
50
|
+
const searchPathEntrySchema = z
|
|
51
|
+
.string()
|
|
52
|
+
.describe(
|
|
53
|
+
'file, directory, glob, internal URL, or "<file>:<lines>" selector (e.g. "src/foo.ts:50-100", "src/foo.ts:50+10", "src/foo.ts:50-100,200-300")',
|
|
54
|
+
);
|
|
43
55
|
const searchSchema = z
|
|
44
56
|
.object({
|
|
45
57
|
pattern: z.string().describe("regex pattern"),
|
|
46
58
|
paths: z
|
|
47
59
|
.union([searchPathEntrySchema, z.array(searchPathEntrySchema).min(1)])
|
|
48
|
-
.describe(
|
|
60
|
+
.describe(
|
|
61
|
+
"file, directory, glob, internal URL, or array of those to search; append `:<lines>` to scope a file to specific line ranges",
|
|
62
|
+
),
|
|
49
63
|
i: z.boolean().optional().describe("case-insensitive search"),
|
|
50
64
|
gitignore: z.boolean().optional().describe("respect gitignore"),
|
|
51
65
|
skip: z
|
|
@@ -98,6 +112,61 @@ function containsTopLevelComma(entry: string): boolean {
|
|
|
98
112
|
return false;
|
|
99
113
|
}
|
|
100
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Parsed `paths` entry — a path (possibly archive-shaped) plus an optional
|
|
117
|
+
* line-range selector peeled off the trailing `:N-M` (or `:N+K`, `:N,M`, …)
|
|
118
|
+
* chunk via {@link splitPathAndSel}.
|
|
119
|
+
*/
|
|
120
|
+
interface SearchPathSpec {
|
|
121
|
+
original: string;
|
|
122
|
+
clean: string;
|
|
123
|
+
ranges?: [LineRange, ...LineRange[]];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parsePathSpecs(rawEntries: readonly string[]): SearchPathSpec[] {
|
|
127
|
+
const specs: SearchPathSpec[] = [];
|
|
128
|
+
for (const entry of rawEntries) {
|
|
129
|
+
const split = splitPathAndSel(entry);
|
|
130
|
+
let clean = entry;
|
|
131
|
+
let ranges: [LineRange, ...LineRange[]] | undefined;
|
|
132
|
+
if (split.sel) {
|
|
133
|
+
const parsed = parseLineRanges(split.sel);
|
|
134
|
+
if (!parsed) {
|
|
135
|
+
throw new ToolError(
|
|
136
|
+
`paths entry "${entry}" — only line-range selectors like ":50-100" are supported (no ":raw"/":conflicts")`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (hasGlobPathChars(split.path)) {
|
|
140
|
+
throw new ToolError(`Line-range selector requires a single file, not a glob: ${entry}`);
|
|
141
|
+
}
|
|
142
|
+
clean = split.path;
|
|
143
|
+
ranges = parsed;
|
|
144
|
+
}
|
|
145
|
+
if (containsTopLevelComma(clean)) {
|
|
146
|
+
throw new ToolError('paths is an array — pass ["a", "b"] not ["a,b"]');
|
|
147
|
+
}
|
|
148
|
+
specs.push({ original: entry, clean, ranges });
|
|
149
|
+
}
|
|
150
|
+
return specs;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function mergeRangesInto(map: Map<string, LineRange[]>, absKey: string, ranges: readonly LineRange[]): void {
|
|
154
|
+
// Concat-without-merge is correct: `isLineInRanges` scans linearly, so
|
|
155
|
+
// duplicates/overlaps only cost a few extra comparisons per match.
|
|
156
|
+
const existing = map.get(absKey);
|
|
157
|
+
if (existing) {
|
|
158
|
+
existing.push(...ranges);
|
|
159
|
+
} else {
|
|
160
|
+
map.set(absKey, [...ranges]);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function matchAbsolutePath(matchPath: string, searchPath: string): string {
|
|
165
|
+
if (matchPath === "") return searchPath;
|
|
166
|
+
if (path.isAbsolute(matchPath)) return matchPath;
|
|
167
|
+
return path.resolve(searchPath, matchPath);
|
|
168
|
+
}
|
|
169
|
+
|
|
101
170
|
/**
|
|
102
171
|
* Pre-resolve any `paths` entries that point at a member inside an archive
|
|
103
172
|
* (e.g. `bundle.zip:src/foo.ts`, `release.tar.gz:notes.md`). Native grep
|
|
@@ -253,12 +322,9 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
253
322
|
if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) {
|
|
254
323
|
throw new ToolError("Skip must be a non-negative number");
|
|
255
324
|
}
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
throw new ToolError('paths is an array — pass ["a", "b"] not ["a,b"]');
|
|
260
|
-
}
|
|
261
|
-
}
|
|
325
|
+
const rawEntries = toPathList(rawPaths);
|
|
326
|
+
const pathSpecs = parsePathSpecs(rawEntries);
|
|
327
|
+
const paths = pathSpecs.map(spec => spec.clean);
|
|
262
328
|
const {
|
|
263
329
|
resolvedPaths,
|
|
264
330
|
displayMap: archiveDisplayMap,
|
|
@@ -267,6 +333,33 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
267
333
|
cleanup: cleanupArchiveScratch,
|
|
268
334
|
} = await resolveArchiveSearchPaths(paths, this.session.cwd);
|
|
269
335
|
try {
|
|
336
|
+
// Build the per-file line-range filter (keyed by absolute path) now that
|
|
337
|
+
// archive entries have been materialized to scratch files. Plain entries
|
|
338
|
+
// resolve through `resolveReadPath`; archive entries are keyed by the
|
|
339
|
+
// scratch path that grep will actually report against.
|
|
340
|
+
const rangesByAbsPath = new Map<string, LineRange[]>();
|
|
341
|
+
for (let idx = 0; idx < pathSpecs.length; idx++) {
|
|
342
|
+
const spec = pathSpecs[idx];
|
|
343
|
+
if (!spec.ranges) continue;
|
|
344
|
+
const resolved = resolvedPaths[idx];
|
|
345
|
+
if (resolved === spec.clean && !archiveDisplayMap.has(resolved)) {
|
|
346
|
+
// Non-archive entry; ensure the cleaned path resolves to a regular file.
|
|
347
|
+
const absKey = path.resolve(resolveReadPath(spec.clean, this.session.cwd));
|
|
348
|
+
const stats = await stat(absKey).catch(() => null);
|
|
349
|
+
if (!stats) {
|
|
350
|
+
throw new ToolError(`Path not found for line-range selector: ${spec.original}`);
|
|
351
|
+
}
|
|
352
|
+
if (!stats.isFile()) {
|
|
353
|
+
throw new ToolError(`Line-range selector requires a single file: ${spec.original} is a directory`);
|
|
354
|
+
}
|
|
355
|
+
mergeRangesInto(rangesByAbsPath, absKey, spec.ranges);
|
|
356
|
+
} else {
|
|
357
|
+
// Archive entry — `resolveArchiveSearchPaths` substituted a scratch path.
|
|
358
|
+
const absKey = path.resolve(resolved);
|
|
359
|
+
mergeRangesInto(rangesByAbsPath, absKey, spec.ranges);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
270
363
|
if (archiveUnreadable.length > 0 && resolvedPaths.length === archiveUnreadable.length) {
|
|
271
364
|
// All inputs were archive selectors we couldn't materialize; surface the
|
|
272
365
|
// reason instead of a downstream "path not found" from the scope resolver.
|
|
@@ -390,12 +483,38 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
390
483
|
}
|
|
391
484
|
throw err;
|
|
392
485
|
}
|
|
486
|
+
if (rangesByAbsPath.size > 0) {
|
|
487
|
+
const filteredMatches: GrepMatch[] = [];
|
|
488
|
+
for (const match of result.matches) {
|
|
489
|
+
const abs = matchAbsolutePath(match.path, searchPath);
|
|
490
|
+
const ranges = rangesByAbsPath.get(abs);
|
|
491
|
+
if (!ranges) {
|
|
492
|
+
// Path has no line-range constraint (e.g. a peer entry without `:N-M`).
|
|
493
|
+
filteredMatches.push(match);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (!isLineInRanges(match.lineNumber, ranges)) continue;
|
|
497
|
+
// Drop context lines that fall outside the allowed ranges; they would
|
|
498
|
+
// otherwise leak content the caller explicitly excluded.
|
|
499
|
+
const trimBefore = match.contextBefore?.filter(c => isLineInRanges(c.lineNumber, ranges));
|
|
500
|
+
const trimAfter = match.contextAfter?.filter(c => isLineInRanges(c.lineNumber, ranges));
|
|
501
|
+
filteredMatches.push({
|
|
502
|
+
...match,
|
|
503
|
+
contextBefore: trimBefore && trimBefore.length > 0 ? trimBefore : undefined,
|
|
504
|
+
contextAfter: trimAfter && trimAfter.length > 0 ? trimAfter : undefined,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
result = {
|
|
508
|
+
matches: filteredMatches,
|
|
509
|
+
totalMatches: filteredMatches.length,
|
|
510
|
+
filesWithMatches: new Set(filteredMatches.map(match => match.path)).size,
|
|
511
|
+
filesSearched: result.filesSearched,
|
|
512
|
+
limitReached: result.limitReached,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
393
515
|
if (archiveDisplayMap.size > 0) {
|
|
394
516
|
for (const match of result.matches) {
|
|
395
|
-
|
|
396
|
-
if (match.path === "") abs = searchPath;
|
|
397
|
-
else if (path.isAbsolute(match.path)) abs = match.path;
|
|
398
|
-
else abs = path.resolve(searchPath, match.path);
|
|
517
|
+
const abs = matchAbsolutePath(match.path, searchPath);
|
|
399
518
|
const display = archiveDisplayMap.get(abs);
|
|
400
519
|
if (display) match.path = display;
|
|
401
520
|
}
|
|
@@ -552,7 +671,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
552
671
|
fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
|
|
553
672
|
}
|
|
554
673
|
if (cacheEntries.length > 0 && hashContext) {
|
|
555
|
-
|
|
674
|
+
getFileSnapshotStore(this.session).recordSparse(hashContext.absolutePath, cacheEntries, {
|
|
556
675
|
fileHash: hashContext.fileHash,
|
|
557
676
|
});
|
|
558
677
|
}
|
package/src/tools/write.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { stripHashlinePrefixes } from "@oh-my-pi/hashline";
|
|
4
5
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
5
6
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
6
7
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
7
8
|
import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
|
8
9
|
import * as z from "zod/v4";
|
|
9
|
-
import { stripHashlinePrefixes } from "../edit";
|
|
10
10
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
11
11
|
import { InternalUrlRouter } from "../internal-urls";
|
|
12
12
|
import { parseInternalUrl } from "../internal-urls/parse";
|
|
@@ -487,7 +487,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
|
|
487
487
|
const batchRequest = getLspBatchRequest(context?.toolCall);
|
|
488
488
|
const diagnostics = await this.#writethrough(absolutePath, newContent, signal, undefined, batchRequest);
|
|
489
489
|
invalidateFsScanAfterWrite(absolutePath);
|
|
490
|
-
this.session.
|
|
490
|
+
this.session.fileSnapshotStore?.invalidate(absolutePath);
|
|
491
491
|
this.session.conflictHistory?.invalidate(entry.id);
|
|
492
492
|
|
|
493
493
|
const range =
|
|
@@ -609,7 +609,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
|
|
609
609
|
|
|
610
610
|
const diagnostics = await this.#writethrough(absolutePath, text, signal, undefined, batchRequest);
|
|
611
611
|
invalidateFsScanAfterWrite(absolutePath);
|
|
612
|
-
this.session.
|
|
612
|
+
this.session.fileSnapshotStore?.invalidate(absolutePath);
|
|
613
613
|
for (const entry of fileEntries) history.invalidate(entry.id);
|
|
614
614
|
succeededFiles.push({ displayPath: sample.displayPath, count: fileEntries.length });
|
|
615
615
|
totalResolvedIds += fileEntries.length;
|
package/src/utils/changelog.ts
CHANGED
|
@@ -8,10 +8,18 @@ export interface ChangelogEntry {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Parse changelog entries from
|
|
12
|
-
*
|
|
11
|
+
* Parse changelog entries from the file at `changelogPath`. Scans for `## [x.y.z]`
|
|
12
|
+
* headings and collects each block until the next heading or EOF.
|
|
13
|
+
*
|
|
14
|
+
* Returns `[]` when `changelogPath` is `undefined` (package directory not
|
|
15
|
+
* resolvable — see `getChangelogPath`) or the file is missing. Callers MUST NOT
|
|
16
|
+
* synthesize a fallback path from the host project's cwd; doing so caused issue
|
|
17
|
+
* #1423 (the host project's `CHANGELOG.md` was rendered as omp's).
|
|
13
18
|
*/
|
|
14
|
-
export async function parseChangelog(changelogPath: string): Promise<ChangelogEntry[]> {
|
|
19
|
+
export async function parseChangelog(changelogPath: string | undefined): Promise<ChangelogEntry[]> {
|
|
20
|
+
if (!changelogPath) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
15
23
|
try {
|
|
16
24
|
const content = await Bun.file(changelogPath).text();
|
|
17
25
|
const lines = content.split("\n");
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from "node:fs/promises";
|
|
9
9
|
import path from "node:path";
|
|
10
|
+
import { computeFileHash, formatHashlineHeader, formatNumberedLines } from "@oh-my-pi/hashline";
|
|
10
11
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
11
12
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
12
13
|
import { glob } from "@oh-my-pi/pi-natives";
|
|
13
14
|
import { fuzzyMatch } from "@oh-my-pi/pi-tui";
|
|
14
15
|
import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
|
|
15
|
-
import { computeFileHash, formatHashlineHeader, formatNumberedLines } from "../hashline/hash";
|
|
16
16
|
import type { FileMentionMessage } from "../session/messages";
|
|
17
17
|
import {
|
|
18
18
|
DEFAULT_MAX_BYTES,
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import type { ToolSession } from "../tools";
|
|
2
|
-
export interface FileReadSnapshot {
|
|
3
|
-
/** 1-indexed line number → exact line content as observed by `read`/`search`. */
|
|
4
|
-
lines: Map<number, string>;
|
|
5
|
-
/** Full normalized text when the read path observed the whole file. */
|
|
6
|
-
fullText?: string;
|
|
7
|
-
/** 4-hex hash of `fullText`, or a sparse snapshot hash supplied by search. */
|
|
8
|
-
fileHash?: string;
|
|
9
|
-
recordedAt: number;
|
|
10
|
-
}
|
|
11
|
-
interface FileReadSnapshotMetadata {
|
|
12
|
-
fullText?: string;
|
|
13
|
-
fileHash?: string;
|
|
14
|
-
}
|
|
15
|
-
export declare class FileReadCache {
|
|
16
|
-
#private;
|
|
17
|
-
/** Look up the most recent snapshot for `absPath`, or `null` if absent. */
|
|
18
|
-
get(absPath: string): FileReadSnapshot | null;
|
|
19
|
-
/** Look up the most recent snapshot for `absPath` whose file hash matches. */
|
|
20
|
-
getByHash(absPath: string, fileHash: string): FileReadSnapshot | null;
|
|
21
|
-
/** Record a contiguous run of lines (e.g. from a `read` tool). `startLine` is 1-indexed. */
|
|
22
|
-
recordContiguous(absPath: string, startLine: number, lines: readonly string[], metadata?: FileReadSnapshotMetadata): void;
|
|
23
|
-
/** Record sparse `(lineNumber, content)` pairs (e.g. `search` matches plus context). */
|
|
24
|
-
recordSparse(absPath: string, entries: Iterable<readonly [number, string]>, metadata?: FileReadSnapshotMetadata): void;
|
|
25
|
-
/** Drop the snapshot history for a single path. */
|
|
26
|
-
invalidate(absPath: string): void;
|
|
27
|
-
/** Drop every snapshot history. */
|
|
28
|
-
clear(): void;
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Look up (or lazily create) the file-read cache attached to a session. The
|
|
32
|
-
* cache is stored as `session.fileReadCache` so it lives exactly as long as
|
|
33
|
-
* the session itself.
|
|
34
|
-
*/
|
|
35
|
-
export declare function getFileReadCache(session: ToolSession): FileReadCache;
|
|
36
|
-
export {};
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
export declare function formatFullAnchorRequirement(raw?: string): string;
|
|
2
|
-
export declare function parseTag(ref: string): {
|
|
3
|
-
line: number;
|
|
4
|
-
};
|
|
5
|
-
export interface HashlineMismatchDetails {
|
|
6
|
-
path?: string;
|
|
7
|
-
expectedFileHash: string;
|
|
8
|
-
actualFileHash: string;
|
|
9
|
-
fileLines: string[];
|
|
10
|
-
anchorLines?: readonly number[];
|
|
11
|
-
}
|
|
12
|
-
export declare class HashlineMismatchError extends Error {
|
|
13
|
-
readonly path: string | undefined;
|
|
14
|
-
readonly expectedFileHash: string;
|
|
15
|
-
readonly actualFileHash: string;
|
|
16
|
-
readonly fileLines: string[];
|
|
17
|
-
readonly anchorLines: readonly number[];
|
|
18
|
-
constructor(details: HashlineMismatchDetails);
|
|
19
|
-
get displayMessage(): string;
|
|
20
|
-
static rejectionHeader(details: HashlineMismatchDetails): string[];
|
|
21
|
-
static formatDisplayMessage(details: HashlineMismatchDetails): string;
|
|
22
|
-
static formatMessage(details: HashlineMismatchDetails): string;
|
|
23
|
-
}
|
|
24
|
-
export declare function validateLineRef(ref: {
|
|
25
|
-
line: number;
|
|
26
|
-
}, fileLines: string[]): void;
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import type { HashlineApplyOptions, HashlineEdit } from "./types";
|
|
2
|
-
export interface HashlineApplyResult {
|
|
3
|
-
lines: string;
|
|
4
|
-
firstChangedLine?: number;
|
|
5
|
-
warnings?: string[];
|
|
6
|
-
noopEdits?: HashlineNoopEdit[];
|
|
7
|
-
}
|
|
8
|
-
export interface HashlineNoopEdit {
|
|
9
|
-
editIndex: number;
|
|
10
|
-
loc: string;
|
|
11
|
-
reason: string;
|
|
12
|
-
current: string;
|
|
13
|
-
}
|
|
14
|
-
export declare function applyHashlineEdits(text: string, edits: HashlineEdit[], options?: HashlineApplyOptions): HashlineApplyResult;
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
/** Lines of context shown either side of a hash mismatch. */
|
|
2
|
-
export declare const MISMATCH_CONTEXT = 2;
|
|
3
|
-
/** Optional patch envelope start marker; silently consumed when present. */
|
|
4
|
-
export declare const BEGIN_PATCH_MARKER = "*** Begin Patch";
|
|
5
|
-
/** Optional patch envelope end marker; terminates parsing when encountered. */
|
|
6
|
-
export declare const END_PATCH_MARKER = "*** End Patch";
|
|
7
|
-
/**
|
|
8
|
-
* Recovery sentinel emitted by the agent loop when a contaminated
|
|
9
|
-
* `to=functions.edit` stream is truncated mid-call (see
|
|
10
|
-
* `docs/ERRATA-GPT5-HARMONY.md`). Behaves like `END_PATCH_MARKER` for
|
|
11
|
-
* parsing — terminates the line loop — and additionally surfaces a
|
|
12
|
-
* warning in the tool result so the model knows to re-issue any
|
|
13
|
-
* remaining edits.
|
|
14
|
-
*/
|
|
15
|
-
export declare const ABORT_MARKER = "*** Abort";
|
|
16
|
-
/** Warning text appended to the tool result when ABORT_MARKER terminates parsing. */
|
|
17
|
-
export declare const ABORT_WARNING = "Tool stream truncated mid-call due to detected output corruption. Applied ops above are valid. Re-issue any remaining edits.";
|
|
18
|
-
/**
|
|
19
|
-
* Warning text appended when two consecutive `A-B:` ops on the exact same
|
|
20
|
-
* range get coalesced (model painted a before/after pair). The second op
|
|
21
|
-
* wins; the first op's payload is silently discarded.
|
|
22
|
-
*/
|
|
23
|
-
export declare const REPLACE_PAIR_COALESCED_WARNING = "Detected an identical-range before/after replace pair; kept only the second block's payload. Issue ONE op per range \u2014 the payload is the final desired content, never both old and new.";
|
|
24
|
-
/**
|
|
25
|
-
* Warning text appended when un-prefixed continuation lines are accepted as
|
|
26
|
-
* implicit payload (lenient legacy behavior). The model authored a multi-line
|
|
27
|
-
* replace without `+` prefixes; the parser accepted it because the lines did
|
|
28
|
-
* not classify as ops/headers/payloads, but the canonical syntax requires `+`
|
|
29
|
-
* on every continuation line after the op.
|
|
30
|
-
*/
|
|
31
|
-
export declare const IMPLICIT_CONTINUATION_WARNING = "Accepted continuation line(s) without the `+` prefix as implicit payload. Canonical syntax is `A-B:` followed by `+` on every continuation row; without `+`, lines that look like ops will be parsed as new ops instead of payload. Prefer the explicit form.";
|
|
32
|
-
/**
|
|
33
|
-
* Warning text appended when an inner `LINE:TEXT` (or sub-range `A-B:TEXT`)
|
|
34
|
-
* op arrives while an outer `A-B:` replace is still pending and the inner
|
|
35
|
-
* anchor falls inside the outer range. The model used the read-output
|
|
36
|
-
* `LINE:TEXT` format as if it were a payload-continuation line; we strip the
|
|
37
|
-
* `LINE:` prefix and append the body to the pending payload, but warn so the
|
|
38
|
-
* canonical `+`-continuation form remains preferred.
|
|
39
|
-
*/
|
|
40
|
-
export declare const PAYLOAD_LINE_PREFIX_DEMOTED_WARNING = "Detected one or more `LINE:TEXT` lines whose anchors fell inside a pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside a multi-line `A-B:` block, payload lines after the first should be prefixed with `+` \u2014 never reuse the read-output gutter format.";
|