io-v2-output.ts (3327B)
1 // Presentation only. Byte acceptance and strict source UTF-8 Results are separate. 2 // No evaluator handles, host callbacks, IO acknowledgements or terminal escapes. 3 import { decodeUTF8, UTF8Error } from './io-v2-utf8.js'; 4 export type OutputStream = 'output' | 'error'; 5 export type OutputToken = { kind: 'text'; offset: number; text: string } | 6 { kind: 'bytes'; offset: number; bytes: number[]; reason: 'invalid' | 'incomplete' | 'pending' }; 7 export interface OutputView { bytes: number[]; hex: string; tokens: OutputToken[] } 8 9 function view(bytes: number[], final: boolean): OutputView { 10 const tokens: OutputToken[] = []; 11 for (let offset = 0; offset < bytes.length;) { 12 const lead = bytes[offset]!; 13 const width = lead <= 0x7f ? 1 : lead >= 0xc2 && lead <= 0xdf ? 2 : 14 lead >= 0xe0 && lead <= 0xef ? 3 : lead >= 0xf0 && lead <= 0xf4 ? 4 : 1; 15 const candidate = bytes.slice(offset, offset + width); 16 try { 17 const decoded = decodeUTF8(candidate, false); 18 if (decoded.carry.length) { 19 tokens.push({ kind: 'bytes', offset, bytes: candidate, reason: final ? 'incomplete' : 'pending' }); 20 break; 21 } 22 const previous = tokens.at(-1); 23 if (previous?.kind === 'text') previous.text += decoded.text; 24 else tokens.push({ kind: 'text', offset, text: decoded.text }); 25 offset += width; 26 } catch (error) { 27 if (!(error instanceof UTF8Error)) throw error; 28 // Escape one offending byte; reconsider following bytes independently. 29 // Thus invalid prefixes cannot swallow subsequent valid text. 30 tokens.push({ kind: 'bytes', offset, bytes: [lead], reason: 'invalid' }); 31 offset++; 32 } 33 } 34 return { bytes, hex: bytes.map(b => b.toString(16).padStart(2, '0')).join(' '), tokens }; 35 } 36 37 // Owned by one UI run. The session separately enforces attempted-output budgets; 38 // this buffer bounds retained accepted bytes, without refunds or history. 39 export class OutputBuffer { 40 private readonly streams: Record<OutputStream, number[]> = { output: [], error: [] }; 41 private size = 0; 42 private ended = false; 43 constructor(readonly limit = 16384) { 44 if (!Number.isSafeInteger(limit) || limit < 0 || limit > 16384) throw new Error('invalid output limit'); 45 } 46 append(stream: OutputStream, bytes: readonly number[]): void { 47 if (this.ended) throw new Error('output presentation ended'); 48 if (stream !== 'output' && stream !== 'error') throw new Error('invalid output stream'); 49 if (!Array.isArray(bytes) || bytes.length === 0 || bytes.length > 4096) throw new Error('invalid output bytes'); 50 for (const byte of bytes) if (!Number.isInteger(byte) || byte < 0 || byte > 255) throw new Error('invalid output bytes'); 51 if (bytes.length > this.limit - this.size) throw new Error('output presentation limit'); 52 this.streams[stream].push(...bytes); this.size += bytes.length; 53 } 54 // End-of-presentation, including after structured cancellation, not closing a 55 // borrowed stream. Incomplete suffixes become visible bytes, never IO failures. 56 finish(): void { this.ended = true; } 57 snapshot(): { done: boolean; size: number; output: OutputView; error: OutputView } { 58 return { done: this.ended, size: this.size, 59 output: view([...this.streams.output], this.ended), error: view([...this.streams.error], this.ended) }; 60 } 61 }