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

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