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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/examples/hooks/handoff.ts +1 -1
  3. package/examples/hooks/qna.ts +1 -1
  4. package/examples/sdk/03-custom-prompt.ts +7 -4
  5. package/examples/sdk/README.md +1 -1
  6. package/package.json +12 -12
  7. package/src/autoresearch/index.ts +48 -44
  8. package/src/cli/grep-cli.ts +1 -1
  9. package/src/cli/read-cli.ts +58 -0
  10. package/src/cli.ts +1 -0
  11. package/src/commands/read.ts +40 -0
  12. package/src/commit/agentic/agent.ts +1 -1
  13. package/src/commit/analysis/conventional.ts +1 -1
  14. package/src/commit/analysis/summary.ts +1 -1
  15. package/src/commit/changelog/generate.ts +1 -1
  16. package/src/commit/map-reduce/map-phase.ts +1 -1
  17. package/src/commit/map-reduce/reduce-phase.ts +1 -1
  18. package/src/config/settings-schema.ts +49 -0
  19. package/src/config/settings.ts +71 -1
  20. package/src/dap/client.ts +1 -0
  21. package/src/discovery/builtin.ts +34 -9
  22. package/src/edit/line-hash.ts +34 -4
  23. package/src/edit/modes/hashline.ts +352 -8
  24. package/src/edit/streaming.ts +4 -1
  25. package/src/export/html/index.ts +1 -1
  26. package/src/extensibility/extensions/runner.ts +3 -3
  27. package/src/extensibility/extensions/types.ts +4 -4
  28. package/src/internal-urls/docs-index.generated.ts +1 -1
  29. package/src/main.ts +13 -18
  30. package/src/memories/index.ts +1 -1
  31. package/src/modes/components/agent-dashboard.ts +1 -1
  32. package/src/modes/components/read-tool-group.ts +4 -9
  33. package/src/modes/components/tool-execution.ts +4 -0
  34. package/src/modes/controllers/event-controller.ts +2 -0
  35. package/src/modes/interactive-mode.ts +19 -12
  36. package/src/modes/rpc/rpc-types.ts +1 -1
  37. package/src/modes/utils/context-usage.ts +12 -5
  38. package/src/modes/utils/ui-helpers.ts +1 -0
  39. package/src/prompts/system/plan-mode-active.md +7 -3
  40. package/src/prompts/system/plan-mode-approved.md +5 -0
  41. package/src/prompts/system/project-prompt.md +36 -0
  42. package/src/prompts/system/system-prompt.md +0 -29
  43. package/src/prompts/tools/github.md +1 -0
  44. package/src/prompts/tools/read.md +15 -14
  45. package/src/sdk.ts +29 -28
  46. package/src/session/agent-session.ts +20 -12
  47. package/src/session/compaction/branch-summarization.ts +1 -1
  48. package/src/session/compaction/compaction.ts +3 -3
  49. package/src/session/session-dump-format.ts +10 -5
  50. package/src/session/streaming-output.ts +1 -1
  51. package/src/slash-commands/builtin-registry.ts +2 -2
  52. package/src/system-prompt.ts +35 -3
  53. package/src/task/executor.ts +4 -3
  54. package/src/task/isolation-backend.ts +22 -0
  55. package/src/tools/fetch.ts +4 -4
  56. package/src/tools/gh.ts +187 -0
  57. package/src/tools/inspect-image.ts +1 -1
  58. package/src/tools/output-meta.ts +1 -1
  59. package/src/tools/path-utils.ts +11 -0
  60. package/src/tools/read.ts +393 -204
  61. package/src/tools/search.ts +1 -1
  62. package/src/tools/sqlite-reader.ts +1 -1
  63. package/src/utils/commit-message-generator.ts +1 -1
  64. package/src/utils/title-generator.ts +1 -1
  65. package/src/web/search/providers/anthropic.ts +1 -1
  66. package/src/workspace-tree.ts +396 -0
