@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/types/config/settings-schema.d.ts +27 -0
  3. package/dist/types/config.d.ts +31 -5
  4. package/dist/types/edit/file-snapshot-store.d.ts +18 -0
  5. package/dist/types/edit/hashline/diff.d.ts +30 -0
  6. package/dist/types/edit/hashline/execute.d.ts +29 -0
  7. package/dist/types/edit/hashline/filesystem.d.ts +57 -0
  8. package/dist/types/edit/hashline/index.d.ts +4 -0
  9. package/dist/types/edit/hashline/params.d.ts +12 -0
  10. package/dist/types/edit/index.d.ts +4 -3
  11. package/dist/types/edit/normalize.d.ts +4 -16
  12. package/dist/types/index.d.ts +0 -1
  13. package/dist/types/tools/bash.d.ts +1 -0
  14. package/dist/types/tools/index.d.ts +6 -5
  15. package/dist/types/tools/path-utils.d.ts +18 -0
  16. package/dist/types/utils/changelog.d.ts +8 -3
  17. package/package.json +8 -15
  18. package/src/config/settings-schema.ts +32 -0
  19. package/src/config.ts +42 -15
  20. package/src/edit/file-snapshot-store.ts +22 -0
  21. package/src/edit/hashline/diff.ts +88 -0
  22. package/src/edit/hashline/execute.ts +188 -0
  23. package/src/edit/hashline/filesystem.ts +129 -0
  24. package/src/edit/hashline/index.ts +4 -0
  25. package/src/edit/hashline/params.ts +11 -0
  26. package/src/edit/index.ts +7 -15
  27. package/src/edit/normalize.ts +11 -41
  28. package/src/edit/renderer.ts +1 -1
  29. package/src/edit/streaming.ts +8 -9
  30. package/src/index.ts +0 -1
  31. package/src/internal-urls/docs-index.generated.ts +1 -1
  32. package/src/sdk.ts +8 -1
  33. package/src/tools/ast-edit.ts +1 -1
  34. package/src/tools/ast-grep.ts +3 -3
  35. package/src/tools/bash.ts +74 -10
  36. package/src/tools/index.ts +6 -5
  37. package/src/tools/path-utils.ts +81 -0
  38. package/src/tools/read.ts +14 -72
  39. package/src/tools/search.ts +136 -17
  40. package/src/tools/write.ts +3 -3
  41. package/src/utils/changelog.ts +11 -3
  42. package/src/utils/file-mentions.ts +1 -1
  43. package/dist/types/edit/file-read-cache.d.ts +0 -36
  44. package/dist/types/hashline/anchors.d.ts +0 -26
  45. package/dist/types/hashline/apply.d.ts +0 -14
  46. package/dist/types/hashline/constants.d.ts +0 -40
  47. package/dist/types/hashline/diff-preview.d.ts +0 -2
  48. package/dist/types/hashline/diff.d.ts +0 -16
  49. package/dist/types/hashline/execute.d.ts +0 -4
  50. package/dist/types/hashline/executor.d.ts +0 -56
  51. package/dist/types/hashline/hash.d.ts +0 -76
  52. package/dist/types/hashline/index.d.ts +0 -14
  53. package/dist/types/hashline/input.d.ts +0 -4
  54. package/dist/types/hashline/prefixes.d.ts +0 -7
  55. package/dist/types/hashline/recovery.d.ts +0 -21
  56. package/dist/types/hashline/stream.d.ts +0 -2
  57. package/dist/types/hashline/tokenizer.d.ts +0 -94
  58. package/dist/types/hashline/types.d.ts +0 -75
  59. package/src/edit/file-read-cache.ts +0 -138
  60. package/src/hashline/anchors.ts +0 -104
  61. package/src/hashline/apply.ts +0 -790
  62. package/src/hashline/bigrams.json +0 -649
  63. package/src/hashline/constants.ts +0 -51
  64. package/src/hashline/diff-preview.ts +0 -42
  65. package/src/hashline/diff.ts +0 -82
  66. package/src/hashline/execute.ts +0 -334
  67. package/src/hashline/executor.ts +0 -334
  68. package/src/hashline/grammar.lark +0 -23
  69. package/src/hashline/hash.ts +0 -131
  70. package/src/hashline/index.ts +0 -14
  71. package/src/hashline/input.ts +0 -137
  72. package/src/hashline/prefixes.ts +0 -111
  73. package/src/hashline/recovery.ts +0 -139
  74. package/src/hashline/stream.ts +0 -123
  75. package/src/hashline/tokenizer.ts +0 -473
  76. package/src/hashline/types.ts +0 -66
  77. package/src/prompts/tools/hashline.md +0 -63
@@ -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 { getFileReadCache } from "../edit/file-read-cache";
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
- getFileReadCache(session).recordContiguous(absolutePath, 1, context.fullText.split("\n"), {
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
- getFileReadCache(this.session).recordContiguous(
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
- if (countTextLines(code) > MAX_SUMMARY_LINES) return null;
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
- return summarizeCode({
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
- getFileReadCache(this.session).recordContiguous(
1866
+ getFileSnapshotStore(this.session).recordContiguous(
1925
1867
  absolutePath,
1926
1868
  startLineDisplay,
1927
1869
  collectedLines,
@@ -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 { getFileReadCache } from "../edit/file-read-cache";
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 { resolveReadPath, resolveToolSearchScope } from "./path-utils";
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.string().describe("file, directory, glob, or internal URL to search");
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("file, directory, glob, internal URL, or array of those to search"),
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 paths = toPathList(rawPaths);
257
- for (const entry of paths) {
258
- if (containsTopLevelComma(entry)) {
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
- let abs: string;
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
- getFileReadCache(this.session).recordSparse(hashContext.absolutePath, cacheEntries, {
674
+ getFileSnapshotStore(this.session).recordSparse(hashContext.absolutePath, cacheEntries, {
556
675
  fileHash: hashContext.fileHash,
557
676
  });
558
677
  }
@@ -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.fileReadCache?.invalidate(absolutePath);
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.fileReadCache?.invalidate(absolutePath);
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;
@@ -8,10 +8,18 @@ export interface ChangelogEntry {
8
8
  }
9
9
 
10
10
  /**
11
- * Parse changelog entries from CHANGELOG.md
12
- * Scans for ## lines and collects content until next ## or EOF
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.";
@@ -1,2 +0,0 @@
1
- import type { CompactHashlineDiffOptions, CompactHashlineDiffPreview } from "./types";
2
- export declare function buildCompactHashlineDiffPreview(diff: string, _options?: CompactHashlineDiffOptions): CompactHashlineDiffPreview;