char-slop/ai-dots
ai dotfiles
git clone https://git.t4t.associates/char-slop/ai-dots
1567399
main
1import { spawn } from "node:child_process" ; 2import { mkdtemp , readFile , rm } from "node:fs/promises" ; 3import { tmpdir } from "node:os" ; 4import { join } from "node:path" ; 5import { fileURLToPath } from "node:url" ; 6import { Type } from "@earendil-works/pi-ai" ; 7import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" ; 8import { filePath , hash , readText } from "./hashline.ts" ; 9 10const binary = fileURLToPath ( new URL ( "../../libexec/visualize-code" , import . meta. url )); 11const sequence = Type . Array ( Type . String ({ pattern :"^[A-Za-z]{4}$" }), { minItems :1 }); 12 13function matches ( anchors :string [], sequence :string []) :number [] { 14return anchors . flatMap (( _ , i ) => ( sequence . every (( anchor , j ) => anchor === anchors [ i + j ]) ?[ i ] :[])); 15} 16 17export default function ( pi :ExtensionAPI ) { 18pi . registerTool ({ 19name :"visualize_code" , 20label :"visualize_code" , 21description : 22"Render a range of a UTF-8 text file as a syntax-highlighted image, with each line's hash " + 23"in the gutter (the same hashes read shows and edit accepts) and ↪ marking soft-wrapped " + 24"continuations. start is a consecutive hash sequence that must match exactly once; the range " + 25"begins at its first line. end is a consecutive hash sequence; the range ends at the last line " + 26"of its first match at or after start. Without start, the range begins at the top of the file; " + 27"without end, it continues to the end of the file. Ranges that don't fit in one image are cut " + 28"off at a line boundary, with a start to continue from." , 29promptSnippet :"Render file ranges, selected by content hashes, as syntax-highlighted images" , 30parameters :Type . Object ({ 31path :Type . String (), 32start :Type . Optional ( sequence ), 33end :Type . Optional ( sequence ), 34}, { additionalProperties :false }), 35async execute ( _id , args , signal , _onUpdate , ctx ) { 36const path = filePath ( args . path , ctx . cwd ); 37const { text, lines} = await readText ( path , signal ); 38if ( ! lines . length ) throw new Error ( "File is empty." ); 39const anchors = lines . map ( hash ); 40 41const starts = args . start ?matches ( anchors , args . start ) :[ 0 ]; 42if ( starts . length !== 1 ) { 43throw new Error ( starts . length 44 ?"start is ambiguous; add adjacent hashes." 45 :"start hashes not found (stale reference); read the file again." ); 46} 47const start = starts [ 0 ]; 48let end = lines . length ; 49if ( args . end ) { 50const match = matches ( anchors , args . end ). find (( i ) => i >= start ); 51if ( match === undefined ) { 52throw new Error ( "end hashes not found at or after start (stale reference?); read the file again." ); 53} 54end = match + args . end . length ; 55} 56// Bounds argv; still far more lines than fit in one image. 57const labels = anchors . slice ( start , Math . min ( end , start + 2000 )); 58 59const dir = await mkdtemp ( join ( tmpdir (), "visualize-code-" )); 60try { 61const image = join ( dir , "code.png" ); 62const shown = await new Promise < number >(( resolve , reject ) => { 63const child = spawn ( binary , [ image , path , String ( start + 1 ), ...labels ], { signal}); 64let stdout = "" ; 65let stderr = "" ; 66child . stdout . on ( "data" , ( chunk ) => ( stdout += chunk )); 67child . stderr . on ( "data" , ( chunk ) => ( stderr += chunk )); 68child . on ( "error" , ( error :NodeJS . ErrnoException ) => reject ( error . code === "ENOENT" 69 ?new Error ( ` ${ binary } is missing; build src/visualize-code and copy the binary there.` ) 70 :error )); 71child . on ( "close" , ( code ) => code === 0 72 ?resolve ( Number ( stdout )) 73 :reject ( new Error ( stderr . trim () || `visualize-code exited with code ${ code } ` ))); 74// Failures surface through the exit status, so an early exit shouldn't crash us with EPIPE. 75child . stdin . on ( "error" , () => {}); 76child . stdin . end ( text ); 77}); 78 79const next = start + shown ; 80let summary = `Lines ${ start + 1 } - ${ next } of ${ lines . length } .` ; 81if ( next < end ) { 82const continuation = [ 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 ] 83. map (( length ) => anchors . slice ( next , next + length )) 84. find (( candidate ) => matches ( anchors , candidate ). length === 1 ); 85summary += continuation 86 ?` Continue with start: ${ JSON . stringify ( continuation )} ${ args . end ? " and the same end" : "" } .` 87 :` No short hash sequence uniquely identifies line ${ next + 1 } ; use read with offset= ${ next + 1 } to continue.` ; 88} 89return { 90content :[ 91{ type :"text" , text :summary }, 92{ type :"image" , data :( await readFile ( image )). toString ( "base64" ), mimeType :"image/png" }, 93], 94details :{}, 95}; 96} finally { 97await rm ( dir , { recursive :true , force :true }); 98} 99}, 100}); 101}