package/src/tools/read.ts CHANGED
@@ -3,12 +3,12 @@ import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
5
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
6
- import { glob } from "@oh-my-pi/pi-natives";
6
+ import { glob, type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives";
7
7
  import type { Component } from "@oh-my-pi/pi-tui";
8
8
  import { Text } from "@oh-my-pi/pi-tui";
9
9
  import { getRemoteDir, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
10
10
  import { type Static, Type } from "@sinclair/typebox";
11
- import { formatHashLines } from "../edit/line-hash";
11
+ import { formatHashLine, formatHashLines, formatLineHash, HL_BODY_SEP } from "../edit/line-hash";
12
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
13
  import { parseInternalUrl } from "../internal-urls/parse";
14
14
  import type { InternalUrl } from "../internal-urls/types";
@@ -28,6 +28,7 @@ import { CachedOutputBlock } from "../tui/output-block";
28
28
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
29
29
  import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading";
30
30
  import { convertFileWithMarkit } from "../utils/markit";
31
+ import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree";
31
32
  import { type ArchiveReader, openArchive, parseArchivePathCandidates } from "./archive-reader";
32
33
  import {
33
34
  executeReadUrl,
@@ -40,8 +41,8 @@ import {
40
41
  } from "./fetch";
41
42
  import { applyListLimit } from "./list-limit";
42
43
  import { formatFullOutputReference, formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
43
- import { expandPath, formatPathRelativeToCwd, resolveReadPath } from "./path-utils";
44
- import { formatAge, formatBytes, shortenPath, wrapBrackets } from "./render-utils";
44
+ import { expandPath, formatPathRelativeToCwd, resolveReadPath, splitPathAndSel } from "./path-utils";
45
+ import { formatBytes, shortenPath, wrapBrackets } from "./render-utils";
45
46
  import {
46
47
  executeReadQuery,
47
48
  getRowByKey,
@@ -64,9 +65,26 @@ import { toolResult } from "./tool-result";
64
65
  // Document types converted to markdown via markit.
65
66
  const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]);
66
67
 
68
+ const MAX_SUMMARY_BYTES = 2 * 1024 * 1024;
69
+ const MAX_SUMMARY_LINES = 20_000;
70
+ const PROSE_SUMMARY_EXTENSIONS = new Set([".md", ".txt"]);
67
71
  // Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs
68
72
  const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep;
69
73
 
74
+ const READ_DIRECTORY_EXCLUDED_DIRS = new Set([
75
+ "node_modules",
76
+ ".git",
77
+ ".next",
78
+ "dist",
79
+ "build",
80
+ "target",
81
+ ".venv",
82
+ ".cache",
83
+ ".turbo",
84
+ ".parcel-cache",
85
+ "coverage",
86
+ ]);
87
+
70
88
  function isRemoteMountPath(absolutePath: string): boolean {
71
89
  return absolutePath.startsWith(REMOTE_MOUNT_PREFIX);
72
90
  }
@@ -87,6 +105,61 @@ function formatTextWithMode(
87
105
  return text;
88
106
  }
89
107
 
108
+ const BRACE_PAIRS: Record<string, string> = { "{": "}", "(": ")", "[": "]" };
109
+ const BRACE_TAIL_TRAILING_RE = /^[;,)\]}]*$/;
110
+
111
+ /**
112
+ * Decide whether the kept lines surrounding an elided range collapse to a
113
+ * single brace-pair line in the rendered summary. Returns true when the head
114
+ * line ends with `{` / `(` / `[` and the tail line is the matching closer
115
+ * (optionally followed by terminating punctuation like `;`, `,`, or further
116
+ * closers — e.g. `};`, `})`, `]);`).
117
+ */
118
+ function canMergeBracePair(headLine: string, tailLine: string): boolean {
119
+ const head = headLine.trimEnd();
120
+ const tail = tailLine.trim();
121
+ const opener = head.slice(-1);
122
+ const closer = BRACE_PAIRS[opener];
123
+ if (!closer) return false;
124
+ if (!tail.startsWith(closer)) return false;
125
+ return BRACE_TAIL_TRAILING_RE.test(tail.slice(closer.length));
126
+ }
127
+
128
+ function formatSingleLine(
129
+ line: number,
130
+ text: string,
131
+ shouldAddHashLines: boolean,
132
+ shouldAddLineNumbers: boolean,
133
+ ): string {
134
+ if (shouldAddHashLines) return formatHashLine(line, text);
135
+ if (shouldAddLineNumbers) return `${line}|${text}`;
136
+ return text;
137
+ }
138
+
139
+ function formatMergedBraceLine(
140
+ startLine: number,
141
+ endLine: number,
142
+ headText: string,
143
+ tailText: string,
144
+ shouldAddHashLines: boolean,
145
+ shouldAddLineNumbers: boolean,
146
+ ): { model: string; display: string } {
147
+ const merged = `${headText.trimEnd()} .. ${tailText.trim()}`;
148
+ if (shouldAddHashLines) {
149
+ const start = formatLineHash(startLine, headText);
150
+ const end = formatLineHash(endLine, tailText);
151
+ return { model: `${start}-${end}${HL_BODY_SEP}${merged}`, display: merged };
152
+ }
153
+ if (shouldAddLineNumbers) {
154
+ return { model: `${startLine}-${endLine}|${merged}`, display: merged };
155
+ }
156
+ return { model: merged, display: merged };
157
+ }
158
+
159
+ function countTextLines(text: string): number {
160
+ if (text.length === 0) return 0;
161
+ return text.split("\n").length;
162
+ }
90
163
  const READ_CHUNK_SIZE = 8 * 1024;
91
164
 
92
165
  async function streamLinesFromFile(
@@ -341,8 +414,10 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
341
414
  }
342
415
 
343
416
  const readSchema = Type.Object({
344
- path: Type.String({ description: "path or url", examples: ["src/foo.ts", "https://example.com"] }),
345
- sel: Type.Optional(Type.String({ description: "line range or mode", examples: ["50", "50-200", "50+150", "raw"] })),
417
+ path: Type.String({
418
+ description: 'path or url; append :<sel> for line ranges or raw mode (e.g. "src/foo.ts:50-100")',
419
+ examples: ["src/foo.ts", "src/foo.ts:50-100", "https://example.com:L1-L40"],
420
+ }),
346
421
  timeout: Type.Optional(Type.Number({ description: "timeout in seconds", default: 20 })),
347
422
  });
348
423
 
