char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
8744a75
main
1import { Buffer , isUtf8 } from "node:buffer" ; 2import { createHash } from "node:crypto" ; 3import { structuredPatch } from "diff" ; 4import anchorData from "./anchor-pieces.json" with { type :"json" }; 5 6export const MAX_LINES = 2000 ; 7export const MAX_BYTES = 50 * 1024 ; 8 9export function hash ( line :string ) :string { 10const value = createHash ( "sha256" ). update ( line ). digest (). readUInt32BE ( 0 ); 11const prefixCount = anchorData . prefixes . length / 2 ; 12const prefix = ( value % prefixCount ) * 2 ; 13const suffix = ( Math . floor ( value / prefixCount ) % ( anchorData . suffixes . length / 2 )) * 2 ; 14return anchorData . prefixes . slice ( prefix , prefix + 2 ) + 15anchorData . suffixes . slice ( suffix , suffix + 2 ); 16} 17 18export type TextFile = { 19readonly raw :string ; 20readonly bom :string ; 21readonly text :string ; 22/** Lines with their line endings. */ 23readonly rows :readonly string []; 24readonly lines :readonly string []; 25}; 26 27/** The most text read or edit takes in. */ 28export const MAX_TEXT_BYTES = 50 * 1024 * 1024 ; 29 30export function decodeText ( content :Uint8Array ) :TextFile { 31const bytes = Buffer . from ( content . buffer , content . byteOffset , content . byteLength ); 32if ( ! isUtf8 ( bytes ) || bytes . includes ( 0 )) { 33throw new Error ( "Read and edit require UTF-8 text without NUL bytes." ); 34} 35const raw = bytes . toString ( "utf8" ); 36const bom = raw . startsWith ( "\uFEFF" ) ?"\uFEFF" :"" ; 37const text = raw . slice ( bom . length ); 38const rows = text . match ( / [^\n]*\n|[^\n]+$ / g ) ?? []; 39return { raw, bom, text, rows, lines :rows . map (( row ) => row . replace ( / \r?\n$ / , "" )) }; 40} 41 42/** How many leading rows fit in `maxBytes`, newlines between them included. */ 43export function fitting ( rows :readonly string [], maxBytes = MAX_BYTES , separatorBytes = 1 ) :number { 44let bytes = - separatorBytes ; 45for ( const [ i , row ] of rows . entries ()) { 46bytes += Buffer . byteLength ( row ) + separatorBytes ; 47if ( bytes > maxBytes ) return i ; 48} 49return rows . length ; 50} 51 52export function renderRead ( 53file :TextFile , 54offset = 1 , 55limit = MAX_LINES , 56hashes = true , 57) :string { 58const { lines} = file ; 59const start = offset - 1 ; 60if ( start > 0 && start >= lines . length ) throw new Error ( "Offset is beyond end of file." ); 61if ( ! lines . length ) { 62return hashes 63 ?"[Empty file; edit with old: [] to insert.]" 64 :'[Empty file; edit with oldText: "" to insert.]' ; 65} 66const end = start + Math . min ( limit , MAX_LINES ); 67const rows = hashes 68 ?lines . slice ( start , end ). map (( line ) => ` ${ hash ( line )} │ ${ line } ` ) 69 :file . rows . slice ( start , end ); 70const count = fitting ( rows , MAX_BYTES , hashes ?1 :0 ); 71if ( count === 0 ) throw new Error ( `Line ${ start + 1 } exceeds 50 KiB; use bash to inspect it.` ); 72const next = start + count ; 73const continuation = next < lines . length 74 ?`\n\n[Showing lines ${ start + 1 } - ${ next } of ${ lines . length } . Use offset= ${ 75next + 1 76} to continue.]` 77 :"" ; 78return rows . slice ( 0 , count ). join ( hashes ?"\n" :"" ) + continuation ; 79} 80 81export type HashlineEdit = { 82readonly before ?:readonly string []; 83readonly old :readonly string []; 84readonly after ?:readonly string []; 85readonly new :readonly string []; 86}; 87 88/** The edited file contents and a diff; `content === file.raw` when nothing changes. */ 89export function planEdit ( 90file :TextFile , 91edits :readonly ( HashlineEdit | { readonly oldText :string ; readonly newText :string })[], 92hashes = true , 93) :{ content :string ; diff :string [] } { 94const { bom, text, rows, lines} = file ; 95const anchors = edits . some (( edit ) => "old" in edit ) ?lines . map ( hash ) :[]; 96const eol = text . match ( / \r?\n / )?.[ 0 ] ?? "\n" ; 97const offsets = [ 0 ]; 98for ( const row of rows ) offsets . push ( offsets . at ( - 1 ) ! + row . length ); 99 100const changes = edits . map (( edit , index ) => { 101if ( "oldText" in edit ) { 102if ( / [\0] / . test ( edit . oldText + edit . newText )) { 103throw new Error ( `Edit ${ index + 1 } : text must not contain NUL.` ); 104} 105if ( ! edit . oldText && text ) { 106throw new Error ( `Edit ${ index + 1 } : empty oldText is allowed only for an empty file.` ); 107} 108const start = text . indexOf ( edit . oldText ); 109if ( start === - 1 ) { 110throw new Error ( 111`Edit ${ index + 1 } : oldText not found (stale reference); read the file again.` , 112); 113} 114if ( edit . oldText && text . indexOf ( edit . oldText , start + 1 ) !== - 1 ) { 115throw new Error ( `Edit ${ index + 1 } : ambiguous oldText; include more surrounding text.` ); 116} 117return { 118 start, 119end :start + edit . oldText . length , 120replacement :edit . newText , 121hashline :false , 122separate :false , 123}; 124} 125 126const before = edit . before ?? []; 127const sequence = [ ...before , ...edit . old , ...( edit . after ?? [])]; 128if ( ! sequence . length && lines . length ) { 129throw new Error ( `Edit ${ index + 1 } : supply old or adjacent context hashes.` ); 130} 131if ( edit . new . some (( line ) => / [\r\n\0] / . test ( line ))) { 132throw new Error ( `Edit ${ index + 1 } : new must contain individual lines, without CR/LF/NUL.` ); 133} 134let match = - 1 ; 135for ( let i = 0 ; i <= anchors . length - sequence . length ; i ++ ) { 136if ( ! sequence . every (( anchor , j ) => anchor === anchors [ i + j ])) continue ; 137if ( match !== - 1 ) { 138throw new Error ( `Edit ${ index + 1 } : ambiguous hashes; add adjacent before/after context.` ); 139} 140match = i ; 141} 142if ( match === - 1 ) { 143throw new Error ( 144`Edit ${ index + 1 } : hashes not found (stale reference); read the file again.` , 145); 146} 147const start = match + before . length ; 148const end = start + edit . old . length ; 149const replacement = edit . new . map (( line , i ) => { 150const row = start + i < end && line === lines [ start + i ] ?rows [ start + i ] :line + eol ; 151return i < edit . new . length - 1 && ! row . endsWith ( "\n" ) ?row + eol :row ; 152}). join ( "" ); 153return { 154start :offsets [ start ], 155end :offsets [ end ], 156 replacement, 157hashline :true , 158separate :start === rows . length , 159}; 160}). sort (( a , b ) => a . start - b . start ); 161 162for ( let i = 1 ; i < changes . length ; i ++ ) { 163if ( changes [ i ]. start < changes [ i - 1 ]. end || changes [ i ]. start === changes [ i - 1 ]. start ) { 164throw new Error ( "Overlapping edits (or insertions at the same position); merge them." ); 165} 166} 167 168const updated :string [] = []; 169let cursor = 0 ; 170for ( const { start, end, replacement, separate} of changes ) { 171updated . push ( text . slice ( cursor , start )); 172// Inserting after an unterminated last line needs a separator, not a joined line. 173if ( 174separate && replacement && updated . findLast (( part ) => part . length )?. endsWith ( "\n" ) === false 175) { 176updated . push ( eol ); 177} 178updated . push ( replacement ); 179cursor = end ; 180} 181updated . push ( text . slice ( cursor )); 182let result = updated . join ( "" ); 183const last = changes . at ( - 1 ); 184if ( 185last ?. hashline && last . end === text . length && text && ! text . endsWith ( "\n" ) && 186result !== eol && ! result . endsWith ( "\n" + eol ) 187) { 188result = result . replace ( / \r?\n$ / , "" ); 189} 190 191const diff = structuredPatch ( "" , "" , text , result , "" , "" , { context :3 }). hunks . flatMap (( 192hunk , 193) => [ 194`@@ original line ${ hunk . oldStart } @@` , 195 ...hunk . lines . flatMap (( row , i ) => { 196if ( row . startsWith ( "\\" )) return []; 197const line = hunk . lines [ i + 1 ]?. startsWith ( "\\" ) 198 ?row . slice ( 1 ) 199 :row . slice ( 1 ). replace ( / \r$ / , "" ); 200return [ 201row . startsWith ( "+" ) || row . startsWith ( "-" ) 202 ?` ${ row [ 0 ]} ${ hashes ? ` ${ hash ( line )} │` : "" } ${ line } ` 203 :` ${ line } ` , 204]; 205}), 206]); 207return { content :bom + result , diff}; 208}