read-render.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. /**
  2. * Cordis-free read rendering for `@deepseek-ai/dsh-tool-fs`: turn a file's
  3. * decoded text into a bounded, line-numbered window (offset/limit, byte cap,
  4. * per-line truncation) and format it as the model-facing text block. This is
  5. * the `read` tool's RENDERING detail — not a storage primitive, not freshness
  6. * policy — so it lives apart from the tool's I/O and event wiring as a pure,
  7. * independently-testable module (no cordis, no filesystem).
  8. *
  9. * The provider (`ctx.fs.readText`/`streamText`) hands back already-decoded text
  10. * (UTF-8 validated, binary rejected); {@link buildWindow} only scans that text
  11. * for newlines and builds the requested window. A capped line buffer means a
  12. * newline-free giant line can never balloon memory even when streamed.
  13. * {@link formatReadOutput} turns the resulting {@link FileReadOutcome} into the
  14. * `<path>/<content>` envelope the model sees.
  15. *
  16. * @module @deepseek-ai/dsh-tool-fs/read-render
  17. */
  18. import { FsError } from '@deepseek-ai/dsh-fs'
  19. import type { FsVersion } from '@deepseek-ai/dsh-fs'
  20. /** Maximum characters returned for a single line. */
  21. export const READ_MAX_LINE_LENGTH = 2000
  22. /** Maximum bytes returned for selected file lines. */
  23. export const READ_MAX_BYTES = 50 * 1024
  24. const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`
  25. const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1
  26. /** Resolved read window. The consumer applies its defaults/caps before calling. */
  27. export interface ReadWindow {
  28. /** 1-based first line to return. */
  29. offset: number
  30. /** Maximum number of lines to return. */
  31. limit: number
  32. }
  33. /** One line returned from a text file. */
  34. export interface FileTextLine {
  35. /** 1-based line number in the file. */
  36. number: number
  37. /** Line text without its trailing newline. */
  38. text: string
  39. }
  40. /** The windowed result {@link buildWindow} produces from a file's decoded text. */
  41. export interface WindowResult {
  42. /** Returned lines, already numbered. */
  43. lines: FileTextLine[]
  44. /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
  45. totalLines: number
  46. /** Whether selected output hit the byte cap before EOF or the requested limit. */
  47. truncatedByBytes: boolean
  48. }
  49. /** Outcome of a bounded text read — what {@link formatReadOutput} renders. */
  50. export interface FileReadOutcome {
  51. /** 1-based first line requested. */
  52. offset: number
  53. /** Maximum number of lines requested. */
  54. limit: number
  55. /** Returned lines, already numbered. */
  56. lines: FileTextLine[]
  57. /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
  58. totalLines: number
  59. /** Whether selected output hit the byte cap before EOF or the requested limit. */
  60. truncatedByBytes?: true
  61. /** Opaque version of the file at read time. */
  62. version: FsVersion
  63. }
  64. interface WindowAccumulator {
  65. lines: FileTextLine[]
  66. totalLines: number
  67. outputBytes: number
  68. truncatedByBytes: boolean
  69. done: boolean
  70. }
  71. function newAccumulator(): WindowAccumulator {
  72. return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false }
  73. }
  74. function truncateLine(line: string): string {
  75. return line.length > READ_MAX_LINE_LENGTH ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` : line
  76. }
  77. function lineByteSize(line: string, currentLineCount: number): number {
  78. return Buffer.byteLength(line, 'utf8') + (currentLineCount > 0 ? 1 : 0)
  79. }
  80. function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void {
  81. acc.totalLines += 1
  82. if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
  83. const text = truncateLine(rawLine)
  84. const bytes = lineByteSize(text, acc.lines.length)
  85. if (acc.outputBytes + bytes > READ_MAX_BYTES) {
  86. acc.truncatedByBytes = true
  87. acc.done = true
  88. return
  89. }
  90. acc.outputBytes += bytes
  91. acc.lines.push({ number: acc.totalLines, text })
  92. }
  93. function stripCarriageReturn(line: string): string {
  94. return line.endsWith('\r') ? line.slice(0, -1) : line
  95. }
  96. function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string): WindowResult {
  97. if (!acc.truncatedByBytes && request.offset > acc.totalLines && !(acc.totalLines === 0 && request.offset === 1)) {
  98. throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, 'FS_NOT_FOUND')
  99. }
  100. return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes }
  101. }
  102. /**
  103. * Build a bounded, line-numbered window from a file's decoded text chunks.
  104. * Accepts an `AsyncIterable<string>` (a chunked `streamText`) or an
  105. * `Iterable<string>` (a whole-file `readText` wrapped as `[text]`), so one code
  106. * path serves both. Scans for newlines with a capped line buffer (a newline-free
  107. * giant line is truncated, never buffered past {@link READ_MAX_LINE_LENGTH}),
  108. * enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF.
  109. */
  110. export async function buildWindow(
  111. chunks: AsyncIterable<string> | Iterable<string>,
  112. request: ReadWindow,
  113. displayPath: string,
  114. ): Promise<WindowResult> {
  115. const acc = newAccumulator()
  116. let lineBuffer = ''
  117. function appendToLineBuffer(segment: string): void {
  118. if (lineBuffer.length >= LINE_BUFFER_CAP) return
  119. lineBuffer += segment
  120. if (lineBuffer.length > LINE_BUFFER_CAP) lineBuffer = lineBuffer.slice(0, LINE_BUFFER_CAP)
  121. }
  122. function flushLine(): void {
  123. consumeLine(acc, stripCarriageReturn(lineBuffer), request)
  124. lineBuffer = ''
  125. }
  126. for await (const chunk of chunks) {
  127. let startPos = 0
  128. let newlinePos: number
  129. while ((newlinePos = chunk.indexOf('\n', startPos)) !== -1) {
  130. appendToLineBuffer(chunk.slice(startPos, newlinePos))
  131. flushLine()
  132. startPos = newlinePos + 1
  133. if (acc.done) return finish(acc, request, displayPath)
  134. }
  135. appendToLineBuffer(chunk.slice(startPos))
  136. }
  137. if (lineBuffer.length > 0) flushLine()
  138. return finish(acc, request, displayPath)
  139. }
  140. /** Format a read outcome as one OpenCode-style line-numbered text block body. */
  141. export function formatReadOutput(displayPath: string, outcome: FileReadOutcome): string {
  142. const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1)
  143. let footer: string
  144. if (outcome.truncatedByBytes) {
  145. footer = `(Output capped. Showing lines ${outcome.offset}-${endLine}. Use offset=${endLine + 1} to continue.)`
  146. } else if (endLine < outcome.totalLines) {
  147. footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)`
  148. } else {
  149. footer = `(End of file - total ${outcome.totalLines} lines)`
  150. }
  151. const body = outcome.lines.length > 0
  152. ? `${outcome.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}`
  153. : footer
  154. return `<path>${displayPath}</path>
  155. <type>file</type>
  156. <content>
  157. ${body}
  158. </content>`
  159. }