char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
32b51b2
main
1import type { JsonValue } from "@earendil-works/chord" ; 2import type { 3Api , 4AuthResult , 5Message , 6Model , 7Provider , 8Static , 9TSchema , 10} from "@earendil-works/pi-ai" ; 11import type { Environment } from "./environment.ts" ; 12import type { TObject } from "typebox" ; 13import { Value } from "typebox/value" ; 14import type { EntryRecord } from "@earendil-works/pi-durable" ; 15 16export type { EntryRecord , JsonValue }; 17export type { Environment , Process , Pty , PtyOptions , Socket , SpawnOptions } from "./environment.ts" ; 18 19/** Who can call a tool: scripts in codemode (the harness's one direct tool), the model directly, or both. */ 20export type Exposure = "codemode" | "model" | "both" ; 21 22export type Tool < 23S extends JsonValue = JsonValue , 24E extends JsonValue = never , 25P extends TSchema = TSchema , 26R extends JsonValue | undefined = JsonValue | undefined , 27> = { 28readonly name :string ; 29readonly description :string ; 30readonly parameters :P ; 31/** Type of the value scripts receive; only shapes the codemode declaration. */ 32readonly returns ?:TSchema ; 33/** Default `"codemode"`. */ 34readonly expose ?:Exposure ; 35/** Whether the tool is offered and callable in the conversation’s current environment. */ 36enabled ?( ctx :PromptContext ) :boolean | Promise < boolean >; 37/** 38* The value is what scripts receive. Called directly, strings become text, `{ type: "image", data, mimeType }` 39* an image, and anything else JSON text, unless `text` says otherwise. 40*/ 41execute ( args :Static < P >, ctx :ToolContext < S , E >) :R | Promise < R >; 42/** The value as text for the model's direct calls, where JSON would read poorly. */ 43text ?( value :R ) :string ; 44}; 45 46/** One conversation, as an extension sees it. */ 47export type ConversationContext < S extends JsonValue > = { 48readonly tid :string ; 49/** The extension's state, folded from history. */ 50readonly state :S ; 51}; 52 53export type ToolContext < S extends JsonValue , E extends JsonValue > = ConversationContext < S > & { 54/** The conversation's environment at its working directory, as this call found it. */ 55readonly env :Environment ; 56readonly signal :AbortSignal ; 57/** Commit an event to history; `state` reflects it once this resolves. */ 58emit ( event :E ) :Promise < void >; 59/** Running output, for display. */ 60output ( chunk :string ) :void ; 61/** Running details, for display; the last value stays with the result. */ 62progress ( details :JsonValue ) :void ; 63}; 64 65export type CommandContext < S extends JsonValue , E extends JsonValue > = ConversationContext < S > & { 66/** The conversation's environment at its working directory. */ 67readonly env :Environment ; 68/** 69* Record an event as the user. While the conversation is busy it lands at the next boundary, so the model never 70* sees it mid-turn; this resolves once it is admitted. 71*/ 72emit ( event :E , options ?:{ 73/** Passive user-message context stored with the event, without starting a run. */ 74content :string ; 75}) :Promise < void >; 76/** 77* Move the working directory, as the user, to the directory `path` names from it on the same machine. Lands like 78* `emit`; resolves with where it moves to. 79*/ 80cd ( path :string , options ?:{ 81/** Add the destination as a workspace of the source directory's project, if any. */ 82inheritProject ?:boolean ; 83}) :Promise < string >; 84/** Start a manual compaction of this conversation; resolves once it has started, not once its summary is placed. */ 85compact ( instructions ?:string ) :Promise < void >; 86}; 87 88/** Something the user invokes, as `/name args`. */ 89export type Command < S extends JsonValue = JsonValue , E extends JsonValue = never > = { 90readonly name :string ; 91readonly description :string ; 92/** Whether the command is offered and callable in the conversation’s current environment. */ 93enabled ?( ctx :PromptContext ) :boolean | Promise < boolean >; 94/** `args` is the text after the command's name. The value is shown to the user. */ 95execute ( 96args :string , 97ctx :CommandContext < S , E >, 98) :JsonValue | undefined | Promise < JsonValue | undefined >; 99}; 100 101/** What a handler answers with: its conversation, and the request's own abort signal. */ 102export type HandlerContext < S extends JsonValue > = ConversationContext < S > & { 103/** The conversation's environment at its working directory. */ 104readonly env :Environment ; 105/** Aborted once the frontend gives up on the request. */ 106readonly signal :AbortSignal ; 107}; 108 109/** A request this extension's own frontend makes of it, such as for the data a view draws. */ 110export type Handler < 111S extends JsonValue = JsonValue , 112P extends TSchema = TSchema , 113R extends JsonValue | undefined = JsonValue | undefined , 114> = { 115readonly name :string ; 116/** What the frontend sends, validated with its defaults filled in. */ 117readonly parameters :P ; 118execute ( args :Static < P >, ctx :HandlerContext < S >) :R | Promise < R >; 119}; 120 121/** What crosses a channel: JSON, or bytes as they are. */ 122export type ChannelMessage = JsonValue | Uint8Array ; 123 124/** One end of a channel between an extension's frontend and its server side. */ 125export interface Channel { 126/** Dropped once the channel has closed. */ 127send ( message :ChannelMessage ) :void ; 128/** What the other end sends: it ends once either end closes the channel, and fails if it could not open. */ 129readonly messages :ReadableStream < ChannelMessage >; 130close () :void ; 131} 132 133/** What a channel opens with: its conversation, and a signal for its closing. */ 134export type ChannelContext < S extends JsonValue > = ConversationContext < S > & { 135/** The conversation's environment at its working directory. */ 136readonly env :Environment ; 137/** Aborted once the channel closes, from either end. */ 138readonly signal :AbortSignal ; 139}; 140 141/** A long-lived connection this extension's frontend opens to it, such as a terminal's. */ 142export type ChannelHandler < S extends JsonValue = JsonValue , P extends TSchema = TSchema > = { 143readonly name :string ; 144/** What the frontend opens it with, validated with its defaults filled in. */ 145readonly parameters :P ; 146/** The channel is the extension's from here, to keep and to close; a throw closes it with the message. */ 147open ( args :Static < P >, channel :Channel , ctx :ChannelContext < S >) :void | Promise < void >; 148}; 149 150/** A handler's or a channel's arguments as it receives them: validated, with their defaults filled in. */ 151export function handlerArguments < P extends TSchema > ( 152parameters :P , 153args :JsonValue | undefined , 154) :Static < P > { 155const filled = Value . Default ( parameters , structuredClone ( args ?? {})); 156const [ error ] = Value . Errors ( parameters , filled ); 157if ( error ) throw new Error ( ` ${ error . instancePath . slice ( 1 ) || "arguments" } : ${ error . message } ` ); 158return filled as Static < P >; 159} 160 161/** An editable projection of extension state, shared by new and existing conversations. */ 162export type Configuration < 163S extends JsonValue , 164E extends JsonValue , 165P extends TObject = TObject , 166> = { 167/** 168* The questions, as a form draws them: string, number, integer and boolean properties, and unions of string 169* literals, with their `title`, `description` and `default`. 170*/ 171readonly parameters :P ; 172values ( state :S ) :Static < P > & Record < string , JsonValue >; 173/** Thrown errors refuse the values, with their message. */ 174events ( values :Static < P >, state :S ) :readonly E [] | Promise < readonly E []>; 175}; 176 177/** Global editable prose for this extension's main section, rendered as entered with `$USER` replaced; blank prose is omitted. */ 178export type PromptProse = { 179/** What the prose is unless settings override it. */ 180readonly default :string ; 181}; 182 183/** Validate configuration values, their defaults filled in, and turn them into events without changing state. */ 184export async function configurationEvents < S extends JsonValue , E extends JsonValue > ( 185configuration :Configuration < S , E >, 186values :JsonValue | undefined , 187state :S , 188) :Promise < readonly E []> { 189const filled = Value . Default ( configuration . parameters , structuredClone ( values ?? {})); 190const [ error ] = Value . Errors ( configuration . parameters , filled ); 191if ( error ) throw new Error ( ` ${ error . instancePath . slice ( 1 ) || "values" } : ${ error . message } ` ); 192return await configuration . events ( filled as Static < TObject >, state ); 193} 194 195/** What a prompt renders against. */ 196export type PromptContext = { 197/** The conversation's environment, unless it cannot be reached. */ 198readonly env :Environment | undefined ; 199}; 200 201/** Text for this extension's system prompt section; `undefined` leaves it out. */ 202export type PromptRender = ( 203ctx :PromptContext , 204) => string | undefined | Promise < string | undefined >; 205 206/** A named system prompt section, rendered against the conversation's current state. */ 207export type SectionRender < S extends JsonValue > = ( 208ctx :ConversationContext < S > & PromptContext , 209) => ReturnType < PromptRender >; 210 211/** Inspect or replace outgoing messages without changing stored history. */ 212export type ContextHook = ( 213messages :readonly Message [], 214model :Model < Api >, 215) => readonly Message [] | undefined | Promise < readonly Message [] | undefined >; 216 217/** Inspect or replace a provider's assembled request payload. */ 218export type PayloadHook = ( 219payload :unknown , 220model :Model < Api >, 221) => unknown | undefined | Promise < unknown | undefined >; 222 223/** A server-wide cap on effective context windows, so compaction starts at one threshold whatever the model's window. */ 224export type CompactionCap = { 225/** Blocking compaction once the context reaches this many tokens; smaller model windows keep their own policy. */ 226readonly maxContextTokens :number ; 227/** Background compaction starts this far below the blocking threshold; `0` disables it. */ 228readonly backgroundTokens :number ; 229}; 230 231/** The tightest of the caps extensions report, ignoring missing and invalid ones. */ 232export function tightestCap ( 233caps :readonly ( CompactionCap | undefined )[], 234) :CompactionCap | undefined { 235let best :CompactionCap | undefined ; 236for ( const cap of caps ) { 237if ( cap === undefined ) continue ; 238const maxContextTokens = Math . floor ( cap . maxContextTokens ); 239const backgroundTokens = Math . floor ( cap . backgroundTokens ); 240if ( ! ( maxContextTokens > 0 ) || ! ( backgroundTokens >= 0 )) continue ; 241if ( best === undefined || maxContextTokens < best . maxContextTokens ) { 242best = { maxContextTokens, backgroundTokens}; 243} 244} 245return best ; 246} 247 248/** What an extension contributes to one conversation; rebuilt whenever its state changes. */ 249export type ConversationApi < S extends JsonValue , E extends JsonValue > = 250& ConversationContext < S > 251& PromptContext 252& { 253/** Register a tool for this conversation, replacing a same-named tool from this extension's setup. */ 254tool < P extends TSchema , R extends JsonValue | undefined > ( tool :Tool < S , E , P , R >) :void ; 255prompt ( render :PromptRender ) :void ; 256onContext ( hook :ContextHook ) :void ; 257onPayload ( hook :PayloadHook ) :void ; 258command ( command :Command < S , E >) :void ; 259}; 260 261export type ExtensionApi < S extends JsonValue , E extends JsonValue > = { 262/** Register a server-wide provider for this load; builtins may be replaced and are restored on release, but another extension's provider cannot be replaced. */ 263provider ( provider :Provider ) :void ; 264/** Server-only provider auth, refreshed through the same credential store as model requests. */ 265auth ( provider :string , signal ?:AbortSignal ) :Promise < AuthResult | undefined >; 266tool < P extends TSchema , R extends JsonValue | undefined > ( tool :Tool < S , E , P , R >) :void ; 267/** Answer this extension's own frontend, in conversations that have the extension. */ 268handle < P extends TSchema , R extends JsonValue | undefined > ( handler :Handler < S , P , R >) :void ; 269/** Accept channels this extension's own frontend opens, in conversations that have the extension. */ 270channel < P extends TSchema > ( handler :ChannelHandler < S , P >) :void ; 271/** A separate section keyed as `<extension>_<name>`, outside the default conversation prompt. */ 272prompt ( name :string , render :SectionRender < S >) :void ; 273/** Global editable prose prepended to this extension's main section; a later call replaces an earlier one. */ 274promptProse ( prose :PromptProse ) :void ; 275/** Cap effective context windows for this load; the tightest cap among loads wins, and it lapses on release. */ 276compactionCap ( getter :() => CompactionCap | undefined ) :void ; 277/** Expose editable conversation configuration; a later call replaces an earlier one. */ 278configuration < P extends TObject > ( configuration :Configuration < S , E , P >) :void ; 279/** Runs when this load of the extension is replaced or shut down. */ 280onDispose ( dispose :() => void | Promise < void >) :void ; 281}; 282 283export type Extension < S extends JsonValue = JsonValue , E extends JsonValue = never > = { 284/** `codemode` is the harness's own. */ 285readonly name :string ; 286readonly description ?:string ; 287/** Available in every conversation, outside its choice of extensions. */ 288readonly alwaysOn ?:boolean ; 289/** What a new conversation starts with, until one is chosen from before. */ 290readonly defaultEnabled ?:boolean ; 291/** 292* Where its configuration sorts among the others': lower first, and one without it after all of them, by name. 293*/ 294readonly sortOrder ?:number ; 295/** 296* Names it went by before: the events recorded under them are its own, and conversations that chose it by one of 297* them choose it by its name now. 298*/ 299readonly formerly ?:readonly string []; 300initial () :S ; 301/** Fold one of this extension's own events. Events are kept forever: stay compatible with old shapes. */ 302apply ?( state :S , event :E ) :S ; 303/** Fold any history entry, including other extensions' events. */ 304observe ?( state :S , entry :EntryRecord ) :S ; 305/** Runs once per load. The returned function contributes to each conversation. */ 306setup ( ext :ExtensionApi < S , E >) :(( conv :ConversationApi < S , E >) => void ) | void ; 307}; 308 309export function defineExtension < S extends JsonValue , E extends JsonValue = never > ( 310extension :Extension < S , E >, 311) :Extension < S , E > { 312return extension ; 313}