char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte SomOrder extension configuration consistently in new conversationsbcca6b5

main
6.6 KiB158 linesraw
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. */
21  open(name: string, cwd: string | null, context: Context): Promise<Environment>;
22  /** Let go of every connection. */
23  close(): Promise<void>;
24};
25
26/** Only the machine berry runs on. */
27export const localOnly: Environments = {
28  open(name, cwd) {
29    if (name !== LOCAL) return Promise.reject(new Error(`No environment named ${name}`));
30    return Promise.resolve(localEnvironment(cwd ?? homedir()));
31  },
32  close: () => 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. */
38  readonly ssh: string;
39  /** How the far side runs deno, which the worker needs; default `deno`. */
40  readonly 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(
51  configured: () => Promise<Readonly<Record<string, EnvironmentSettings>>>,
52  start: (name: string, settings: EnvironmentSettings) => Promise<Connection> = (name, settings) =>
53    Connection.open(name, sshCommand(settings.ssh, settings.deno)),
54): Environments {
55  const connections = new Map<string, { key: string; connection: Promise<Connection> }>();
56  return {
57    async open(name, cwd, context) {
58      if (name === LOCAL) return await localOnly.open(name, cwd, context);
59      const settings = (await configured())[name];
60      if (settings === undefined) throw new Error(`No environment named ${name}`);
61      const key = JSON.stringify([settings.ssh, settings.deno]);
62      const replace = (stale?: { connection: Promise<Connection> }) => {
63        if (stale) void stale.connection.then((connection) => connection.close(), () => {});
64        const fresh = { key, connection: start(name, settings) };
65        connections.set(name, fresh);
66        return fresh;
67      };
68      let open = connections.get(name);
69      if (open?.key !== key) open = replace(open);
70      else if (!(await open.connection.then((connection) => !connection.lost, () => false))) {
71        // a call that found it gone first has replaced it already
72        const current = connections.get(name);
73        open = current !== open && current?.key === key ? current : replace(open);
74      }
75      const connection = await open.connection;
76      return new RemoteEnvironment(name, connection, cwd ?? connection.hello.home);
77    },
78    async close() {
79      const open = [...connections.values()];
80      connections.clear();
81      await Promise.allSettled(
82        open.map(async ({ connection }) => await (await connection).close()),
83      );
84    },
85  };
86}
87
88const isLegacyCd = (event: JsonValue): event is { type: "cd"; cwd: string } =>
89  typeof event === "object" && event !== null && !Array.isArray(event) && event.type === "cd" &&
90  typeof event.cwd === "string";
91
92/** The binding as harness's own extension: always on, configured like any other. */
93export function environmentExtension(environments: Environments) {
94  return defineExtension<Binding, BindingEvent>({
95    name: ENVIRONMENT,
96    description: "the target machine & working directory for the conversation",
97    alwaysOn: true,
98    sortOrder: Number.MIN_SAFE_INTEGER,
99    initial: () => ({ environment: LOCAL, cwd: null }),
100    apply: (_, event) => ({ environment: event.environment, cwd: event.cwd }),
101    // before, the code extension kept the working directory, which was always on this machine
102    observe(state, entry) {
103      if (!EventEntry.is(entry)) return state;
104      const { ext, event } = entry.data;
105      return (ext === "code" || ext === "coding") && isLegacyCd(event)
106        ? { ...state, cwd: event.cwd }
107        : state;
108    },
109    setup(ext) {
110      ext.configuration({
111        parameters: Type.Object({
112          environment: Type.String({
113            title: "environment",
114            default: LOCAL,
115            description: "local, or an ssh environment (see settings)",
116          }),
117          cwd: Type.String({
118            title: "working directory",
119            default: "",
120            description: "if empty, defaults to $HOME",
121          }),
122        }),
123        values: ({ environment, cwd }) => ({ environment, cwd: cwd ?? "" }),
124        async events({ environment, cwd }, state) {
125          const moving = environment !== state.environment;
126          const env = await environments.open(
127            environment,
128            moving ? null : state.cwd,
129            BACKGROUND_CONTEXT,
130          );
131          // a change of machine alone leaves the old machine's directory in the form
132          const path = moving && cwd === (state.cwd ?? "") ? "" : cwd;
133          return [{
134            type: "bind",
135            environment,
136            cwd: await directory(env, path, BACKGROUND_CONTEXT),
137          }];
138        },
139      });
140      return (conv) => {
141        conv.command({
142          name: "cd",
143          description: "change conversation working directory",
144          execute: (path, ctx) => ctx.cd(path),
145        });
146        conv.prompt(({ env }) => {
147          if (env === undefined) {
148            return `the environment ${conv.state.environment} cannot be reached right now: ` +
149              "calls that use its files or commands fail until it can";
150          }
151          if (env.name === LOCAL) return undefined;
152          return `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}