@@ -364,11 +439,12 @@ export interface ReadToolDetails {
364
439
  * Mirrors the same lines the model receives but without hashline/line-number prefixes,
365
440
  * so the TUI can render the file content with its own gutter without re-parsing the formatted text. */
366
441
  displayContent?: { text: string; startLine: number };
442
+ summary?: { lines: number; elidedSpans: number };
367
443
  }
368
444
 
369
445
  type ReadParams = ReadToolInput;
370
446
 
371
- /** Parsed representation of the `sel` parameter. */
447
+ /** Parsed representation of a path-embedded selector. */
372
448
  type ParsedSelector =
373
449
  | { kind: "none" }
374
450
  | { kind: "raw" }
@@ -378,12 +454,12 @@ const LINE_RANGE_RE = /^L?(\d+)(?:([-+])L?(\d+))?$/i;
378
454
 
379
455
  function parseSel(sel: string | undefined): ParsedSelector {
380
456
  if (!sel || sel.length === 0) return { kind: "none" };
381
- if (sel === "raw") return { kind: "raw" };
457
+ if (sel.toLowerCase() === "raw") return { kind: "raw" };
382
458
  const lineMatch = LINE_RANGE_RE.exec(sel);
383
459
  if (lineMatch) {
384
460
  const rawStart = Number.parseInt(lineMatch[1]!, 10);
385
461
  if (rawStart < 1) {
386
- throw new ToolError("sel=0 is invalid; lines are 1-indexed. Use sel=1.");
462
+ throw new ToolError("Line selector 0 is invalid; lines are 1-indexed. Use :1.");
387
463
  }
388
464
  const sep = lineMatch[2];
389
465
  const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
@@ -401,7 +477,7 @@ function parseSel(sel: string | undefined): ParsedSelector {
401
477
  }
402
478
  return { kind: "lines", startLine: rawStart, endLine: rawEnd };
403
479
  }
404
- // Unrecognized selectors fall through; sqlite/archive/url readers consume `sel` themselves.
480
+ // Unrecognized selectors fall through; sqlite/archive/url readers consume their own colon syntax.
405
481
  return { kind: "none" };
406
482
  }
407
483
 
@@ -427,22 +503,6 @@ interface ResolvedSqliteReadPath {
427
503
  suffixResolution?: { from: string; to: string };
428
504
  }
429
505
 
