char-slop/ai-dots
ai dotfiles
git clone https://git.t4t.associates/char-slop/ai-dots
9720b9d
main
1import { Type } from "@earendil-works/pi-ai" ; 2import { 3createReadTool , 4type ExtensionAPI , 5keyHint , 6truncateHead , 7withFileMutationQueue , 8} from "@earendil-works/pi-coding-agent" ; 9import { Text } from "@earendil-works/pi-tui" ; 10import { isUtf8 } from "node:buffer" ; 11import { createHash } from "node:crypto" ; 12import { readFile , stat , writeFile } from "node:fs/promises" ; 13import { homedir } from "node:os" ; 14import { resolve } from "node:path" ; 15 16import anchorData from "./anchor-pieces.json" with { type :"json" }; 17 18function filePath ( path :string , cwd :string ) :string { 19return resolve ( cwd , path === "~" ?homedir () :path . replace ( / ^~\/ / , ` ${ homedir ()} /` )); 20} 21 22function hash ( line :string ) :string { 23const value = createHash ( "sha256" ). update ( line ). digest (). readUInt32BE ( 0 ); 24const prefixCount = anchorData . prefixes . length / 2 ; 25const prefix = ( value % prefixCount ) * 2 ; 26const suffix = ( Math . floor ( value / prefixCount ) % ( anchorData . suffixes . length / 2 )) * 2 ; 27return anchorData . prefixes . slice ( prefix , prefix + 2 ) + anchorData . suffixes . slice ( suffix , suffix + 2 ); 28} 29 30async function readText ( path :string , signal ?:AbortSignal ) { 31signal ?. throwIfAborted (); 32const info = await stat ( path ); 33if ( ! info . isFile ()) throw new Error ( "Not a regular file." ); 34if ( info . size > 50 * 1024 * 1024 ) throw new Error ( "File exceeds the 50 MiB edit/read limit." ); 35const bytes = await readFile ( path , { signal}); 36if ( ! isUtf8 ( bytes ) || bytes . includes ( 0 )) { 37throw new Error ( "Hashline requires UTF-8 text without NUL bytes." ); 38} 39const raw = bytes . toString ( "utf8" ); 40const bom = raw . startsWith ( "\uFEFF" ) ?"\uFEFF" :"" ; 41const text = raw . slice ( bom . length ); 42const rows = text . match ( / [^\n]*\n|[^\n]+$ / g ) ?? []; 43return { raw, bom, text, rows, lines :rows . map (( row ) => row . replace ( / \r?\n$ / , "" )) }; 44} 45 46const hashes = Type . Array ( Type . String ({ pattern :"^[A-Za-z]{4}$" })); 47 48export default function ( pi :ExtensionAPI ) { 49pi . registerTool ({ 50name :"read" , 51label :"read" , 52description : 53"Read UTF-8 text as hash│content, using four-letter tokenizer-friendly hashes of exact line " + 54"content, independent of position. Duplicate lines and hash collisions share anchors; " + 55"use adjacent context with edit to disambiguate them. Supports images with jpg, jpeg, " + 56"png, gif, webp, or bmp extensions. Text output is capped at 2000 lines / 50 KiB; " + 57"use offset/limit to page. Text files over 50 MiB are rejected." , 58promptSnippet :"Read file contents with position-independent content hashes; supports images" , 59parameters :Type . Object ({ 60path :Type . String (), 61offset :Type . Optional ( Type . Integer ({ minimum :1 , description :"First line (1-indexed)." })), 62limit :Type . Optional ( Type . Integer ({ minimum :1 , description :"Maximum lines to return." })), 63}), 64async execute ( id , args , signal , onUpdate , ctx ) { 65const path = filePath ( args . path , ctx . cwd ); 66if ( / \.(jpe?g|png|gif|webp|bmp)$ / i . test ( path )) { 67return createReadTool ( ctx . cwd ). execute ( id , { ...args , path}, signal , onUpdate ); 68} 69const { lines} = await readText ( path , signal ); 70const start = ( args . offset ?? 1 ) - 1 ; 71if ( start > 0 && start >= lines . length ) throw new Error ( "Offset is beyond end of file." ); 72if ( ! lines . length ) { 73return { content :[{ type :"text" , text :"[Empty file; edit with old: [] to insert.]" }], details :{} }; 74} 75const selected = lines . slice ( start , start + Math . min ( args . limit ?? 2000 , 2000 )); 76const output = truncateHead ( selected . map (( line ) => ` ${ hash ( line )} │ ${ line } ` ). join ( "\n" )); 77if ( output . firstLineExceedsLimit ) { 78throw new Error ( `Line ${ start + 1 } exceeds 50 KiB; use bash to inspect it.` ); 79} 80const next = start + output . outputLines ; 81const continuation = next < lines . length 82 ?`\n\n[Showing lines ${ start + 1 } - ${ next } of ${ lines . length } . Use offset= ${ next + 1 } to continue.]` 83 :"" ; 84return { 85content :[{ type :"text" , text :output . content + continuation }], 86details :{ truncation :output , nextOffset :next < lines . length ?next + 1 :undefined }, 87}; 88}, 89}); 90 91pi . registerTool ({ 92name :"edit" , 93label :"edit" , 94description : 95"Edit a file using content hashes from read. Each edit matches the consecutive sequence " + 96"before + old + after exactly once in the original file, then replaces only old with new. " + 97"Include every removed line's hash in old. Add adjacent before/after hashes to disambiguate " + 98"duplicate lines or hash collisions; context is preserved. old: [] inserts; new: [] deletes. An empty selector " + 99"is allowed only for an empty file. New lines are literal, without hash prefixes or embedded " + 100"CR/LF/NUL. All edits are validated before writing; ambiguous, stale, or overlapping targets fail. " + 101"Batch separate edits to the same file in one call. In results, + rows carry new hashes; - rows are removed lines." , 102promptSnippet :"Edit files by content hashes; supports batched replacements, insertions, and deletions" , 103renderShell :"default" , 104renderCall ( args , theme ) { 105return new Text ( 106theme . fg ( "toolTitle" , theme . bold ( "edit" )) + " " + theme . fg ( "accent" , args . path ?? "..." ), 1070 , 0 , 108); 109}, 110renderResult ( result , { expanded}, theme , context ) { 111const lines = result . content . filter (( part ) => part . type === "text" ) 112. map (( part ) => part . text ). join ( "\n" ). split ( "\n" ); 113const visible = expanded ?lines :lines . slice ( 0 , 10 ); 114let output = visible . map (( line ) => theme . fg ( 115context . isError ?"error" :line . startsWith ( "+" ) ?"toolDiffAdded" : 116line . startsWith ( "-" ) ?"toolDiffRemoved" :"toolOutput" , 117context . isError ?line :line . replace ( / ^([+-])(?:[A-Za-z]{4}|[0-9a-f]{16})│ / , "$1" ), 118)). join ( "\n" ); 119if ( visible . length < lines . length ) { 120output += theme . fg ( "muted" , `\n... ( ${ lines . length - visible . length } more lines, ${ keyHint ( "app.tools.expand" , "to expand" )} )` ); 121} 122return new Text ( output , 0 , 0 ); 123}, 124parameters :Type . Object ({ 125path :Type . String (), 126edits :Type . Array ( Type . Object ({ 127before :Type . Optional ( hashes ), 128old :hashes , 129after :Type . Optional ( hashes ), 130new :Type . Array ( Type . String ({ pattern :"^[^\r\n\u0000]*$" })), 131}, { additionalProperties :false }), { minItems :1 }), 132}, { additionalProperties :false }), 133async execute ( _id , { path :inputPath , edits}, signal , _onUpdate , ctx ) { 134const path = filePath ( inputPath , ctx . cwd ); 135return withFileMutationQueue ( path , async () => { 136const { raw, bom, text, rows, lines} = await readText ( path , signal ); 137const anchors = lines . map ( hash ); 138const eol = text . match ( / \r?\n / )?.[ 0 ] ?? "\n" ; 139const changes = edits . map (( edit , index ) => { 140const before = edit . before ?? []; 141const sequence = [ ...before , ...edit . old , ...( edit . after ?? [])]; 142if ( ! sequence . length && lines . length ) { 143throw new Error ( `Edit ${ index + 1 } : supply old or adjacent context hashes.` ); 144} 145if ( edit . new . some (( line ) => / [\r\n\0] / . test ( line ))) { 146throw new Error ( `Edit ${ index + 1 } : new must contain individual lines, without CR/LF/NUL.` ); 147} 148let match = - 1 ; 149for ( let i = 0 ; i <= anchors . length - sequence . length ; i ++ ) { 150if ( ! sequence . every (( anchor , j ) => anchor === anchors [ i + j ])) continue ; 151if ( match !== - 1 ) { 152throw new Error ( `Edit ${ index + 1 } : ambiguous hashes; add adjacent before/after context.` ); 153} 154match = i ; 155} 156if ( match === - 1 ) { 157throw new Error ( `Edit ${ index + 1 } : hashes not found (stale reference); read the file again.` ); 158} 159const start = match + before . length ; 160return { start, end :start + edit . old . length , replacement :edit . new }; 161}). sort (( a , b ) => a . start - b . start ); 162 163for ( let i = 1 ; i < changes . length ; i ++ ) { 164if ( changes [ i ]. start < changes [ i - 1 ]. end || changes [ i ]. start === changes [ i - 1 ]. start ) { 165throw new Error ( "Overlapping edits (or insertions at the same position); merge them." ); 166} 167} 168 169const updated :string [] = []; 170const diff :string [] = []; 171let cursor = 0 ; 172for ( const { start, end, replacement} of changes ) { 173for ( let i = cursor ; i < start ; i ++ ) updated . push ( rows [ i ]); 174for ( const [ i , line ] of replacement . entries ()) { 175updated . push ( start + i < end && line === lines [ start + i ] ?rows [ start + i ] :line + eol ); 176} 177diff . push ( `@@ original line ${ start + 1 } @@` ); 178for ( let i = start ; i < end ; i ++ ) diff . push ( `- ${ anchors [ i ]} │ ${ lines [ i ]} ` ); 179for ( const line of replacement ) diff . push ( `+ ${ hash ( line )} │ ${ line } ` ); 180cursor = end ; 181} 182for ( let i = cursor ; i < rows . length ; i ++ ) updated . push ( rows [ i ]); 183// Inserting after an unterminated last line needs a separator, not a joined line. 184let result = updated . map (( row , i ) => 185i < updated . length - 1 && ! row . endsWith ( "\n" ) ?row + eol :row , 186). join ( "" ); 187if ( text && ! text . endsWith ( "\n" ) && updated . at ( - 1 ) !== eol ) { 188result = result . replace ( / \r?\n$ / , "" ); 189} 190const content = bom + result ; 191if ( content === raw ) { 192return { content :[{ type :"text" , text :"No changes made." }], details :{} }; 193} 194signal ?. throwIfAborted (); 195// The shared queue also serializes this against Pi's built-in write tool. 196if ( ! ( await readFile ( path )). equals ( Buffer . from ( raw ))) { 197throw new Error ( "File changed while preparing the edit; read it again." ); 198} 199signal ?. throwIfAborted (); 200await writeFile ( path , content , "utf8" ); 201const preview = truncateHead ( diff . join ( "\n" )); 202return { 203content :[{ 204type :"text" , 205text :`Applied ${ changes . length } edit(s).\n\n` + 206preview . content + ( preview . truncated ?"\n[Diff truncated; read for more context.]" :"" ), 207}], 208details :{}, 209}; 210}); 211}, 212}); 213}