char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
bcca6b5
main
1// The machine each conversation's files and commands are on: a binding its history keeps, and the environments 2// bindings name. 3 4import type { Context , JsonValue } from "@earendil-works/chord" ; 5import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context" ; 6import { homedir } from "node:os" ; 7import { Type } from "@earendil-works/pi-ai" ; 8import { defineExtension } from "@berry/api" ; 9import { directory , type Environment , LOCAL , localEnvironment } from "@berry/api/environment.ts" ; 10import { EventEntry } from "./entries.ts" ; 11import { Connection , RemoteEnvironment , sshCommand } from "./remote/connection.ts" ; 12export const ENVIRONMENT = "environment" ; 13 14/** Where a conversation works; a `cwd` of `null` is the environment's home. */ 15export type Binding = { environment :string ; cwd :string | null }; 16export type BindingEvent = { type :"bind" ; environment :string ; cwd :string }; 17 18/** Opens the environments bindings name. */ 19export type Environments = { 20/** Throws for a name it does not know, or a machine it cannot reach. */ 21open ( name :string , cwd :string | null , context :Context ) :Promise < Environment >; 22/** Let go of every connection. */ 23close () :Promise < void >; 24}; 25 26/** Only the machine berry runs on. */ 27export const localOnly :Environments = { 28open ( name , cwd ) { 29if ( name !== LOCAL ) return Promise . reject ( new Error ( `No environment named ${ name } ` )); 30return Promise . resolve ( localEnvironment ( cwd ?? homedir ())); 31}, 32close :() => Promise . resolve (), 33}; 34 35/** A machine settings name, which berry reaches over ssh. */ 36export type EnvironmentSettings = { 37/** What `ssh` connects to: a host from its config, `user@host`, or an `ssh://` url. */ 38readonly ssh :string ; 39/** How the far side runs deno, which the worker needs; default `deno`. */ 40readonly deno ?:string ; 41}; 42 43/** A name settings can give a machine: never `local`, and nothing a command line could mistake for a flag. */ 44export const ENVIRONMENT_NAME = / ^[A-Za-z0-9][A-Za-z0-9._-]*$ / ; 45 46/** 47* This machine, and those `configured` names, each over one connection that a change of its settings or the loss of 48* it replaces. 49*/ 50export function configuredEnvironments ( 51configured :() => Promise < Readonly < Record < string , EnvironmentSettings >>>, 52start :( name :string , settings :EnvironmentSettings ) => Promise < Connection > = ( name , settings ) => 53Connection . open ( name , sshCommand ( settings . ssh , settings . deno )), 54) :Environments { 55const connections = new Map < string , { key :string ; connection :Promise < Connection > }>(); 56return { 57async open ( name , cwd , context ) { 58if ( name === LOCAL ) return await localOnly . open ( name , cwd , context ); 59const settings = ( await configured ())[ name ]; 60if ( settings === undefined ) throw new Error ( `No environment named ${ name } ` ); 61const key = JSON . stringify ([ settings . ssh , settings . deno ]); 62const replace = ( stale ?:{ connection :Promise < Connection > }) => { 63if ( stale ) void stale . connection . then (( connection ) => connection . close (), () => {}); 64const fresh = { key, connection :start ( name , settings ) }; 65connections . set ( name , fresh ); 66return fresh ; 67}; 68let open = connections . get ( name ); 69if ( open ?. key !== key ) open = replace ( open ); 70else if ( ! ( await open . connection . then (( connection ) => ! connection . lost , () => false ))) { 71// a call that found it gone first has replaced it already 72const current = connections . get ( name ); 73open = current !== open && current ?. key === key ?current :replace ( open ); 74} 75const connection = await open . connection ; 76return new RemoteEnvironment ( name , connection , cwd ?? connection . hello . home ); 77}, 78async close () { 79const open = [ ...connections . values ()]; 80connections . clear (); 81await Promise . allSettled ( 82open . map ( async ({ connection}) => await ( await connection ). close ()), 83); 84}, 85}; 86} 87 88const isLegacyCd = ( event :JsonValue ) :event is{ type :"cd" ; cwd :string } => 89typeof event === "object" && event !== null && ! Array . isArray ( event ) && event . type === "cd" && 90typeof event . cwd === "string" ; 91 92/** The binding as harness's own extension: always on, configured like any other. */ 93export function environmentExtension ( environments :Environments ) { 94return defineExtension < Binding , BindingEvent >({ 95name :ENVIRONMENT , 96description :"the target machine & working directory for the conversation" , 97alwaysOn :true , 98sortOrder :Number . MIN_SAFE_INTEGER , 99initial :() => ({ environment :LOCAL , cwd :null }), 100apply :( _ , event ) => ({ environment :event . environment , cwd :event . cwd }), 101// before, the code extension kept the working directory, which was always on this machine 102observe ( state , entry ) { 103if ( ! EventEntry . is ( entry )) return state ; 104const { ext, event} = entry . data ; 105return ( ext === "code" || ext === "coding" ) && isLegacyCd ( event ) 106 ?{ ...state , cwd :event . cwd } 107 :state ; 108}, 109setup ( ext ) { 110ext . configuration ({ 111parameters :Type . Object ({ 112environment :Type . String ({ 113title :"environment" , 114default :LOCAL , 115description :"local, or an ssh environment (see settings)" , 116}), 117cwd :Type . String ({ 118title :"working directory" , 119default :"" , 120description :"if empty, defaults to $HOME" , 121}), 122}), 123values :({ environment, cwd}) => ({ environment, cwd :cwd ?? "" }), 124async events ({ environment, cwd}, state ) { 125const moving = environment !== state . environment ; 126const env = await environments . open ( 127environment , 128moving ?null :state . cwd , 129BACKGROUND_CONTEXT , 130); 131// a change of machine alone leaves the old machine's directory in the form 132const path = moving && cwd === ( state . cwd ?? "" ) ?"" :cwd ; 133return [{ 134type :"bind" , 135 environment, 136cwd :await directory ( env , path , BACKGROUND_CONTEXT ), 137}]; 138}, 139}); 140return ( conv ) => { 141conv . command ({ 142name :"cd" , 143description :"change conversation working directory" , 144execute :( path , ctx ) => ctx . cd ( path ), 145}); 146conv . prompt (({ env}) => { 147if ( env === undefined ) { 148return `the environment ${ conv . state . environment } cannot be reached right now: ` + 149"calls that use its files or commands fail until it can" ; 150} 151if ( env . name === LOCAL ) return undefined ; 152return `files and commands are on ${ env . name } ( ${ env . platform } ), not on the machine berry runs ` + 153"on: berry's own source and data are out of reach from there" ; 154}); 155}; 156}, 157}); 158}