430
- function parseSqliteSelectorInput(selector: string | undefined): { subPath: string; queryString: string } {
431
- if (!selector) {
432
- return { subPath: "", queryString: "" };
433
- }
434
-
435
- const queryIndex = selector.indexOf("?");
436
- if (queryIndex === -1) {
437
- return { subPath: selector.replace(/^:+/, ""), queryString: "" };
438
- }
439
-
440
- return {
441
- subPath: selector.slice(0, queryIndex).replace(/^:+/, ""),
442
- queryString: selector.slice(queryIndex + 1),
443
- };
444
- }
445
-
446
506
  /**
447
507
  * Read tool implementation.
448
508
  *
@@ -603,7 +663,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
603
663
  const suggestion =
604
664
  allLines.length === 0
605
665
  ? `The ${options.entityLabel} is empty.`
606
- : `Use sel=1 to read from the start, or sel=${allLines.length} to read the last line.`;
666
+ : `Use :1 to read from the start, or :${allLines.length} to read the last line.`;
607
667
  return resultBuilder
608
668
  .text(
609
669
  `Line ${startLineDisplay} is beyond end of ${options.entityLabel} (${allLines.length} lines total). ${suggestion}`,
@@ -665,7 +725,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
665
725
  const nextOffset = startLine + userLimitedLines + 1;
666
726
 
667
727
  outputText = formatText(selectedContent, startLineDisplay);
668
- outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use sel=${nextOffset} to continue]`;
728
+ outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use :${nextOffset} to continue]`;
669
729
  } else {
670
730
  outputText = formatText(truncation.content, startLineDisplay);
671
731
  }
@@ -779,15 +839,15 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
779
839
  }
780
840
 
781
841
  async #readSqlite(
782
- sel: string | undefined,
783
842
  resolvedSqlitePath: ResolvedSqliteReadPath,
784
843
  signal?: AbortSignal,
785
844
  ): Promise<AgentToolResult<ReadToolDetails>> {
786
845
  throwIfAborted(signal);
787
846
 
788
- const selectorInput = sel
789
- ? parseSqliteSelectorInput(sel)
790
- : { subPath: resolvedSqlitePath.sqliteSubPath, queryString: resolvedSqlitePath.queryString };
847
+ const selectorInput = {
848
+ subPath: resolvedSqlitePath.sqliteSubPath,
849
+ queryString: resolvedSqlitePath.queryString,
850
+ };
791
851
  const selector = parseSqliteSelector(selectorInput.subPath, selectorInput.queryString);
792
852
  const details: ReadToolDetails = {
793
853
  resolvedPath: resolvedSqlitePath.absolutePath,
@@ -826,7 +886,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
826
886
  });
827
887
  if (sampleRows.rows.length < sampleRows.totalCount) {
828
888
  const remaining = sampleRows.totalCount - sampleRows.rows.length;
829
- output += `\n[${remaining} more rows; use sel="${selector.table}?limit=20&offset=${sampleRows.rows.length}" to continue]`;
889
+ output += `\n[${remaining} more rows; append :${selector.table}?limit=20&offset=${sampleRows.rows.length} to the database path to continue]`;
830
890
  }
831
891
  return toolResult<ReadToolDetails>(details)
832
892
  .text(prependSuffixResolutionNotice(output, resolvedSqlitePath.suffixResolution))
@@ -904,6 +964,118 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
904
964
  }
905
965
  }
906
966
 
967
+ async #trySummarize(absolutePath: string, fileSize: number, signal?: AbortSignal): Promise<SummaryResult | null> {
968
+ if (fileSize > MAX_SUMMARY_BYTES) return null;
969
+
970
+ try {
971
+ throwIfAborted(signal);
972
+ const code = await Bun.file(absolutePath).text();
973
+ throwIfAborted(signal);
974
+ if (countTextLines(code) > MAX_SUMMARY_LINES) return null;
975
+
976
+ return summarizeCode({
977
+ code,
978
+ path: absolutePath,
979
+ minBodyLines: this.session.settings.get("read.summarize.minBodyLines"),
980
+ minCommentLines: this.session.settings.get("read.summarize.minCommentLines"),
981
+ });
982
+ } catch {
983
+ return null;
984
+ }
985
+ }
986
+
987
+ #renderSummary(summary: SummaryResult): {
988
+ text: string;
989
+ displayText: string;
990
+ elidedSpans: number;
991
+ } {
992
+ const displayMode = resolveFileDisplayMode(this.session);
993
+ const shouldAddHashLines = displayMode.hashLines;
994
+ const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
995
+
996
+ // Flatten segments into per-line units so we can merge a kept-head /
997
+ // elided / kept-tail sandwich into a single brace-pair line when the
998
+ // boundary lines look like `… {` and `}` (or matching variants).
999
+ type Unit =
1000
+ | { kind: "line"; line: number; text: string }
1001
+ | { kind: "elided"; startLine: number; endLine: number }
1002
+ | {
1003
+ kind: "merged";
1004
+ startLine: number;
1005
+ endLine: number;
1006
+ headText: string;
1007
+ tailText: string;
1008
+ };
1009
+
1010
+ const raw: Unit[] = [];
1011
+ for (const segment of summary.segments) {
1012
+ if (segment.kind === "elided") {
1013
+ raw.push({ kind: "elided", startLine: segment.startLine, endLine: segment.endLine });
1014
+ continue;
1015
+ }
1016
+ const text = segment.text ?? "";
1017
+ if (text.length === 0) continue;
1018
+ const lines = text.split("\n");
1019
+ for (let i = 0; i < lines.length; i++) {
1020
+ raw.push({ kind: "line", line: segment.startLine + i, text: lines[i] });
1021
+ }
1022
+ }
1023
+
1024
+ const units: Unit[] = [];
1025
+ let i = 0;
1026
+ while (i < raw.length) {
1027
+ const cur = raw[i];
1028
+ if (cur.kind === "elided") {
1029
+ const prev = units.length > 0 ? units[units.length - 1] : null;
1030
+ const next = i + 1 < raw.length ? raw[i + 1] : null;
1031
+ if (prev?.kind === "line" && next?.kind === "line" && canMergeBracePair(prev.text, next.text)) {
1032
+ units.pop();
1033
+ units.push({
1034
+ kind: "merged",
1035
+ startLine: prev.line,
1036
+ endLine: next.line,
1037
+ headText: prev.text,
1038
+ tailText: next.text,
1039
+ });
1040
+ i += 2;
1041
+ continue;
1042
+ }
1043
+ }
1044
+ units.push(cur);
1045
+ i++;
1046
+ }
1047
+
1048
+ const modelParts: string[] = [];
1049
+ const displayParts: string[] = [];
1050
+ let elidedSpans = 0;
1051
+ for (const unit of units) {
1052
+ if (unit.kind === "elided") {
1053
+ modelParts.push("...");
1054
+ displayParts.push("...");
1055
+ elidedSpans++;
1056
+ continue;
1057
+ }
1058
+ if (unit.kind === "merged") {
1059
+ const formatted = formatMergedBraceLine(
1060
+ unit.startLine,
1061
+ unit.endLine,
1062
+ unit.headText,
1063
+ unit.tailText,
1064
+ shouldAddHashLines,
1065
+ shouldAddLineNumbers,
1066
+ );
1067
+ modelParts.push(formatted.model);
1068
+ displayParts.push(formatted.display);
1069
+ elidedSpans++;
1070
+ continue;
1071
+ }
1072
+ modelParts.push(formatSingleLine(unit.line, unit.text, shouldAddHashLines, shouldAddLineNumbers));
1073
+ displayParts.push(unit.text);
1074
+ }
1075
+
1076
+ return { text: modelParts.join("\n"), displayText: displayParts.join("\n"), elidedSpans };
1077
+ }
1078
+
907
1079
  async execute(
908
1080
  _toolCallId: string,
909
1081
  params: ReadParams,
@@ -911,21 +1083,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
911
1083
  _onUpdate?: AgentToolUpdateCallback<ReadToolDetails>,
912
1084
  _toolContext?: AgentToolContext,
913
1085
  ): Promise<AgentToolResult<ReadToolDetails>> {
914
- let { path: readPath, sel, timeout } = params;
1086
+ let { path: readPath, timeout } = params;
915
1087
  if (readPath.startsWith("file://")) {
916
1088
  readPath = expandPath(readPath);
917
1089
  }
918
1090
  const displayMode = resolveFileDisplayMode(this.session);
919
1091
 
920
- // Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://)
921
- const internalRouter = this.session.internalRouter;
922
- if (internalRouter?.canHandle(readPath)) {
923
- const parsed = parseSel(sel);
924
- const { offset, limit } = selToOffsetLimit(parsed);
925
- return this.#handleInternalUrl(readPath, offset, limit);
926
- }
927
-
928
- const parsedUrlTarget = parseReadUrlTarget(readPath, sel);
1092
+ const parsedUrlTarget = parseReadUrlTarget(readPath);
929
1093
  if (parsedUrlTarget) {
930
1094
  if (!this.session.settings.get("fetch.enabled")) {
931
1095
  throw new ToolError("URL reads are disabled by settings.");
@@ -949,20 +1113,39 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
949
1113
  return executeReadUrl(this.session, { path: parsedUrlTarget.path, timeout, raw: parsedUrlTarget.raw }, signal);
950
1114
  }
951
1115
 
952
- const localReadPath = readPath;
953
- const parsed = parseSel(sel);
1116
+ // Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://)
1117
+ const internalTarget = splitPathAndSel(readPath);
1118
+ const internalRouter = this.session.internalRouter;
1119
+ if (internalRouter?.canHandle(internalTarget.path)) {
1120
+ const parsed = parseSel(internalTarget.sel);
1121
+ const { offset, limit } = selToOffsetLimit(parsed);
1122
+ return this.#handleInternalUrl(internalTarget.path, offset, limit);
1123
+ }
954
1124
 
955
- const archivePath = await this.#resolveArchiveReadPath(localReadPath, signal);
1125
+ const archivePath = await this.#resolveArchiveReadPath(readPath, signal);
956
1126
  if (archivePath) {
957
- const { offset, limit } = selToOffsetLimit(parsed);
958
- return this.#readArchive(readPath, offset, limit, archivePath, signal, { raw: parsed.kind === "raw" });
1127
+ const archiveSubPath = splitPathAndSel(archivePath.archiveSubPath);
1128
+ const archiveParsed = parseSel(archiveSubPath.sel);
1129
+ const { offset, limit } = selToOffsetLimit(archiveParsed);
1130
+ return this.#readArchive(
1131
+ readPath,
1132
+ offset,
1133
+ limit,
1134
+ { ...archivePath, archiveSubPath: archiveSubPath.path },
1135
+ signal,
1136
+ { raw: archiveParsed.kind === "raw" },
1137
+ );
959
1138
  }
960
1139
 
961
1140
  const sqlitePath = await this.#resolveSqliteReadPath(readPath, signal);
962
1141
  if (sqlitePath) {
963
- return this.#readSqlite(sel, sqlitePath, signal);
1142
+ return this.#readSqlite(sqlitePath, signal);
964
1143
  }
965
1144
 
1145
+ const localTarget = splitPathAndSel(readPath);
1146
+ const localReadPath = localTarget.path;
1147
+ const parsed = parseSel(localTarget.sel);
1148
+
966
1149
  let absolutePath = resolveReadPath(localReadPath, this.session.cwd);
967
1150
  let suffixResolution: { from: string; to: string } | undefined;
968
1151
 
@@ -1014,7 +1197,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1014
1197
  const _language = getLanguageFromPath(absolutePath);
1015
1198
  const shouldConvertWithMarkit = CONVERTIBLE_EXTENSIONS.has(ext) || (ext === ".ipynb" && parsed.kind === "raw");
1016
1199
  // Read the file based on type
1017
- let content: Array<TextContent | ImageContent>;
1200
+ let content: Array<TextContent | ImageContent> | undefined;
1018
1201
  let details: ReadToolDetails = {};
1019
1202
  let sourcePath: string | undefined;
1020
1203
  let truncationInfo:
@@ -1098,129 +1281,152 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1098
1281
  content = [{ type: "text", text: `[Cannot read ${ext} file: conversion failed]` }];
1099
1282
  }
1100
1283
  } else {
1101
- // Raw text or line-range mode
1102
- const { offset, limit } = selToOffsetLimit(parsed);
1103
- const startLine = offset ? Math.max(0, offset - 1) : 0;
1104
- const startLineDisplay = startLine + 1;
1105
-
1106
- const DEFAULT_LIMIT = this.#defaultLimit;
1107
- const effectiveLimit = limit ?? DEFAULT_LIMIT;
1108
- const maxLinesToCollect = Math.min(effectiveLimit, DEFAULT_MAX_LINES);
1109
- const selectedLineLimit = effectiveLimit;
1110
- // Scale byte budget with line limit so the configured line count actually fits.
1111
- // Assume ~512 bytes/line average; never go below the shared default.
1112
- const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
1113
-
1114
- const streamResult = await streamLinesFromFile(
1115
- absolutePath,
1116
- startLine,
1117
- maxLinesToCollect,
1118
- maxBytesForRead,
1119
- selectedLineLimit,
1120
- signal,
1121
- );
1284
+ if (
1285
+ parsed.kind === "none" &&
1286
+ this.session.settings.get("read.summarize.enabled") &&
1287
+ (this.session.settings.get("read.summarize.prose") || !PROSE_SUMMARY_EXTENSIONS.has(ext))
1288
+ ) {
1289
+ const summary = await this.#trySummarize(absolutePath, fileSize, signal);
1290
+ if (summary?.parsed && summary.elided) {
1291
+ const renderedSummary = this.#renderSummary(summary);
1292
+ details = {
1293
+ displayContent: { text: renderedSummary.displayText, startLine: 1 },
1294
+ summary: {
1295
+ lines: countTextLines(renderedSummary.text),
1296
+ elidedSpans: renderedSummary.elidedSpans,
1297
+ },
1298
+ };
1122
1299
 
1123
- const {
1124
- lines: collectedLines,
1125
- totalFileLines,
1126
- collectedBytes,
1127
- stoppedByByteLimit,
1128
- firstLinePreview,
1129
- firstLineByteLength,
1130
- } = streamResult;
1131
-
1132
- // Check if offset is out of bounds - return graceful message instead of throwing
1133
- if (startLine >= totalFileLines) {
1134
- const suggestion =
1135
- totalFileLines === 0
1136
- ? "The file is empty."
1137
- : `Use sel=1 to read from the start, or sel=${totalFileLines} to read the last line.`;
1138
- return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
1139
- .text(`Line ${startLineDisplay} is beyond end of file (${totalFileLines} lines total). ${suggestion}`)
1140
- .done();
1300
+ sourcePath = absolutePath;
1301
+ content = [{ type: "text", text: renderedSummary.text }];
1302
+ }
1141
1303
  }
1142
1304
 
1143
- const selectedContent = collectedLines.join("\n");
1144
- const userLimitedLines = collectedLines.length;
1145
-
1146
- const totalSelectedLines = totalFileLines - startLine;
1147
- const totalSelectedBytes = collectedBytes;
1148
- const wasTruncated = collectedLines.length < totalSelectedLines || stoppedByByteLimit;
1149
- const firstLineExceedsLimit = firstLineByteLength !== undefined && firstLineByteLength > maxBytesForRead;
1150
-
1151
- const truncation: TruncationResult = {
1152
- content: selectedContent,
1153
- truncated: wasTruncated,
1154
- truncatedBy: stoppedByByteLimit ? "bytes" : wasTruncated ? "lines" : undefined,
1155
- totalLines: totalSelectedLines,
1156
- totalBytes: totalSelectedBytes,
1157
- outputLines: collectedLines.length,
1158
- outputBytes: collectedBytes,
1159
- lastLinePartial: false,
1160
- firstLineExceedsLimit,
1161
- };
1305
+ if (!content) {
1306
+ // Raw text or line-range mode
1307
+ const { offset, limit } = selToOffsetLimit(parsed);
1308
+ const startLine = offset ? Math.max(0, offset - 1) : 0;
1309
+ const startLineDisplay = startLine + 1;
1310
+
1311
+ const DEFAULT_LIMIT = this.#defaultLimit;
1312
+ const effectiveLimit = limit ?? DEFAULT_LIMIT;
1313
+ const maxLinesToCollect = Math.min(effectiveLimit, DEFAULT_MAX_LINES);
1314
+ const selectedLineLimit = effectiveLimit;
1315
+ // Scale byte budget with line limit so the configured line count actually fits.
1316
+ // Assume ~512 bytes/line average; never go below the shared default.
1317
+ const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
1318
+
1319
+ const streamResult = await streamLinesFromFile(
1320
+ absolutePath,
1321
+ startLine,
1322
+ maxLinesToCollect,
1323
+ maxBytesForRead,
1324
+ selectedLineLimit,
1325
+ signal,
1326
+ );
1162
1327
 
1163
- const isRawMode = parsed.kind === "raw";
1164
- const shouldAddHashLines = !isRawMode && displayMode.hashLines;
1165
- const shouldAddLineNumbers = isRawMode ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1166
- let capturedDisplayContent: { text: string; startLine: number } | undefined;
1167
- const formatText = (text: string, startNum: number): string => {
1168
- capturedDisplayContent = { text, startLine: startNum };
1169
- return formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers);
1170
- };
1328
+ const {
1329
+ lines: collectedLines,
1330
+ totalFileLines,
1331
+ collectedBytes,
1332
+ stoppedByByteLimit,
1333
+ firstLinePreview,
1334
+ firstLineByteLength,
1335
+ } = streamResult;
1336
+
1337
+ // Check if offset is out of bounds - return graceful message instead of throwing
1338
+ if (startLine >= totalFileLines) {
1339
+ const suggestion =
1340
+ totalFileLines === 0
1341
+ ? "The file is empty."
1342
+ : `Use :1 to read from the start, or :${totalFileLines} to read the last line.`;
1343
+ return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
1344
+ .text(`Line ${startLineDisplay} is beyond end of file (${totalFileLines} lines total). ${suggestion}`)
1345
+ .done();
1346
+ }
1171
1347
 
1172
- let outputText: string;
1348
+ const selectedContent = collectedLines.join("\n");
1349
+ const userLimitedLines = collectedLines.length;
1350
+
1351
+ const totalSelectedLines = totalFileLines - startLine;
1352
+ const totalSelectedBytes = collectedBytes;
1353
+ const wasTruncated = collectedLines.length < totalSelectedLines || stoppedByByteLimit;
1354
+ const firstLineExceedsLimit = firstLineByteLength !== undefined && firstLineByteLength > maxBytesForRead;
1355
+
1356
+ const truncation: TruncationResult = {
1357
+ content: selectedContent,
1358
+ truncated: wasTruncated,
1359
+ truncatedBy: stoppedByByteLimit ? "bytes" : wasTruncated ? "lines" : undefined,
1360
+ totalLines: totalSelectedLines,
1361
+ totalBytes: totalSelectedBytes,
1362
+ outputLines: collectedLines.length,
1363
+ outputBytes: collectedBytes,
1364
+ lastLinePartial: false,
1365
+ firstLineExceedsLimit,
1366
+ };
1173
1367
 
1174
- if (truncation.firstLineExceedsLimit) {
1175
- const firstLineBytes = firstLineByteLength ?? 0;
1176
- const snippet = firstLinePreview ?? { text: "", bytes: 0 };
1368
+ const isRawMode = parsed.kind === "raw";
1369
+ const shouldAddHashLines = !isRawMode && displayMode.hashLines;
1370
+ const shouldAddLineNumbers = isRawMode ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1371
+ let capturedDisplayContent: { text: string; startLine: number } | undefined;
1372
+ const formatText = (text: string, startNum: number): string => {
1373
+ capturedDisplayContent = { text, startLine: startNum };
1374
+ return formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers);
1375
+ };
1376
+
1377
+ let outputText: string;
1378
+
1379
+ if (truncation.firstLineExceedsLimit) {
1380
+ const firstLineBytes = firstLineByteLength ?? 0;
1381
+ const snippet = firstLinePreview ?? { text: "", bytes: 0 };
1177
1382
 
1178
- if (shouldAddHashLines) {
1179
- outputText = `[Line ${startLineDisplay} is ${formatBytes(
1180
- firstLineBytes,
1181
- )}, exceeds ${formatBytes(maxBytesForRead)} limit. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`;
1383
+ if (shouldAddHashLines) {
1384
+ outputText = `[Line ${startLineDisplay} is ${formatBytes(
1385
+ firstLineBytes,
1386
+ )}, exceeds ${formatBytes(maxBytesForRead)} limit. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`;
1387
+ } else {
1388
+ outputText = formatText(snippet.text, startLineDisplay);
1389
+ }
1390
+ if (snippet.text.length === 0) {
1391
+ outputText = `[Line ${startLineDisplay} is ${formatBytes(
1392
+ firstLineBytes,
1393
+ )}, exceeds ${formatBytes(maxBytesForRead)} limit. Unable to display a valid UTF-8 snippet.]`;
1394
+ }
1395
+ details = { truncation };
1396
+ sourcePath = absolutePath;
1397
+ truncationInfo = {
1398
+ result: truncation,
1399
+ options: { direction: "head", startLine: startLineDisplay, totalFileLines },
1400
+ };
1401
+ } else if (truncation.truncated) {
1402
+ outputText = formatText(truncation.content, startLineDisplay);
1403
+ details = { truncation };
1404
+ sourcePath = absolutePath;
1405
+ truncationInfo = {
1406
+ result: truncation,
1407
+ options: { direction: "head", startLine: startLineDisplay, totalFileLines },
1408
+ };
1409
+ } else if (startLine + userLimitedLines < totalFileLines) {
1410
+ const remaining = totalFileLines - (startLine + userLimitedLines);
1411
+ const nextOffset = startLine + userLimitedLines + 1;
1412
+
1413
+ outputText = formatText(truncation.content, startLineDisplay);
1414
+ outputText += `\n\n[${remaining} more lines in file. Use :${nextOffset} to continue]`;
1415
+ details = {};
1416
+ sourcePath = absolutePath;
1182
1417
  } else {
1183
- outputText = formatText(snippet.text, startLineDisplay);
1184
- }
1185
- if (snippet.text.length === 0) {
1186
- outputText = `[Line ${startLineDisplay} is ${formatBytes(
1187
- firstLineBytes,
1188
- )}, exceeds ${formatBytes(maxBytesForRead)} limit. Unable to display a valid UTF-8 snippet.]`;
1418
+ // No truncation, no user limit exceeded
1419
+ outputText = formatText(truncation.content, startLineDisplay);
1420
+ details = {};
1421
+ sourcePath = absolutePath;
1189
1422
  }
1190
- details = { truncation };
1191
- sourcePath = absolutePath;
1192
- truncationInfo = {
1193
- result: truncation,
1194
- options: { direction: "head", startLine: startLineDisplay, totalFileLines },
1195
- };
1196
- } else if (truncation.truncated) {
1197
- outputText = formatText(truncation.content, startLineDisplay);
1198
- details = { truncation };
1199
- sourcePath = absolutePath;
1200
- truncationInfo = {
1201
- result: truncation,
1202
- options: { direction: "head", startLine: startLineDisplay, totalFileLines },
1203
- };
1204
- } else if (startLine + userLimitedLines < totalFileLines) {
1205
- const remaining = totalFileLines - (startLine + userLimitedLines);
1206
- const nextOffset = startLine + userLimitedLines + 1;
1207
1423
 
1208
- outputText = formatText(truncation.content, startLineDisplay);
1209
- outputText += `\n\n[${remaining} more lines in file. Use sel=${nextOffset} to continue]`;
1210
- details = {};
1211
- sourcePath = absolutePath;
1212
- } else {
1213
- // No truncation, no user limit exceeded
1214
- outputText = formatText(truncation.content, startLineDisplay);
1215
- details = {};
1216
- sourcePath = absolutePath;
1217
- }
1424
+ if (capturedDisplayContent) {
1425
+ details.displayContent = capturedDisplayContent;
1426
+ }
1218
1427
 
1219
- if (capturedDisplayContent) {
1220
- details.displayContent = capturedDisplayContent;
1428
+ content = [{ type: "text", text: outputText }];
1221
1429
  }
1222
-
1223
- content = [{ type: "text", text: outputText }];
1224
1430
  }
1225
1431
 
1226
1432
  if (suffixResolution) {
@@ -1297,61 +1503,41 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1297
1503
  limit: number | undefined,
1298
1504
  signal?: AbortSignal,
1299
1505
  ): Promise<AgentToolResult<ReadToolDetails>> {
1300
- const DEFAULT_LIMIT = 500;
1301
- const effectiveLimit = limit ?? DEFAULT_LIMIT;
1506
+ const READ_DIRECTORY_MAX_DEPTH = 2;
1507
+ const READ_DIRECTORY_CHILD_LIMIT = 12;
1302
1508
 
1303
- let entries: string[];
1509
+ throwIfAborted(signal);
1510
+ let tree: DirectoryTree;
1304
1511
  try {
1305
- entries = await fs.readdir(absolutePath);
1512
+ tree = await buildDirectoryTree(absolutePath, {
1513
+ maxDepth: READ_DIRECTORY_MAX_DEPTH,
1514
+ directoryEntryLimit: READ_DIRECTORY_CHILD_LIMIT,
1515
+ rootEntryLimit: null,
1516
+ lineCap: limit ?? null,
1517
+ lineCapProtectedDepth: 1,
1518
+ hidden: true,
1519
+ gitignore: false,
1520
+ cache: true,
1521
+ excludedDirectoryNames: READ_DIRECTORY_EXCLUDED_DIRS,
1522
+ rootLabel: ".",
1523
+ });
1306
1524
  } catch (error) {
1307
1525
  const message = error instanceof Error ? error.message : String(error);
1308
1526
  throw new ToolError(`Cannot read directory: ${message}`);
1309
1527
  }
1528
+ throwIfAborted(signal);
1310
1529
 
1311
- // Sort alphabetically (case-insensitive)
1312
- entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
1313
-
1314
- const listLimit = applyListLimit(entries, { limit: effectiveLimit });
1315
- const limitedEntries = listLimit.items;
1316
- const limitMeta = listLimit.meta;
1317
-
1318
- // Format entries with directory indicators and ages
1319
- const results: string[] = [];
1320
-
1321
- for (const entry of limitedEntries) {
1322
- throwIfAborted(signal);
1323
- const fullPath = path.join(absolutePath, entry);
1324
- let suffix = "";
1325
- let age = "";
1326
-
1327
- try {
1328
- const entryStat = await fs.stat(fullPath);
1329
- suffix = entryStat.isDirectory() ? "/" : "";
1330
- const ageSeconds = Math.floor((Date.now() - entryStat.mtimeMs) / 1000);
1331
- age = formatAge(ageSeconds);
1332
- } catch {
1333
- // Skip entries we can't stat
1334
- continue;
1335
- }
1336
-
1337
- const line = age ? `${entry}${suffix} (${age})` : entry + suffix;
1338
- results.push(line);
1339
- }
1340
-
1341
- if (results.length === 0) {
1342
- return { content: [{ type: "text", text: "(empty directory)" }], details: {} };
1343
- }
1344
-
1345
- const output = results.join("\n");
1530
+ const output = tree.totalLines <= 1 ? "(empty directory)" : tree.rendered;
1346
1531
  const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
1347
-
1348
1532
  const details: ReadToolDetails = {
1349
1533
  isDirectory: true,
1534
+ resolvedPath: tree.rootPath,
1350
1535
  };
1351
1536
 
1352
- const resultBuilder = toolResult(details)
1353
- .text(truncation.content)
1354
- .limits({ resultLimit: limitMeta.resultLimit?.reached });
1537
+ const resultBuilder = toolResult(details).text(truncation.content).sourcePath(tree.rootPath);
1538
+ if (tree.truncated) {
1539
+ resultBuilder.limits({ resultLimit: 1 });
1540
+ }
1355
1541
  if (truncation.truncated) {
1356
1542
  resultBuilder.truncation(truncation, { direction: "head" });
1357
1543
  details.truncation = truncation;
@@ -1482,6 +1668,9 @@ export const readToolRenderer = {
1482
1668
  const endLine = args.limit !== undefined ? startLine + args.limit - 1 : "";
1483
1669
  title += `:${startLine}${endLine ? `-${endLine}` : ""}`;
1484
1670
  }
1671
+ if (details?.summary) {
1672
+ title += ` (summary: ${details.summary.elidedSpans} elided span${details.summary.elidedSpans === 1 ? "" : "s"})`;
1673
+ }
1485
1674
  let cachedWidth: number | undefined;
1486
1675
  let cachedLines: string[] | undefined;
1487
1676
  return {