char-slop/ai-dots
ai dotfiles
git clone https://git.t4t.associates/char-slop/ai-dots
bfafa36
main
1import { spawn } from "node:child_process" ; 2import { createHash } from "node:crypto" ; 3import { readdirSync , statSync } from "node:fs" ; 4import { createConnection } from "node:net" ; 5import { join } from "node:path" ; 6import { StringEnum , Type , type ImageContent , type TextContent } from "@earendil-works/pi-ai" ; 7import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" ; 8import { Text } from "@earendil-works/pi-tui" ; 9 10const runtimeDir = process . env . XDG_RUNTIME_DIR ?? `/run/user/ ${ process . getuid ?. ()} ` ; 11 12interface ComputerUseDetails { 13screenshotIds :string []; 14} 15 16function waylandEnv () { 17const configured = process . env . WAYLAND_DISPLAY ; 18if ( configured ) { 19try { 20if ( statSync ( join ( runtimeDir , configured )). isSocket ()) { 21return { ...process . env , XDG_RUNTIME_DIR :runtimeDir , WAYLAND_DISPLAY :configured }; 22} 23} catch {} 24} 25 26const display = readdirSync ( runtimeDir ) 27. filter (( name ) => / ^wayland-\d+$ / . test ( name )) 28. map (( name ) => ({ name, modified :statSync ( join ( runtimeDir , name )). mtimeMs })) 29. sort (( a , b ) => b . modified - a . modified )[ 0 ]?. name ; 30 31if ( ! display ) throw new Error ( `No Wayland compositor found in ${ runtimeDir } ` ); 32return { ...process . env , XDG_RUNTIME_DIR :runtimeDir , WAYLAND_DISPLAY :display }; 33} 34 35function wait ( ms :number , signal ?:AbortSignal ) :Promise < void > { 36return new Promise (( resolve , reject ) => { 37if ( signal ?. aborted ) { 38reject ( new Error ( "Operation aborted" )); 39return ; 40} 41 42const timer = setTimeout (() => { 43signal ?. removeEventListener ( "abort" , abort ); 44resolve (); 45}, ms ); 46const abort = () => { 47clearTimeout ( timer ); 48reject ( new Error ( "Operation aborted" )); 49}; 50signal ?. addEventListener ( "abort" , abort , { once :true }); 51}); 52} 53 54function run ( command :string , args :string [], signal ?:AbortSignal ) :Promise < Buffer > { 55return new Promise (( resolve , reject ) => { 56if ( signal ?. aborted ) { 57reject ( new Error ( "Operation aborted" )); 58return ; 59} 60 61const child = spawn ( command , args , { 62env :waylandEnv (), 63stdio :[ "ignore" , "pipe" , "pipe" ], 64}); 65const stdout :Buffer [] = []; 66const stderr :Buffer [] = []; 67const abort = () => child . kill (); 68 69child . stdout . on ( "data" , ( chunk :Buffer ) => stdout . push ( chunk )); 70child . stderr . on ( "data" , ( chunk :Buffer ) => stderr . push ( chunk )); 71child . on ( "error" , reject ); 72child . on ( "close" , ( code ) => { 73signal ?. removeEventListener ( "abort" , abort ); 74if ( signal ?. aborted ) reject ( new Error ( "Operation aborted" )); 75else if ( code === 0 ) resolve ( Buffer . concat ( stdout )); 76else { 77reject ( 78new Error ( 79Buffer . concat ( stderr ). toString (). trim () || ` ${ command } exited with code ${ code } ` , 80), 81); 82} 83}); 84 85signal ?. addEventListener ( "abort" , abort , { once :true }); 86}); 87} 88 89async function connectVnc ( signal ?:AbortSignal ) { 90const socket = createConnection ({ host :"127.0.0.1" , port :5900 , signal}); 91socket . on ( "error" , () => {}); 92socket . setTimeout ( 5_000 , () => socket . destroy ( new Error ( "WayVNC handshake timed out" ))); 93const chunks = socket . iterator ({ destroyOnReturn :false }); 94let buffered = Buffer . alloc ( 0 ); 95 96async function read ( length :number ) :Promise < Buffer > { 97while ( buffered . length < length ) { 98const chunk = await chunks . next (); 99if ( chunk . done ) throw new Error ( "WayVNC disconnected during handshake" ); 100buffered = Buffer . concat ([ buffered , chunk . value ]); 101} 102const result = buffered . subarray ( 0 , length ); 103buffered = buffered . subarray ( length ); 104return result ; 105} 106 107try { 108if (( await read ( 12 )). toString () !== "RFB 003.008\n" ) { 109throw new Error ( "Expected WayVNC to support RFB 3.8" ); 110} 111socket . write ( "RFB 003.008\n" ); 112const securityTypes = await read (( await read ( 1 ))[ 0 ]); 113if ( ! securityTypes . includes ( 1 )) { 114throw new Error ( "Local WayVNC must allow unauthenticated connections" ); 115} 116socket . write ( Buffer . from ([ 1 ])); 117if (( await read ( 4 )). readUInt32BE () !== 0 ) { 118throw new Error ( "WayVNC rejected the connection" ); 119} 120socket . write ( Buffer . from ([ 1 ])); // Share the desktop with existing VNC clients. 121await read ( 24 ); 122await chunks . return ?.(); 123socket . setTimeout ( 0 ); 124socket . resume (); 125 126// Give Firefox time to bind the seat's newly advertised input devices. 127await wait ( 100 , signal ); 128if ( socket . destroyed ) throw socket . errored ?? new Error ( "WayVNC disconnected" ); 129return socket ; 130} catch ( error ) { 131socket . destroy (); 132throw error ; 133} 134} 135 136export default function ( pi :ExtensionAPI ) { 137if ( ! process . env . WAYLAND_DISPLAY ) return ; 138 139const visibleScreenshotResults = new Set < string >(); 140pi . on ( "agent_end" , () => visibleScreenshotResults . clear ()); 141pi . on ( "context" , ( event ) => ({ 142messages :event . messages . map (( message ) => { 143if ( 144message . role !== "toolResult" || 145message . toolName !== "computer_use" || 146visibleScreenshotResults . has ( message . toolCallId ) 147) { 148return message ; 149} 150 151return { ...message , content :message . content . filter (( content ) => content . type !== "image" ) }; 152}), 153})); 154 155pi . registerTool ({ 156name :"computer_use" , 157label :"Computer Use" , 158description : 159"Interact with visible desktop applications. Prefer web_search and web_fetch for ordinary web research and page reading because they return text directly; use the GUI browser when visual or interactive access is useful. Coordinates are absolute across the current 1920×1080 screen, from (0, 0) at top-left to (1919, 1079) at bottom-right. Take a screenshot before and after interacting; use separate calls when an intermediate screenshot is needed." , 160promptSnippet : 161"Interact with visible desktop applications; prefer textual web tools for ordinary research" , 162promptGuidelines :[ 163"Prefer web_search/web_fetch over opening Firefox and reading screenshots when either textual tool can handle the task adequately." , 164"All coordinates are absolute screen positions on a 1920×1080 screen, not positions relative to a window, element, screenshot crop, or the current pointer." , 165"Screenshots remain available throughout the current turn. Take another after interacting with the computer, or recall one from an earlier turn by its ID." , 166"Recalled screenshots are not visible until the entire action sequence completes." , 167], 168parameters :Type . Object ( 169{ 170actions :Type . Array ( 171Type . Union ([ 172Type . Object ( 173{ 174action :StringEnum ([ "move" ] as const ), 175x :Type . Integer ({ 176minimum :0 , 177maximum :1919 , 178description :"Absolute screen x-coordinate: 0 is left, 1919 is right" , 179}), 180y :Type . Integer ({ 181minimum :0 , 182maximum :1079 , 183description :"Absolute screen y-coordinate: 0 is top, 1079 is bottom" , 184}), 185}, 186{ additionalProperties :false }, 187), 188Type . Object ( 189{ 190action :StringEnum ([ "click" ] as const ), 191x :Type . Optional ( 192Type . Integer ({ 193minimum :0 , 194maximum :1919 , 195description : 196"Absolute screen x-coordinate (0 left to 1919 right); omit x and y to click in place" , 197}), 198), 199y :Type . Optional ( 200Type . Integer ({ 201minimum :0 , 202maximum :1079 , 203description : 204"Absolute screen y-coordinate (0 top to 1079 bottom); omit x and y to click in place" , 205}), 206), 207button :Type . Optional ( 208StringEnum ([ "left" , "middle" , "right" ] as const , { default :"left" }), 209), 210}, 211{ additionalProperties :false }, 212), 213Type . Object ( 214{ 215action :StringEnum ([ "scroll" ] as const ), 216x :Type . Optional ( 217Type . Integer ({ 218minimum :0 , 219maximum :1919 , 220description : 221"Absolute screen x-coordinate (0 left to 1919 right); x and y must be supplied together" , 222}), 223), 224y :Type . Optional ( 225Type . Integer ({ 226minimum :0 , 227maximum :1079 , 228description : 229"Absolute screen y-coordinate (0 top to 1079 bottom); x and y must be supplied together" , 230}), 231), 232deltaX :Type . Optional ( Type . Integer ({ description :"Horizontal scroll amount" })), 233deltaY :Type . Optional ( 234Type . Integer ({ description :"Vertical scroll amount; positive scrolls down" }), 235), 236}, 237{ additionalProperties :false }, 238), 239Type . Object ( 240{ 241action :StringEnum ([ "type" ] as const ), 242text :Type . String ({ 243description :"Literal text to type; use a key action for named keys and shortcuts" , 244}), 245}, 246{ additionalProperties :false }, 247), 248Type . Object ( 249{ 250action :StringEnum ([ "key" ] as const ), 251key :Type . String ({ 252description : 253"One XKB key name, such as Return, Escape, Tab, BackSpace, Delete, Left, Page_Down, F5, or a. Put shortcut modifiers in modifiers; do not put them in key" , 254pattern :"^[A-Za-z0-9_]+$" , 255}), 256modifiers :Type . Optional ( 257Type . Array ( StringEnum ([ "shift" , "ctrl" , "alt" , "logo" ] as const ), { 258description :"Modifiers held while pressing the key" , 259uniqueItems :true , 260}), 261), 262}, 263{ additionalProperties :false }, 264), 265Type . Object ( 266{ 267action :StringEnum ([ "new_browser_tab" ] as const ), 268url :Type . String ({ 269description : 270"HTTP or HTTPS URL to open in a new Firefox tab every time; prefer web_fetch for ordinary page reading" , 271pattern :"^https?://" , 272}), 273}, 274{ additionalProperties :false }, 275), 276Type . Object ( 277{ 278action :StringEnum ([ "recall" ] as const ), 279id :Type . String ({ 280description :"ID from an earlier screenshot result" , 281pattern :"^sc_[0-9a-f]{16}$" , 282}), 283}, 284{ additionalProperties :false }, 285), 286Type . Object ( 287{ action :StringEnum ([ "screenshot" ] as const ) }, 288{ additionalProperties :false }, 289), 290Type . Object ( 291{ 292action :StringEnum ([ "sleep" ] as const ), 293ms :Type . Integer ({ 294minimum :0 , 295maximum :30_000 , 296description :"Time to wait, in milliseconds" , 297}), 298}, 299{ additionalProperties :false }, 300), 301]), 302{ minItems :1 , maxItems :20 }, 303), 304}, 305{ additionalProperties :false }, 306), 307renderCall ( args , theme ) { 308const actions = args . actions . map (( action ) => { 309if ( action . action === "move" ) return `move ( ${ action . x ?? "…" } , ${ action . y ?? "…" } )` ; 310if ( action . action === "click" ) { 311const position = 312action . x === undefined && action . y === undefined 313 ?"" 314 :` ( ${ action . x ?? "…" } , ${ action . y ?? "…" } )` ; 315return `click ${ action . button ?? "left" } ${ position } ` ; 316} 317if ( action . action === "scroll" ) return `scroll ( ${ action . deltaX ?? 0 } , ${ action . deltaY ?? 0 } )` ; 318if ( action . action === "type" ) return `type ${ JSON . stringify ( action . text ?? "" )} ` ; 319if ( action . action === "key" ) return `press ${[ ... ( action . modifiers ?? []), action . key ?? "…" ]. join ( "+" )} ` ; 320if ( action . action === "new_browser_tab" ) return `new tab ${ action . url ?? "…" } ` ; 321if ( action . action === "recall" ) return `recall ${ action . id ?? "…" } ` ; 322if ( action . action === "screenshot" ) return "screenshot" ; 323return `wait ${ action . ms ?? 0 } ms` ; 324}); 325return new Text ( 326theme . fg ( "toolTitle" , theme . bold ( "computer_use " )) + 327theme . fg ( "accent" , actions . join ( " → " )), 3280 , 3290 , 330); 331}, 332async execute ( toolCallId , params , signal , _onUpdate , ctx ) { 333const screenshots :{ id :string ; image :ImageContent ; recalled :boolean }[] = []; 334 335// WayVNC keeps seat capabilities stable while wlrctl/wtype come and go. 336const vnc = params . actions . some (( action ) => 337[ "move" , "click" , "scroll" , "type" , "key" ]. includes ( action . action ), 338) 339 ?await connectVnc ( signal ) 340 :undefined ; 341try { 342for ( const action of params . actions ) { 343if ( vnc ?. destroyed ) throw vnc . errored ?? new Error ( "WayVNC disconnected" ); 344if ( action . action === "move" ) { 345await run ( "wlrctl" , [ "pointer" , "move" , "-100000" , "-100000" ], signal ); 346await run ( "wlrctl" , [ "pointer" , "move" , String ( action . x ), String ( action . y )], signal ); 347} else if ( action . action === "click" ) { 348const hasPosition = action . x !== undefined || action . y !== undefined ; 349if ( hasPosition && ( action . x === undefined || action . y === undefined )) { 350throw new Error ( "x and y must be supplied together" ); 351} 352if ( hasPosition ) { 353await run ( "wlrctl" , [ "pointer" , "move" , "-100000" , "-100000" ], signal ); 354await run ( "wlrctl" , [ "pointer" , "move" , String ( action . x ), String ( action . y )], signal ); 355} 356await run ( "wlrctl" , [ "pointer" , "click" , action . button ?? "left" ], signal ); 357} else if ( action . action === "scroll" ) { 358const hasPosition = action . x !== undefined || action . y !== undefined ; 359if ( hasPosition && ( action . x === undefined || action . y === undefined )) { 360throw new Error ( "x and y must be supplied together" ); 361} 362if ( hasPosition ) { 363await run ( "wlrctl" , [ "pointer" , "move" , "-100000" , "-100000" ], signal ); 364await run ( "wlrctl" , [ "pointer" , "move" , String ( action . x ), String ( action . y )], signal ); 365} 366await run ( 367"wlrctl" , 368[ "pointer" , "scroll" , String ( action . deltaY ?? 0 ), String ( action . deltaX ?? 0 )], 369signal , 370); 371} else if ( action . action === "type" ) { 372await run ( "wtype" , [ "--" , action . text ], signal ); 373} else if ( action . action === "key" ) { 374await run ( 375"wtype" , 376[ ...( action . modifiers ?? []). flatMap (( modifier ) => [ "-M" , modifier ]), "-k" , action . key ], 377signal , 378); 379} else if ( action . action === "new_browser_tab" ) { 380const url = new URL ( action . url ); 381if ( url . protocol !== "http:" && url . protocol !== "https:" ) { 382throw new Error ( "Only HTTP and HTTPS URLs are supported" ); 383} 384 385const child = spawn ( "firefox" , [ "--new-tab" , url . href ], { 386env :{ ...waylandEnv (), MOZ_ENABLE_WAYLAND :"1" }, 387detached :true , 388stdio :"ignore" , 389}); 390await new Promise < void >(( resolve , reject ) => { 391child . once ( "spawn" , resolve ); 392child . once ( "error" , reject ); 393}); 394child . unref (); 395} else if ( action . action === "recall" ) { 396const branch = ctx . sessionManager . getBranch (); 397let image :ImageContent | undefined ; 398 399for ( let index = branch . length - 1 ; index >= 0 && ! image ; index -- ) { 400const entry = branch [ index ]; 401if ( 402entry . type !== "message" || 403entry . message . role !== "toolResult" || 404entry . message . toolName !== "computer_use" 405) { 406continue ; 407} 408 409const screenshotIds = ( entry . message . details as ComputerUseDetails | undefined ) 410?. screenshotIds ; 411if ( ! Array . isArray ( screenshotIds )) continue ; 412 413const imageIndex = screenshotIds . indexOf ( action . id ); 414if ( imageIndex === - 1 ) continue ; 415image = entry . message . content . filter (( content ) => content . type === "image" )[ imageIndex ]; 416} 417 418if ( ! image ) throw new Error ( `Screenshot ${ action . id } was not found in this session branch` ); 419screenshots . push ({ id :action . id , image, recalled :true }); 420} else if ( action . action === "screenshot" ) { 421const png = await run ( "grim" , [ "-c" , "-" ], signal ); 422screenshots . push ({ 423id :`sc_ ${ createHash ( "sha256" ). update ( png ). digest ( "hex" ). slice ( 0 , 16 )} ` , 424image :{ 425type :"image" , 426data :png . toString ( "base64" ), 427mimeType :"image/png" , 428}, 429recalled :false , 430}); 431} else { 432await wait ( action . ms ?? 0 , signal ); 433} 434} 435 436if ( vnc ?. destroyed ) throw vnc . errored ?? new Error ( "WayVNC disconnected" ); 437} finally { 438if ( vnc ) { 439// Let clients process the last input before removing the seat's devices. 440await wait ( 100 ); 441vnc . destroy (); 442} 443} 444 445const content :( TextContent | ImageContent )[] = [ 446{ type :"text" , text :` ${ params . actions . length } actions completed.` }, 447]; 448for ( const screenshot of screenshots ) { 449content . push ( 450{ 451type :"text" , 452text :`Screenshot ${ screenshot . id } ( ${ screenshot . recalled ? "recalled" : "captured" } ):` , 453}, 454screenshot . image , 455); 456} 457 458if ( screenshots . length ) visibleScreenshotResults . add ( toolCallId ); 459return { 460 content, 461details :{ screenshotIds :screenshots . map (({ id}) => id ) } satisfies ComputerUseDetails , 462}; 463}, 464}); 465}