| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180 |
- /**
- * Cordis-free read rendering for `@deepseek-ai/dsh-tool-fs`: turn a file's
- * decoded text into a bounded, line-numbered window (offset/limit, byte cap,
- * per-line truncation) and format it as the model-facing text block. This is
- * the `read` tool's RENDERING detail — not a storage primitive, not freshness
- * policy — so it lives apart from the tool's I/O and event wiring as a pure,
- * independently-testable module (no cordis, no filesystem).
- *
- * The provider (`ctx.fs.readText`/`streamText`) hands back already-decoded text
- * (UTF-8 validated, binary rejected); {@link buildWindow} only scans that text
- * for newlines and builds the requested window. A capped line buffer means a
- * newline-free giant line can never balloon memory even when streamed.
- * {@link formatReadOutput} turns the resulting {@link FileReadOutcome} into the
- * `<path>/<content>` envelope the model sees.
- *
- * @module @deepseek-ai/dsh-tool-fs/read-render
- */
- import { FsError } from '@deepseek-ai/dsh-fs'
- import type { FsVersion } from '@deepseek-ai/dsh-fs'
- /** Maximum characters returned for a single line. */
- export const READ_MAX_LINE_LENGTH = 2000
- /** Maximum bytes returned for selected file lines. */
- export const READ_MAX_BYTES = 50 * 1024
- const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`
- const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1
- /** Resolved read window. The consumer applies its defaults/caps before calling. */
- export interface ReadWindow {
- /** 1-based first line to return. */
- offset: number
- /** Maximum number of lines to return. */
- limit: number
- }
- /** One line returned from a text file. */
- export interface FileTextLine {
- /** 1-based line number in the file. */
- number: number
- /** Line text without its trailing newline. */
- text: string
- }
- /** The windowed result {@link buildWindow} produces from a file's decoded text. */
- export interface WindowResult {
- /** Returned lines, already numbered. */
- lines: FileTextLine[]
- /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
- totalLines: number
- /** Whether selected output hit the byte cap before EOF or the requested limit. */
- truncatedByBytes: boolean
- }
- /** Outcome of a bounded text read — what {@link formatReadOutput} renders. */
- export interface FileReadOutcome {
- /** 1-based first line requested. */
- offset: number
- /** Maximum number of lines requested. */
- limit: number
- /** Returned lines, already numbered. */
- lines: FileTextLine[]
- /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
- totalLines: number
- /** Whether selected output hit the byte cap before EOF or the requested limit. */
- truncatedByBytes?: true
- /** Opaque version of the file at read time. */
- version: FsVersion
- }
- interface WindowAccumulator {
- lines: FileTextLine[]
- totalLines: number
- outputBytes: number
- truncatedByBytes: boolean
- done: boolean
- }
- function newAccumulator(): WindowAccumulator {
- return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false }
- }
- function truncateLine(line: string): string {
- return line.length > READ_MAX_LINE_LENGTH ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` : line
- }
- function lineByteSize(line: string, currentLineCount: number): number {
- return Buffer.byteLength(line, 'utf8') + (currentLineCount > 0 ? 1 : 0)
- }
- function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void {
- acc.totalLines += 1
- if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
- const text = truncateLine(rawLine)
- const bytes = lineByteSize(text, acc.lines.length)
- if (acc.outputBytes + bytes > READ_MAX_BYTES) {
- acc.truncatedByBytes = true
- acc.done = true
- return
- }
- acc.outputBytes += bytes
- acc.lines.push({ number: acc.totalLines, text })
- }
- function stripCarriageReturn(line: string): string {
- return line.endsWith('\r') ? line.slice(0, -1) : line
- }
- function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string): WindowResult {
- if (!acc.truncatedByBytes && request.offset > acc.totalLines && !(acc.totalLines === 0 && request.offset === 1)) {
- throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, 'FS_NOT_FOUND')
- }
- return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes }
- }
- /**
- * Build a bounded, line-numbered window from a file's decoded text chunks.
- * Accepts an `AsyncIterable<string>` (a chunked `streamText`) or an
- * `Iterable<string>` (a whole-file `readText` wrapped as `[text]`), so one code
- * path serves both. Scans for newlines with a capped line buffer (a newline-free
- * giant line is truncated, never buffered past {@link READ_MAX_LINE_LENGTH}),
- * enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF.
- */
- export async function buildWindow(
- chunks: AsyncIterable<string> | Iterable<string>,
- request: ReadWindow,
- displayPath: string,
- ): Promise<WindowResult> {
- const acc = newAccumulator()
- let lineBuffer = ''
- function appendToLineBuffer(segment: string): void {
- if (lineBuffer.length >= LINE_BUFFER_CAP) return
- lineBuffer += segment
- if (lineBuffer.length > LINE_BUFFER_CAP) lineBuffer = lineBuffer.slice(0, LINE_BUFFER_CAP)
- }
- function flushLine(): void {
- consumeLine(acc, stripCarriageReturn(lineBuffer), request)
- lineBuffer = ''
- }
- for await (const chunk of chunks) {
- let startPos = 0
- let newlinePos: number
- while ((newlinePos = chunk.indexOf('\n', startPos)) !== -1) {
- appendToLineBuffer(chunk.slice(startPos, newlinePos))
- flushLine()
- startPos = newlinePos + 1
- if (acc.done) return finish(acc, request, displayPath)
- }
- appendToLineBuffer(chunk.slice(startPos))
- }
- if (lineBuffer.length > 0) flushLine()
- return finish(acc, request, displayPath)
- }
- /** Format a read outcome as one OpenCode-style line-numbered text block body. */
- export function formatReadOutput(displayPath: string, outcome: FileReadOutcome): string {
- const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1)
- let footer: string
- if (outcome.truncatedByBytes) {
- footer = `(Output capped. Showing lines ${outcome.offset}-${endLine}. Use offset=${endLine + 1} to continue.)`
- } else if (endLine < outcome.totalLines) {
- footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)`
- } else {
- footer = `(End of file - total ${outcome.totalLines} lines)`
- }
- const body = outcome.lines.length > 0
- ? `${outcome.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}`
- : footer
- return `<path>${displayPath}</path>
- <type>file</type>
- <content>
- ${body}
- </content>`
- }
|