char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somtweak the mechanics of extension configuration a little6ae9dd3

main
13.7 KiB404 linesraw
1import { Signal } from "@char/aftercare";
2import type { JsonValue } from "@berry/api";
3import type { ConfigurationContext, ConfigurationSchema, FieldSchema } from "@berry/api/web.ts";
4import { extensionRenderers } from "./renderers.ts";
5import { folderInput, type FolderOptions } from "./folders.tsx";
6import type { ProjectIndex } from "@berry/api/projects.ts";
7import { api, watch } from "./client.ts";
8import type { Shared } from "./conversation.tsx";
9import { modelKey } from "./settings.tsx";
10import { computed, type Scope } from "./ui.ts";
11
12export type { ConfigurationSchema, FieldSchema } from "@berry/api/web.ts";
13type Values = Record<string, JsonValue>;
14export type ConfigurationLocation = Pick<
15  ConfigurationContext,
16  "tid" | "model" | "request" | "command"
17>;
18
19/** Bind custom or generated controls to configuration values and the selected model. */
20export function configurationControls(
21  scope: Scope,
22  name: string,
23  schema: ConfigurationSchema,
24  location: Signal<ConfigurationLocation>,
25  values: Signal<Values | null>,
26  apply: (patch: Values) => Values,
27  generated: (save: (patch: Values) => Promise<Values>) => HTMLElement,
28  status?: Signal<{ pending: number; error: string | null }>,
29): HTMLElement {
30  const view = extensionRenderers(name)?.configuration?.();
31  let form: HTMLElement | undefined;
32  let disposed = false;
33  let error: string | null = null;
34  const renderDefault = () => form ??= generated(save);
35  const draw = () => {
36    if (disposed) return;
37    view?.update({
38      ...location.get(),
39      extension: name,
40      schema,
41      values: values.get(),
42      saveStatus: status?.get().pending
43        ? "saving"
44        : error || status?.get().error
45        ? "error"
46        : "idle",
47      error: error ?? status?.get().error ?? null,
48      save: async (patch) => {
49        await save(patch);
50      },
51      renderDefault,
52    });
53  };
54  const save = (patch: Values): Promise<Values> => {
55    if (disposed || values.get() === null) {
56      return Promise.reject(new Error("Configuration is not available."));
57    }
58    error = null;
59    try {
60      // draft answers must be updated before a synchronous form submission can read them
61      const next = apply(patch);
62      values.set(next);
63      draw();
64      return Promise.resolve(next);
65    } catch (cause) {
66      error = cause instanceof Error ? cause.message : String(cause);
67      draw();
68      return Promise.reject(cause);
69    }
70  };
71  const element = view ? <div>{view.element}</div> as HTMLElement : renderDefault();
72  scope.add(() => {
73    disposed = true;
74    view?.dispose?.();
75  });
76  scope.add(location.subscribe(draw).unsubscribe);
77  scope.add(values.subscribe(draw).unsubscribe);
78  if (status) scope.add(status.subscribe(draw).unsubscribe);
79  draw();
80  return element;
81}
82
83/** Choices a field's schema cannot list, as they are what the server knows now. */
84export type LiveChoices = Readonly<Record<string, Signal<readonly string[]>>>;
85
86/** The live choices of an extension's fields: the machines an environment can be, the models a subagent can run on. */
87export function liveChoices(
88  scope: Scope,
89  extension: string,
90  shared: Pick<Shared, "machines" | "enabled">,
91): LiveChoices {
92  if (extension === "environment") return { environment: shared.machines };
93  if (extension === "subagent") {
94    return { models: computed(scope, [shared.enabled], () => shared.enabled.get().map(modelKey)) };
95  }
96  return {};
97}
98
99/** One question of a configuration: a typebox property, of the few kinds a form draws. */
100export function configurationField(
101  key: string,
102  schema: FieldSchema,
103  value: JsonValue | undefined,
104  answer: (value: JsonValue) => void,
105  live?: { scope: Scope; choices: Signal<readonly string[]> },
106  folders?: FolderOptions,
107) {
108  if (schema.type === "array") return listField(key, schema, value, answer, live);
109  const choices = schema.enum ?? schema.anyOf?.map((option) => option.const ?? null);
110  const boolean = schema.type === "boolean";
111  const numeric = schema.type === "number" || schema.type === "integer";
112  const folder = folders ? folderInput(folders) : undefined;
113  const input = (live || choices?.every((choice) => typeof choice === "string")
114    ? (
115      <select>
116        {((choices ?? []) as string[]).map((choice) => <option value={choice}>{choice}</option>)}
117      </select>
118    )
119    : folder?.input ?? <input type={boolean ? "checkbox" : numeric ? "number" : "text"} />) as
120      | HTMLInputElement
121      | HTMLSelectElement;
122  // the choices as they are, and the value even once it is not among them, so it still shows
123  let selected = "";
124  const draw = () => {
125    if (!live) return;
126    const names = live.choices.get();
127    const all = selected === "" || names.includes(selected) ? names : [...names, selected];
128    input.replaceChildren(...all.map((name) => <option value={name}>{name}</option>));
129    input.value = selected;
130  };
131  if (live) {
132    live.scope.add(live.choices.subscribe(draw).unsubscribe);
133    input.addEventListener("change", () => void (selected = input.value));
134  }
135  if (numeric) (input as HTMLInputElement).step = schema.type === "integer" ? "1" : "any";
136  const read = (): JsonValue =>
137    boolean
138      ? (input as HTMLInputElement).checked
139      : numeric
140      ? (input as HTMLInputElement).valueAsNumber
141      : input.value;
142  const update = (value: JsonValue | undefined) => {
143    const next = value ?? schema.default ?? null;
144    if (boolean) (input as HTMLInputElement).checked = next === true;
145    else if (live) {
146      selected = next === null ? "" : String(next);
147      draw();
148    } else input.value = next === null ? "" : String(next);
149  };
150  update(value);
151  input.addEventListener(boolean || choices || live ? "change" : "input", () => answer(read()));
152  const label = schema.title ?? key;
153  return {
154    element: folder
155      ? (
156        <div class="field" title={schema.description ?? ""}>
157          <label htmlFor={folder.input.id}>{label}</label>
158          {folder.element}
159        </div>
160      )
161      : (
162        <label class={boolean ? "toggle" : "field"} title={schema.description ?? ""}>
163          {boolean ? [] : <span>{label}</span>}
164          {input}
165          {boolean ? label : []}
166        </label>
167      ),
168    input,
169    get value() {
170      return read();
171    },
172    update,
173  };
174}
175
176/** A question with any number of answers among its choices, a box for each. */
177function listField(
178  key: string,
179  schema: FieldSchema,
180  value: JsonValue | undefined,
181  answer: (value: JsonValue) => void,
182  live?: { scope: Scope; choices: Signal<readonly string[]> },
183) {
184  const fixed = (schema.items?.enum ?? schema.items?.anyOf?.map((option) => option.const) ?? [])
185    .filter((choice) => typeof choice === "string");
186  const input = <fieldset class="choices" /> as HTMLFieldSetElement;
187  let selected: string[] = [];
188  const read = () =>
189    [...input.querySelectorAll<HTMLInputElement>("input:checked")].map((box) => box.value);
190  // the choices as they are, and the answers even once they are not among them, so they still show
191  const draw = () => {
192    const choices = live?.choices.get() ?? fixed;
193    const all = [...choices, ...selected.filter((choice) => !choices.includes(choice))];
194    input.replaceChildren(...all.map((choice) => (
195      <label class="toggle">
196        <input type="checkbox" value={choice} checked={selected.includes(choice)} />
197        {choice}
198      </label>
199    )));
200  };
201  if (live) live.scope.add(live.choices.subscribe(draw).unsubscribe);
202  const update = (value: JsonValue | undefined) => {
203    const next = value ?? schema.default;
204    selected = Array.isArray(next) ? next.filter((item) => typeof item === "string") : [];
205    draw();
206  };
207  update(value);
208  input.addEventListener("change", () => answer(selected = read()));
209  return {
210    element: (
211      <div class="field" title={schema.description ?? ""}>
212        <span>{schema.title ?? key}</span>
213        {input}
214      </div>
215    ),
216    input,
217    get value(): JsonValue {
218      return read();
219    },
220    update,
221  };
222}
223
224/** A live configuration draft whose edits become events when committed. */
225export function configurationEditor(
226  scope: Scope,
227  tid: string,
228  name: string,
229  schema: ConfigurationSchema,
230  saving: (save: Promise<unknown>) => void,
231  live: LiveChoices = {},
232  projects?: Signal<ProjectIndex>,
233  location = new Signal<ConfigurationLocation>({ tid, model: null }),
234) {
235  const values = new Signal<Values | null>(null);
236  const status = new Signal({ pending: 0, error: null as string | null });
237  let current: Values | null = null;
238  let tail: Promise<unknown> = Promise.resolve();
239  const edits = new Map<string, { value: JsonValue }>();
240  const submitted = new Map<string, { value: JsonValue }>();
241  const draw = () =>
242    values.set(
243      current && {
244        ...current,
245        ...Object.fromEntries([...edits].map(([key, edit]) => [key, edit.value])),
246      },
247    );
248  scope.add(watch<Values | null>(`configuration:${tid}:${name}`, (next) => {
249    current = next;
250    draw();
251  }));
252  const element = configurationControls(
253    scope,
254    name,
255    schema,
256    location,
257    values,
258    (patch) => {
259      for (const [key, value] of Object.entries(patch)) {
260        if (JSON.stringify(value) === JSON.stringify(values.get()?.[key])) continue;
261        if (!submitted.has(key) && JSON.stringify(value) === JSON.stringify(current?.[key])) {
262          edits.delete(key);
263        } else edits.set(key, { value });
264      }
265      return { ...values.get()!, ...patch };
266    },
267    (save) =>
268      generatedEditor(scope, name, schema, values, save, live, status, () => current, projects),
269    status,
270  );
271  return {
272    element,
273    commit() {
274      const batch = [...edits].filter(([key, edit]) => submitted.get(key) !== edit);
275      if (!batch.length) return;
276      for (const [key, edit] of batch) submitted.set(key, edit);
277      status.set({ pending: status.get().pending + 1, error: null });
278      const operation = tail.catch(() => {}).then(() =>
279        api<Values>(
280          "PATCH",
281          `/api/conversations/${tid}/configuration/${encodeURIComponent(name)}`,
282          Object.fromEntries(batch.map(([key, edit]) => [key, edit.value])),
283        )
284      ).then((next) => {
285        current = next;
286        for (const [key, edit] of batch) {
287          if (edits.get(key) === edit) edits.delete(key);
288          if (submitted.get(key) === edit) submitted.delete(key);
289        }
290        draw();
291        status.set({ ...status.get(), error: null });
292      }).catch((cause) => {
293        for (const [key, edit] of batch) {
294          if (submitted.get(key) === edit) submitted.delete(key);
295        }
296        status.set({
297          ...status.get(),
298          error: cause instanceof Error ? cause.message : String(cause),
299        });
300        throw cause;
301      }).finally(() => status.set({ ...status.get(), pending: status.get().pending - 1 }));
302      tail = operation;
303      saving(operation);
304      return operation;
305    },
306  };
307}
308
309function generatedEditor(
310  scope: Scope,
311  name: string,
312  schema: ConfigurationSchema,
313  values: Signal<Values | null>,
314  save: (patch: Values) => Promise<Values>,
315  live: LiveChoices,
316  remote: Signal<{ pending: number; error: string | null }>,
317  baseline: () => Values | null,
318  projects?: Signal<ProjectIndex>,
319) {
320  let current: Values | null = null;
321  const environment = new Signal("local");
322  const status = <p class="configuration-status" role="status" aria-live="polite" /> as HTMLElement;
323  const fields = Object.entries(schema.properties).map(([key, schema]) => {
324    const choices = live[key];
325    const field = configurationField(
326      key,
327      schema,
328      undefined,
329      (value) => {
330        const field = fields.find((field) => field.key === key)!.field;
331        if (
332          !field.input.checkValidity() ||
333          (typeof value === "number" && !Number.isFinite(value))
334        ) {
335          status.classList.add("error");
336          status.textContent = field.input.validationMessage || "enter a valid number";
337          return;
338        }
339        status.classList.remove("error");
340        status.textContent = "";
341        const patch: Values = { [key]: value };
342        if (name === "environment" && key === "environment") {
343          environment.set(String(value));
344          fields.find(({ key }) => key === "cwd")?.field.update("");
345          patch.cwd = "";
346        }
347        void save(patch).catch(() => {});
348      },
349      choices && { scope, choices },
350      name === "environment" && key === "cwd"
351        ? {
352          scope,
353          environment,
354          projects,
355          base: () => {
356            const saved = baseline();
357            return environment.get() === saved?.environment
358              ? String(saved?.cwd || "") || null
359              : null;
360          },
361        }
362        : undefined,
363    );
364    field.input.addEventListener("keydown", (event) => {
365      if ((event as KeyboardEvent).key === "Enter") {
366        event.preventDefault();
367        (document.activeElement as HTMLElement | null)?.blur();
368      }
369    });
370    return { key, field };
371  });
372  const element = (
373    <fieldset class="configuration" disabled>
374      {fields.map(({ field }) => field.element)}
375      {status}
376    </fieldset>
377  ) as HTMLFieldSetElement;
378  const draw = () => {
379    if (!current) return;
380    for (const { key, field } of fields) {
381      if (!field.input.contains(document.activeElement)) {
382        field.update(current[key]);
383      }
384    }
385  };
386  scope.add(
387    values.subscribeImmediate((values) => {
388      current = values;
389      if (name === "environment" && values) {
390        environment.set(String(values.environment));
391      }
392      element.disabled = current === null;
393      draw();
394    }).unsubscribe,
395  );
396  scope.add(
397    remote.subscribeImmediate(({ pending, error }) => {
398      status.classList.toggle("error", error !== null);
399      status.textContent = error ??
400        (pending ? "applying configuration (pending if agent is busy)" : "");
401    }).unsubscribe,
402  );
403  return element;
404}