char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somadd defaultEnabled to extensions and enable code+web32b51b2

main
13.6 KiB313 linesraw
1import type { JsonValue } from "@earendil-works/chord";
2import type {
3  Api,
4  AuthResult,
5  Message,
6  Model,
7  Provider,
8  Static,
9  TSchema,
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<
23  S extends JsonValue = JsonValue,
24  E extends JsonValue = never,
25  P extends TSchema = TSchema,
26  R extends JsonValue | undefined = JsonValue | undefined,
27> = {
28  readonly name: string;
29  readonly description: string;
30  readonly parameters: P;
31  /** Type of the value scripts receive; only shapes the codemode declaration. */
32  readonly returns?: TSchema;
33  /** Default `"codemode"`. */
34  readonly expose?: Exposure;
35  /** Whether the tool is offered and callable in the conversation’s current environment. */
36  enabled?(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   */
41  execute(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. */
43  text?(value: R): string;
44};
45
46/** One conversation, as an extension sees it. */
47export type ConversationContext<S extends JsonValue> = {
48  readonly tid: string;
49  /** The extension's state, folded from history. */
50  readonly 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. */
55  readonly env: Environment;
56  readonly signal: AbortSignal;
57  /** Commit an event to history; `state` reflects it once this resolves. */
58  emit(event: E): Promise<void>;
59  /** Running output, for display. */
60  output(chunk: string): void;
61  /** Running details, for display; the last value stays with the result. */
62  progress(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. */
67  readonly 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   */
72  emit(event: E, options?: {
73    /** Passive user-message context stored with the event, without starting a run. */
74    content: 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   */
80  cd(path: string, options?: {
81    /** Add the destination as a workspace of the source directory's project, if any. */
82    inheritProject?: boolean;
83  }): Promise<string>;
84  /** Start a manual compaction of this conversation; resolves once it has started, not once its summary is placed. */
85  compact(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> = {
90  readonly name: string;
91  readonly description: string;
92  /** Whether the command is offered and callable in the conversation’s current environment. */
93  enabled?(ctx: PromptContext): boolean | Promise<boolean>;
94  /** `args` is the text after the command's name. The value is shown to the user. */
95  execute(
96    args: string,
97    ctx: 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. */
104  readonly env: Environment;
105  /** Aborted once the frontend gives up on the request. */
106  readonly 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<
111  S extends JsonValue = JsonValue,
112  P extends TSchema = TSchema,
113  R extends JsonValue | undefined = JsonValue | undefined,
114> = {
115  readonly name: string;
116  /** What the frontend sends, validated with its defaults filled in. */
117  readonly parameters: P;
118  execute(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. */
127  send(message: ChannelMessage): void;
128  /** What the other end sends: it ends once either end closes the channel, and fails if it could not open. */
129  readonly messages: ReadableStream<ChannelMessage>;
130  close(): 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. */
136  readonly env: Environment;
137  /** Aborted once the channel closes, from either end. */
138  readonly 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> = {
143  readonly name: string;
144  /** What the frontend opens it with, validated with its defaults filled in. */
145  readonly parameters: P;
146  /** The channel is the extension's from here, to keep and to close; a throw closes it with the message. */
147  open(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>(
152  parameters: P,
153  args: JsonValue | undefined,
154): Static<P> {
155  const filled = Value.Default(parameters, structuredClone(args ?? {}));
156  const [error] = Value.Errors(parameters, filled);
157  if (error) throw new Error(`${error.instancePath.slice(1) || "arguments"}: ${error.message}`);
158  return filled as Static<P>;
159}
160
161/** An editable projection of extension state, shared by new and existing conversations. */
162export type Configuration<
163  S extends JsonValue,
164  E extends JsonValue,
165  P 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   */
171  readonly parameters: P;
172  values(state: S): Static<P> & Record<string, JsonValue>;
173  /** Thrown errors refuse the values, with their message. */
174  events(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. */
180  readonly 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>(
185  configuration: Configuration<S, E>,
186  values: JsonValue | undefined,
187  state: S,
188): Promise<readonly E[]> {
189  const filled = Value.Default(configuration.parameters, structuredClone(values ?? {}));
190  const [error] = Value.Errors(configuration.parameters, filled);
191  if (error) throw new Error(`${error.instancePath.slice(1) || "values"}: ${error.message}`);
192  return 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. */
198  readonly env: Environment | undefined;
199};
200
201/** Text for this extension's system prompt section; `undefined` leaves it out. */
202export type PromptRender = (
203  ctx: 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> = (
208  ctx: ConversationContext<S> & PromptContext,
209) => ReturnType<PromptRender>;
210
211/** Inspect or replace outgoing messages without changing stored history. */
212export type ContextHook = (
213  messages: readonly Message[],
214  model: Model<Api>,
215) => readonly Message[] | undefined | Promise<readonly Message[] | undefined>;
216
217/** Inspect or replace a provider's assembled request payload. */
218export type PayloadHook = (
219  payload: unknown,
220  model: 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. */
226  readonly maxContextTokens: number;
227  /** Background compaction starts this far below the blocking threshold; `0` disables it. */
228  readonly backgroundTokens: number;
229};
230
231/** The tightest of the caps extensions report, ignoring missing and invalid ones. */
232export function tightestCap(
233  caps: readonly (CompactionCap | undefined)[],
234): CompactionCap | undefined {
235  let best: CompactionCap | undefined;
236  for (const cap of caps) {
237    if (cap === undefined) continue;
238    const maxContextTokens = Math.floor(cap.maxContextTokens);
239    const backgroundTokens = Math.floor(cap.backgroundTokens);
240    if (!(maxContextTokens > 0) || !(backgroundTokens >= 0)) continue;
241    if (best === undefined || maxContextTokens < best.maxContextTokens) {
242      best = { maxContextTokens, backgroundTokens };
243    }
244  }
245  return 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. */
254    tool<P extends TSchema, R extends JsonValue | undefined>(tool: Tool<S, E, P, R>): void;
255    prompt(render: PromptRender): void;
256    onContext(hook: ContextHook): void;
257    onPayload(hook: PayloadHook): void;
258    command(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. */
263  provider(provider: Provider): void;
264  /** Server-only provider auth, refreshed through the same credential store as model requests. */
265  auth(provider: string, signal?: AbortSignal): Promise<AuthResult | undefined>;
266  tool<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. */
268  handle<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. */
270  channel<P extends TSchema>(handler: ChannelHandler<S, P>): void;
271  /** A separate section keyed as `<extension>_<name>`, outside the default conversation prompt. */
272  prompt(name: string, render: SectionRender<S>): void;
273  /** Global editable prose prepended to this extension's main section; a later call replaces an earlier one. */
274  promptProse(prose: PromptProse): void;
275  /** Cap effective context windows for this load; the tightest cap among loads wins, and it lapses on release. */
276  compactionCap(getter: () => CompactionCap | undefined): void;
277  /** Expose editable conversation configuration; a later call replaces an earlier one. */
278  configuration<P extends TObject>(configuration: Configuration<S, E, P>): void;
279  /** Runs when this load of the extension is replaced or shut down. */
280  onDispose(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. */
285  readonly name: string;
286  readonly description?: string;
287  /** Available in every conversation, outside its choice of extensions. */
288  readonly alwaysOn?: boolean;
289  /** What a new conversation starts with, until one is chosen from before. */
290  readonly defaultEnabled?: boolean;
291  /**
292   * Where its configuration sorts among the others': lower first, and one without it after all of them, by name.
293   */
294  readonly 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   */
299  readonly formerly?: readonly string[];
300  initial(): S;
301  /** Fold one of this extension's own events. Events are kept forever: stay compatible with old shapes. */
302  apply?(state: S, event: E): S;
303  /** Fold any history entry, including other extensions' events. */
304  observe?(state: S, entry: EntryRecord): S;
305  /** Runs once per load. The returned function contributes to each conversation. */
306  setup(ext: ExtensionApi<S, E>): ((conv: ConversationApi<S, E>) => void) | void;
307};
308
309export function defineExtension<S extends JsonValue, E extends JsonValue = never>(
310  extension: Extension<S, E>,
311): Extension<S, E> {
312  return extension;
313}