read-render.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. /**
  2. * Pure read presentation: turn provider-decoded text into a bounded, line-numbered window and
  3. * model-facing envelope. Chunk scanning caps the current line, so even one newline-free giant
  4. * line cannot grow memory without bound.
  5. * @module @deepseek-ai/dsh-tool-fs/read-render
  6. */
  7. import { FsError } from '@deepseek-ai/dsh-fs'
  8. /** Default maximum characters returned for a single line (the `readMaxLineLength` config). */
  9. export const READ_MAX_LINE_LENGTH = 2000
  10. /** Default maximum bytes returned for selected file lines (the `readMaxBytes` config). */
  11. export const READ_MAX_BYTES = 50 * 1024
  12. /** Resolved read window. The consumer applies its defaults/caps before calling. */
  13. export interface ReadWindow {
  14. /** 1-based first line to return. */
  15. offset: number
  16. /** Maximum number of lines to return. */
  17. limit: number
  18. /** Maximum characters returned for a single line; overflow is truncated with a suffix. */
  19. maxLineLength: number
  20. /** Maximum bytes of selected output; overflow stops the scan and marks `truncatedByBytes`. */
  21. maxBytes: number
  22. }
  23. /** One line returned from a text file. */
  24. export interface FileTextLine {
  25. /** 1-based line number in the file. */
  26. number: number
  27. /** Line text without its trailing newline. */
  28. text: string
  29. }
  30. /** The windowed result {@link buildWindow} produces from a file's decoded text. */
  31. export interface WindowResult {
  32. /** Returned lines, already numbered. */
  33. lines: FileTextLine[]
  34. /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
  35. totalLines: number
  36. /** Whether selected output hit the byte cap before EOF or the requested limit. */
  37. truncatedByBytes: boolean
  38. }
  39. /** Outcome of a bounded text read — what {@link formatReadOutput} renders. */
  40. export interface FileReadOutcome {
  41. /** 1-based first line requested. */
  42. offset: number
  43. /** Returned lines, already numbered. */
  44. lines: FileTextLine[]
  45. /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
  46. totalLines: number
  47. /** Whether selected output hit the byte cap before EOF or the requested limit. */
  48. truncatedByBytes?: true
  49. }
  50. interface WindowAccumulator {
  51. lines: FileTextLine[]
  52. totalLines: number
  53. outputBytes: number
  54. truncatedByBytes: boolean
  55. done: boolean
  56. }
  57. function newAccumulator(): WindowAccumulator {
  58. return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false }
  59. }
  60. function truncateLine(line: string, maxLineLength: number): string {
  61. return line.length > maxLineLength ? `${line.substring(0, maxLineLength)}... (line truncated to ${maxLineLength} chars)` : line
  62. }
  63. function lineByteSize(line: string, currentLineCount: number): number {
  64. return Buffer.byteLength(line, 'utf8') + (currentLineCount > 0 ? 1 : 0)
  65. }
  66. function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void {
  67. acc.totalLines += 1
  68. if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
  69. const text = truncateLine(rawLine, request.maxLineLength)
  70. const bytes = lineByteSize(text, acc.lines.length)
  71. if (acc.outputBytes + bytes > request.maxBytes) {
  72. acc.truncatedByBytes = true
  73. acc.done = true
  74. return
  75. }
  76. acc.outputBytes += bytes
  77. acc.lines.push({ number: acc.totalLines, text })
  78. }
  79. function stripCarriageReturn(line: string): string {
  80. return line.endsWith('\r') ? line.slice(0, -1) : line
  81. }
  82. function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string): WindowResult {
  83. if (!acc.truncatedByBytes && request.offset > acc.totalLines && !(acc.totalLines === 0 && request.offset === 1)) {
  84. throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, 'FS_NOT_FOUND')
  85. }
  86. return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes }
  87. }
  88. /**
  89. * Build one window from streamed or whole-file chunks, enforcing line and byte caps and throwing
  90. * `FS_NOT_FOUND` when the requested offset is past EOF.
  91. * @param chunks - decoded text chunks in file order; chunk boundaries carry no meaning.
  92. * @param request - the resolved window; the caller has already applied its defaults and caps.
  93. * @param displayPath - the caller-facing path used in the offset-out-of-range error.
  94. * @returns the numbered window lines, the total line count seen, and the byte-cap truncation flag.
  95. */
  96. export async function buildWindow(
  97. chunks: AsyncIterable<string> | Iterable<string>,
  98. request: ReadWindow,
  99. displayPath: string,
  100. ): Promise<WindowResult> {
  101. const acc = newAccumulator()
  102. // One char past the truncation point is enough to prove a line overflows.
  103. const lineBufferCap = request.maxLineLength + 1
  104. let lineBuffer = ''
  105. function appendToLineBuffer(segment: string): void {
  106. if (lineBuffer.length >= lineBufferCap) return
  107. lineBuffer += segment
  108. if (lineBuffer.length > lineBufferCap) lineBuffer = lineBuffer.slice(0, lineBufferCap)
  109. }
  110. function flushLine(): void {
  111. consumeLine(acc, stripCarriageReturn(lineBuffer), request)
  112. lineBuffer = ''
  113. }
  114. for await (const chunk of chunks) {
  115. let startPos = 0
  116. let newlinePos: number
  117. while ((newlinePos = chunk.indexOf('\n', startPos)) !== -1) {
  118. appendToLineBuffer(chunk.slice(startPos, newlinePos))
  119. flushLine()
  120. startPos = newlinePos + 1
  121. if (acc.done) return finish(acc, request, displayPath)
  122. }
  123. appendToLineBuffer(chunk.slice(startPos))
  124. }
  125. if (lineBuffer.length > 0) flushLine()
  126. return finish(acc, request, displayPath)
  127. }
  128. /**
  129. * Format a read outcome as one OpenCode-style line-numbered text block body.
  130. * @param displayPath - the backend-resolved path rendered in the envelope's `<path>` element.
  131. * @param outcome - the windowed read to render.
  132. * @returns the model-facing envelope: numbered lines plus a continuation or end-of-file footer.
  133. */
  134. export function formatReadOutput(displayPath: string, outcome: FileReadOutcome): string {
  135. const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1)
  136. let footer: string
  137. if (outcome.truncatedByBytes) {
  138. footer = `(Output capped. Showing lines ${outcome.offset}-${endLine}. Use offset=${endLine + 1} to continue.)`
  139. } else if (endLine < outcome.totalLines) {
  140. footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)`
  141. } else {
  142. footer = `(End of file - total ${outcome.totalLines} lines)`
  143. }
  144. const body = outcome.lines.length > 0
  145. ? `${outcome.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}`
  146. : footer
  147. return `<path>${displayPath}</path>
  148. <type>file</type>
  149. <content>
  150. ${body}
  151. </content>`
  152. }