char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somallow project reorderingb4fc6cf

main
83.2 KiB2186 linesraw
1import type { Context, JsonValue } from "@earendil-works/chord";
2import { homedir } from "node:os";
3import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
4import { diffRevisions, type Op } from "@earendil-works/chord/delta";
5import {
6  type AssistantMessage,
7  clampThinkingLevel,
8  type Context as ModelContext,
9  type ImageContent,
10  lazyStream,
11  type Models,
12  type ModelsSimpleStreamOptions,
13  type ModelThinkingLevel,
14  type MutableModels,
15  type Provider,
16  type TextContent,
17  type ToolCall,
18  Type,
19  validateToolArguments,
20} from "@earendil-works/pi-ai";
21import {
22  type Agent,
23  AgentDoc,
24  type AgentState,
25  type ConversationId,
26  createRegistry,
27  type Cursor,
28  DEFAULT_COMPACTION_POLICY,
29  defineDoc,
30  defineExtension as defineDurableExtension,
31  defineTool,
32  type EntryId,
33  type EntryRecord,
34  type Extension as DurableExtension,
35  GenerationTask,
36  Harness,
37  InboxDoc,
38  LiveDoc,
39  type ModelRef,
40  ProviderDoc,
41  section,
42  type Storage,
43  type SubmissionId,
44  type ToolExecutionApi,
45  type Tx,
46} from "@earendil-works/pi-durable";
47import { CODEMODE_SOURCE_GRAMMAR } from "@earendil-works/pi-codemode/source";
48import {
49  type Channel,
50  type ChannelHandler,
51  type Command,
52  type CompactionCap,
53  type Configuration,
54  configurationEvents,
55  type ContextHook,
56  type ConversationApi,
57  defineExtension,
58  type Extension,
59  type Handler,
60  handlerArguments,
61  type PayloadHook,
62  type PromptProse,
63  type PromptRender,
64  type SectionRender,
65  tightestCap,
66  type Tool,
67  type ToolContext,
68} from "@berry/api";
69import { imageResult } from "@berry/api/content.ts";
70import { MAX_MESSAGE_BYTES, userContent } from "@berry/api/images.ts";
71import { directory, type Environment, LOCAL } from "@berry/api/environment.ts";
72import {
73  applyStoreWrite,
74  DESCRIPTION,
75  type NestedTool,
76  renderCatalog,
77  runScript,
78  type StoreWrite,
79} from "./codemode.ts";
80import { EventEntry, StartEntry } from "./entries.ts";
81import { recordSettled, SettledDoc } from "./settled.ts";
82import { startTool } from "./tool-timing.ts";
83import {
84  type Binding,
85  type BindingEvent,
86  configuredEnvironments,
87  ENVIRONMENT,
88  ENVIRONMENT_NAME,
89  environmentExtension,
90  type Environments,
91  type EnvironmentSettings,
92} from "./environment.ts";
93import { projectFor, type ProjectInput, workspaceFor, workspacePath } from "@berry/api/projects.ts";
94import { ProjectOrder, Projects, resolveProject, validateRoots } from "./projects.ts";
95import { folders } from "./folders.ts";
96import { references } from "./references.ts";
97import { DEFAULT_SYSTEM_PROMPT, renderGuidelines, type SystemPromptSettings } from "./prompts.ts";
98import type { Feed } from "./pubsub.ts";
99import { SUBAGENT, type Subagent, subagentExtension } from "./subagent.ts";
100import { tid } from "./tid.ts";
101import { ShellDoc, shellTask } from "./shell.ts";
102import { conversationRegistry } from "./registry.ts";
103
104const context = BACKGROUND_CONTEXT;
105
106export { EventEntry, StartEntry };
107
108export type ConversationInfo = {
109  /** The durable conversation it began as. */
110  id: number;
111  created: number;
112  /** A user-chosen sidebar name, otherwise the first message is used. */
113  title?: string;
114  /** A fork's parent, and the entry of it the fork continues from. */
115  parent?: { tid: string; at: number };
116  owner?: string;
117  /** The branches `/tree` has made of it since, each a durable conversation that continues from one of its entries. */
118  branches?: { id: number; at: number; created: number }[];
119  /** The branch it continues on, when that is not the one it began as. */
120  head?: number;
121  /** When it was put out of the way, out of the list of conversations. */
122  archived?: number;
123  /** The newest message acknowledged by a frontend, shared across devices. */
124  readThrough?: number;
125};
126
127/** A stored choice of extensions, choosing by `name` the ones it chose by a name in `formerly`. */
128function adopt(agent: AgentState, formerly: readonly string[], name: string) {
129  const renamed = (names: readonly string[]) => [
130    ...new Set(names.map((chosen) => (formerly.includes(chosen) ? name : chosen))),
131  ];
132  const stale = (names?: readonly string[]) => names?.some((chosen) => formerly.includes(chosen));
133  const selection = agent.extensions;
134  if (Array.isArray(selection)) {
135    if (stale(selection)) agent.extensions = renamed(selection);
136  } else if (selection !== undefined) {
137    if (stale(selection.add)) selection.add = renamed(selection.add!);
138    if (stale(selection.remove)) selection.remove = renamed(selection.remove!);
139  }
140}
141
142/** The durable conversation a conversation continues on. */
143export const headOf = (info: ConversationInfo) => (info.head ?? info.id) as ConversationId;
144
145/** Every branch of a conversation, the one it continues on first. */
146const branchesOf = (info: ConversationInfo) =>
147  [
148    ...new Set([headOf(info), info.id, ...(info.branches ?? []).map((branch) => branch.id)]),
149  ] as ConversationId[];
150
151/** A change to a conversation's agent; `null` clears a field. */
152export type AgentChange = {
153  readonly model?: ModelRef | null;
154  readonly thinkingLevel?: ModelThinkingLevel | null;
155  /** Exactly these extensions, in order. */
156  readonly extensions?: readonly string[];
157};
158
159export const Conversations = defineDoc<Record<string, ConversationInfo>>({
160  kind: "berry.conversations",
161  version: 1,
162  scope: "session",
163  initial: () => ({}),
164  checkpointWhen: () => true,
165});
166
167/** The harness's own settings, shared by every conversation. */
168export type BerrySettings = {
169  /** The models conversations may use, out of those the logged-in providers offer. */
170  models: { provider: string; modelId: string }[];
171  /** The machines conversations may work on besides this one, by name. */
172  environments?: Record<string, EnvironmentSettings>;
173  allowLocalEnvironment?: boolean;
174  systemPrompt?: SystemPromptSettings;
175};
176
177export const Settings = defineDoc<BerrySettings>({
178  kind: "berry.settings",
179  version: 1,
180  scope: "session",
181  initial: () => ({
182    models: [],
183    allowLocalEnvironment: true,
184    systemPrompt: { ...DEFAULT_SYSTEM_PROMPT },
185  }),
186  checkpointWhen: () => true,
187});
188
189// deno-lint-ignore no-explicit-any
190type AnyExtension = Extension<any, any>;
191// deno-lint-ignore no-explicit-any
192type AnyTool = Tool<any, any>;
193// deno-lint-ignore no-explicit-any
194type AnyCommand = Command<any, any>;
195// deno-lint-ignore no-explicit-any
196type AnyHandler = Handler<any>;
197// deno-lint-ignore no-explicit-any
198type AnyChannelHandler = ChannelHandler<any>;
199
200/** The calls running in one load of an extension, so a replaced load is disposed only once they end. */
201type Calls = { count: number; drained: PromiseWithResolvers<void> | null };
202const idle = (): Calls => ({ count: 0, drained: null });
203
204async function running<T>(of: Loaded, work: () => Promise<T>): Promise<T> {
205  of.calls.count++;
206  try {
207    return await work();
208  } finally {
209    if (--of.calls.count === 0) of.calls.drained?.resolve();
210  }
211}
212
213type Loaded = {
214  readonly extension: AnyExtension;
215  readonly providers: readonly Provider[];
216  readonly replacedProviders: ReadonlyMap<string, Provider>;
217  readonly compactionCaps: readonly (() => CompactionCap | undefined)[];
218  readonly tools: readonly AnyTool[];
219  readonly handlers: readonly AnyHandler[];
220  readonly channels: readonly AnyChannelHandler[];
221  /** Channels open to this load, which close with it. */
222  readonly open: Set<Channel>;
223  readonly prompts: readonly { name: string; render: SectionRender<JsonValue> }[];
224  readonly promptProse?: PromptProse;
225  readonly configuration?: Configuration<JsonValue, JsonValue>;
226  readonly conversation?: (conv: ConversationApi<JsonValue, JsonValue>) => void;
227  readonly disposers: readonly (() => void | Promise<void>)[];
228  readonly calls: Calls;
229};
230
231type Contribution = {
232  readonly tools: AnyTool[];
233  readonly prompts: PromptRender[];
234  readonly payloadHooks: PayloadHook[];
235  readonly contextHooks: ContextHook[];
236  readonly commands: AnyCommand[];
237};
238
239/** The durable tool invocation every nested call runs inside of. */
240type Frame = { readonly api: ToolExecutionApi; readonly context: Context };
241
242type CallOptions = {
243  readonly signal?: AbortSignal;
244  readonly output?: (chunk: string) => void;
245  readonly progress?: (details: JsonValue) => void;
246};
247
248export class BerryError extends Error {}
249
250/** An extension's failure, for the user to read. */
251const surfaced = (error: unknown, prefix = "") =>
252  new BerryError(prefix + (error instanceof Error ? error.message : String(error)), {
253    cause: error,
254  });
255
256export type CreateOptions = {
257  /** Selected extensions, in order, with their configuration values. */
258  readonly extensions: Readonly<Record<string, JsonValue>>;
259  readonly model?: ModelRef;
260  readonly thinkingLevel?: ModelThinkingLevel;
261};
262
263const NEVER = new AbortController().signal;
264
265function toContent(value: JsonValue | undefined): (TextContent | ImageContent)[] {
266  if (value === undefined) return [];
267  if (typeof value === "string") return [{ type: "text", text: value }];
268  const image = imageResult(value).value;
269  if (image && !Array.isArray(value)) {
270    return [{ type: "image", data: image.data, mimeType: image.mimeType }];
271  }
272  return [{ type: "text", text: JSON.stringify(value, null, 2) }];
273}
274
275const exposure = (tool: AnyTool) => tool.expose ?? "codemode";
276
277const declaration = (of: Loaded, tool: AnyTool) => ({
278  name: tool.name,
279  extension: of.extension.name,
280  description: tool.description,
281  parameters: tool.parameters,
282  ...(tool.returns === undefined ? {} : { returns: tool.returns }),
283  direct: exposure(tool) === "both",
284});
285
286/** How an extension's last load went. */
287export type ExtensionStatus = {
288  readonly name: string;
289  /** The source directory when known, or null for an extension without a unique source. */
290  readonly directory?: string | null;
291  readonly description: string | null;
292  readonly status: "loaded" | "failed";
293  /** Why the last load failed; the version before it, if any, still runs. */
294  readonly error: string | null;
295};
296
297/** What the process running berry offers its self-modification tools. */
298export type Host = {
299  /** Where berry's source is. */
300  readonly root: string;
301  /** Where extensions on disk are discovered and watched. */
302  readonly extensionDirectories?: readonly string[];
303  /** What `deno check` says of the code on disk, or `null` when it passes. */
304  check(): Promise<string | null>;
305  /** Stop gracefully, and come back running the code on disk. */
306  restart(): void;
307  extensions(): readonly ExtensionStatus[];
308  /** Load an extension's files again. */
309  reload(name: string): Promise<ExtensionStatus>;
310};
311
312/** The harness's own extension: codemode, and with a host the /reload command. Its state is the store. */
313export const Builtin = defineExtension<Record<string, JsonValue>, StoreWrite>({
314  name: "berry",
315  alwaysOn: true,
316  initial: () => ({}),
317  apply: applyStoreWrite,
318  setup() {},
319});
320
321/** The user's `/reload`: every extension on disk, or the one named. */
322function reloadCommand(host: Host): AnyCommand {
323  return {
324    name: "reload",
325    description: "reload all extensions or one extension by name",
326    async execute(args) {
327      const names = args.trim() ? [args.trim()] : host.extensions().map((status) => status.name);
328      const statuses = [];
329      for (const name of names) statuses.push(await host.reload(name));
330      const failed = statuses.filter((status) => status.status === "failed");
331      if (failed.length > 0) {
332        throw new BerryError(failed.map(({ name, error }) => `${name}: ${error}`).join("\n\n"));
333      }
334      return `reloaded ${names.join(", ")}`;
335    },
336  };
337}
338
339function guide(root: string, directories: readonly string[] = [`${root}/extensions`]): string {
340  return [
341    `the assistant's operating environment is berry. its source is at ${root}, a deno project.`,
342    `- extensions live in ${directories.join(" and ")}, each under <name>/: ` +
343    "mod.ts (`defineExtension` from @berry/api), an optional " +
344    "web.tsx (renderers, from @berry/api/web.ts), and tests using @berry/api/testing.ts. " +
345    "an extension imports only its own directory's files and bare specifiers.",
346    "- saving an extension's files auto-reloads it once `deno check` passes.",
347    "- events are kept forever: a reducer must keep folding every event shape it has ever recorded.",
348    "- changes outside extension directories need the header's restart button, which checks the code first.",
349  ].join("\n");
350}
351
352export async function openBerry(
353  storage: Storage,
354  models: MutableModels,
355  host?: Host,
356  given?: Environments,
357) {
358  const machines = given ??
359    configuredEnvironments(async () =>
360      (await harness.snapshot(Settings, context))?.environments ?? {}
361    );
362  const environments: Environments = {
363    async open(name, cwd, during) {
364      await requireEnvironmentAllowed(name);
365      return await machines.open(name, cwd, during);
366    },
367    close: () => machines.close(),
368  };
369  const registry = createRegistry();
370  const loaded = new Map<string, Loaded>();
371  const folds = new Map<string, { loaded: Loaded; upTo: number; state: JsonValue }>();
372  const tids = new Map<number, string>();
373  const providerSessions = new Map<string, ConversationId>();
374  /** Live state feeds; a reinstall changes state without a commit. */
375  const stateFeeds = new Set<() => void>();
376
377  const own: Loaded = {
378    extension: Builtin,
379    providers: [],
380    replacedProviders: new Map(),
381    compactionCaps: [],
382    tools: [],
383    handlers: [],
384    channels: [],
385    open: new Set(),
386    prompts: [],
387    disposers: [],
388    calls: idle(),
389    ...(host ? { conversation: (conv) => conv.command(reloadCommand(host)) } : {}),
390  };
391  loaded.set(Builtin.name, own);
392  const shell = shellTask(async (command, runtime, during, progress) => {
393    const id = runtime.conversationId;
394    const of = (await selected(id)).find((of) => of.extension.name === "code");
395    const env = await environmentOf(id, during);
396    const name = env.platform === "win32" ? "powershell" : "bash";
397    if (!of) throw new BerryError("Shell commands require the code extension");
398    const tool = [...of.tools, ...(await contribution(id, of)).tools]
399      .findLast((tool) => tool.name === name);
400    if (!tool) throw new BerryError(`The code extension has no ${name} tool for this conversation`);
401    let state = await stateOf(id, of);
402    const value = await running(of, async () =>
403      await tool.execute({ command }, {
404        tid: await tidOf(id),
405        get state() {
406          return state;
407        },
408        env,
409        signal: runtime.signal,
410        emit: async (event: JsonValue) => {
411          await runtime.commit(async (tx) => {
412            await tx.appendEntry(id, { kind: EventEntry.kind, data: { ext: "code", event } });
413          }, during);
414          state = await stateOf(id, of);
415        },
416        output: () => {},
417        progress,
418      }));
419    return { value, text: tool.text ? tool.text(value) : JSON.stringify(value) ?? "(no output)" };
420  }, () => harness);
421  const builtin = defineDurableExtension({
422    name: Builtin.name,
423    tasks: [shell],
424    tools: [
425      defineTool({
426        name: "codemode",
427        description: DESCRIPTION,
428        parameters: Type.Object({ code: Type.String({ description: "Raw JavaScript source." }) }),
429        // capable models write the script as raw text instead of a json-escaped string
430        constrainedSampling: {
431          type: "grammar",
432          variants: { openai_lark: CODEMODE_SOURCE_GRAMMAR },
433        },
434        execute: async ({ code }, api, context) => {
435          await startTool(api, context);
436          const id = api.conversationId;
437          const frame = { api, context };
438          const result = await runScript(code, {
439            tools: await nested(id, frame),
440            store: await stateOf(id, own) as Record<string, JsonValue>,
441            signal: context.abortSignal ?? NEVER,
442            // a rejection only means the invocation ended first
443            progress: (details) => void api.details(details, context).catch(() => {}),
444          });
445          if (result.storeWrites) await commitEvent(frame, id, Builtin.name, result.storeWrites);
446          return { output: result.content, isError: result.isError, details: result.details };
447        },
448      }),
449    ],
450    sections: [
451      section("lede", async () => {
452        const { userName } = await systemPrompt();
453        return "we are a multispecialist software dev & sysadmin team. you are assisting the user" +
454          (userName ? ` (${userName})` : "");
455      }, { tag: false }),
456      section("guidelines", async () => {
457        const { generalGuidelines, userName } = await systemPrompt();
458        return renderGuidelines(generalGuidelines, userName);
459      }, { tag: false }),
460      ...(host
461        ? [
462          section("berry", ({ env }) =>
463            (env as Environment | undefined)?.name === LOCAL
464              ? guide(host.root, host.extensionDirectories)
465              : undefined),
466        ]
467        : []),
468      section(
469        "codemode",
470        async (input) => renderCatalog(await declarations(input.conversationId)),
471        { tag: false },
472      ),
473    ],
474  });
475  registry.install(builtin);
476
477  // model reads go through the tightest compaction cap, so generation compacts at one
478  // threshold whatever the model's window; the stored providers keep their own windows
479  const effectiveModels: Models = new Proxy(models, {
480    get(target, prop) {
481      if (prop === "streamSimple" || prop === "completeSimple") {
482        return (...[model, request, options]: Parameters<Models[typeof prop]>) => {
483          const source = lazyStream(model, async () => {
484            const id = await providerConversation(options?.sessionId);
485            return await target.streamSimple(
486              model,
487              await contextRequest(id, model, request, options),
488              payloadOptions(id, await cacheSession(model, id, options)),
489            );
490          });
491          // pi-ai 1.1.0's transient-error classifier misses raw EOF messages.
492          const normalizeError = (message: AssistantMessage): AssistantMessage =>
493            message.stopReason === "error" &&
494              /\b(?:unexpected eof|error reading a body from connection)\b/i.test(
495                message.errorMessage ?? "",
496              )
497              ? { ...message, errorMessage: `Network error: ${message.errorMessage}` }
498              : message;
499          const stream = lazyStream(model, () =>
500            Promise.resolve({
501              async *[Symbol.asyncIterator]() {
502                for await (const event of source) {
503                  yield event.type === "error"
504                    ? { ...event, error: normalizeError(event.error) }
505                    : event;
506                }
507              },
508              result: async () => normalizeError(await source.result()),
509            }));
510          return prop === "completeSimple" ? stream.result() : stream;
511        };
512      }
513      if (
514        prop === "getModel" || prop === "getModelOfType" || prop === "getModels" ||
515        prop === "getModelsOfType" || prop === "getAllModels" || prop === "getAvailable" ||
516        prop === "getAvailableOfType" || prop === "getAllAvailable"
517      ) {
518        const read = Reflect.get(target, prop) as (...args: never[]) => unknown;
519        return (...args: never[]) => {
520          const found = read.apply(target, args);
521          const cap = effectiveCap();
522          if (cap === undefined) return found;
523          if (found instanceof Promise) {
524            return found.then((value) =>
525              Array.isArray(value)
526                ? value.map((model) => cappedModel(model, cap))
527                : cappedModel(value, cap)
528            );
529          }
530          return Array.isArray(found)
531            ? found.map((model) => cappedModel(model, cap))
532            : cappedModel(found, cap);
533        };
534      }
535      const value = Reflect.get(target, prop);
536      return typeof value === "function" ? value.bind(target) : value;
537    },
538  });
539
540  const harness = await Harness.open(storage, {
541    // the proxied catalog keeps generation honest about the cap without touching stored providers
542    models: effectiveModels,
543    registry: conversationRegistry(registry, resolveTools),
544    // every conversation selects the always-on extensions; their stored choices only add to them
545    settings: {
546      retry: { enabled: true, maxRetries: 3, baseDelayMs: 2_000, maxAgentDelayMs: 60_000 },
547      get compaction() {
548        const cap = effectiveCap();
549        return cap === undefined ? {} : { backgroundTokens: cap.backgroundTokens };
550      },
551      get extensions() {
552        return registry.snapshot().installed().filter((ext) =>
553          ext.name === SUBAGENT || loaded.get(ext.name)?.extension.alwaysOn
554        );
555      },
556    },
557    env: ({ conversationId }, during) => environmentOf(conversationId, during),
558    onReport: (error) => console.error("[berry]", error),
559    conversationCreated: async (tx, record) => {
560      const index = await tx.doc(Conversations);
561      const tidFor = (id: ConversationId) =>
562        Object.entries(index).find(([, info]) => branchesOf(info).includes(id))?.[0];
563      const parent = record.parent && tidFor(record.parent.conversationId);
564      const owner = record.owner && tidFor(record.owner.conversationId);
565      index[tid()] = {
566        id: record.id,
567        created: Date.now(),
568        ...(parent === undefined ? {} : {
569          parent: { tid: parent, at: record.parent!.at },
570          readThrough: record.parent!.at,
571        }),
572        ...(owner === undefined ? {} : { owner }),
573      };
574      if (record.parent === undefined) {
575        await tx.appendEntry(StartEntry, record.id, {});
576        await tx.doc(SettledDoc, record.id);
577      }
578      // a fork starts with its parent's choice as of the fork point, which may name extensions by what they were
579      if (record.parent !== undefined) {
580        const agent = await tx.doc(AgentDoc, record.id);
581        for (const { extension } of loaded.values()) {
582          adopt(agent, extension.formerly ?? [], extension.name);
583        }
584      }
585    },
586  }, context);
587  const stopSettled = recordSettled(harness);
588  // watchDoc never creates a document; the conversations topic needs this one from the start
589  await harness.commit(async (tx) => {
590    await tx.doc(Conversations);
591    const settings = await tx.doc(Settings);
592    settings.systemPrompt = { ...DEFAULT_SYSTEM_PROMPT, ...settings.systemPrompt };
593    const legacy = settings.systemPrompt as SystemPromptSettings & { codingGuidelines?: unknown };
594    if (typeof legacy.codingGuidelines === "string" && legacy.promptProse?.code === undefined) {
595      const text = legacy.codingGuidelines;
596      legacy.promptProse = {
597        ...(legacy.promptProse ?? {}),
598        code: text.trim() ? `guidelines:\n${text}` : text,
599      };
600    }
601    delete legacy.codingGuidelines;
602    await tx.doc(Projects);
603    await tx.doc(ProjectOrder);
604  }, context);
605
606  async function systemPrompt(): Promise<SystemPromptSettings> {
607    return (await harness.snapshot(Settings, context))?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
608  }
609
610  /** Editable prose for `name`, with `$USER` replaced and blank prose omitted. */
611  async function resolveProse(
612    name: string,
613    registered: PromptProse | undefined,
614  ): Promise<string | undefined> {
615    if (registered === undefined) return undefined;
616    const { userName, promptProse } = await systemPrompt();
617    const raw = Object.hasOwn(promptProse ?? {}, name) ? promptProse![name] : registered.default;
618    return raw.trim() ? raw.replaceAll("$USER", () => userName) : undefined;
619  }
620
621  /** The level `model` runs `requested` at: a conversation's level is always one its model supports. */
622  function supported(model: ModelRef, requested: ModelThinkingLevel): ModelThinkingLevel {
623    const known = models.getModel(model.provider, model.modelId);
624    return known ? clampThinkingLevel(known, requested) : requested;
625  }
626
627  async function requireEnabled(model: ModelRef) {
628    const settings = await harness.snapshot(Settings, context);
629    const enabled = settings?.models.some((m) =>
630      m.provider === model.provider && m.modelId === model.modelId
631    );
632    if (!enabled) {
633      throw new BerryError(`${model.provider}/${model.modelId} is not enabled in settings`);
634    }
635  }
636
637  async function idOf(tid: string): Promise<ConversationId | undefined> {
638    const index = await harness.snapshot(Conversations, context);
639    const info = index?.[tid];
640    return info && headOf(info);
641  }
642
643  async function existing(tid: string): Promise<ConversationId> {
644    const id = await idOf(tid);
645    if (id === undefined) throw new BerryError(`No conversation ${tid}`);
646    return id;
647  }
648
649  async function conversationOf(tid: string) {
650    const id = await idOf(tid);
651    return id === undefined ? undefined : await harness.conversation(id, context);
652  }
653
654  /** The name an extension goes by now, given any it went by. */
655  function current(name: string): string {
656    if (loaded.has(name)) return name;
657    return [...loaded.values()].find((of) => of.extension.formerly?.includes(name))?.extension
658      .name ??
659      name;
660  }
661
662  function selection(names: readonly string[]) {
663    const snapshot = registry.snapshot();
664    const chosen = names.map(current).filter((name) => !loaded.get(name)?.extension.alwaysOn);
665    const add = chosen.map((name) => {
666      const installed = snapshot.extension(name);
667      if (installed === undefined) throw new BerryError(`No extension named ${name}`);
668      return installed;
669    });
670    return {
671      add,
672      // old conversations inherit delegation; explicit choices override that default
673      ...(chosen.includes(SUBAGENT) ? {} : { remove: [subagent.extension] }),
674    };
675  }
676
677  // Read from committed state only: a failed creating commit may hand its ID out again.
678  async function tidOf(id: ConversationId): Promise<string> {
679    if (!tids.has(id)) {
680      const index = await harness.snapshot(Conversations, context);
681      for (const [tid, info] of Object.entries(index ?? {})) {
682        for (const id of branchesOf(info)) tids.set(id, tid);
683      }
684    }
685    const found = tids.get(id);
686    if (found === undefined) throw new BerryError(`Conversation ${id} has no TID`);
687    return found;
688  }
689
690  async function conversation(id: ConversationId) {
691    const found = await harness.conversation(id, context);
692    if (found === undefined) throw new BerryError(`No conversation ${id}`);
693    return found;
694  }
695
696  /** The branch of `tid` whose history holds its entry `at`, the one it continues on if that does. */
697  async function locate(tid: string, at: number) {
698    const info = (await harness.snapshot(Conversations, context))?.[tid];
699    for (const id of info ? branchesOf(info) : []) {
700      const branch = await conversation(id);
701      const query = {
702        order: "descending",
703        minEntryId: at as EntryId,
704        maxEntryId: at as EntryId,
705      } as const;
706      if ((await branch.entries(query, 1, undefined, context)).items.length > 0) return branch;
707    }
708    return undefined;
709  }
710
711  /**
712   * Continue `tid` from its entry `at`: on a branch of it that ends there, or else on a new one, which keeps the agent
713   * it has now. A busy conversation stays where it is.
714   */
715  async function branch(tid: string, at: number) {
716    const info = (await harness.snapshot(Conversations, context))?.[tid];
717    if (info === undefined) throw new BerryError(`No conversation ${tid}`);
718    const head = headOf(info);
719    const ending = await (async () => {
720      for (const id of branchesOf(info)) {
721        const query = { order: "descending" } as const;
722        const [tip] = (await (await conversation(id)).entries(query, 1, undefined, context)).items;
723        if (tip?.id === at) return id;
724      }
725    })();
726    if (ending === head) return;
727    if (
728      (await harness.snapshot(LiveDoc, head, context))?.run ||
729      (await harness.snapshot(ShellDoc, head, context))?.run
730    ) {
731      throw new BerryError(`${tid} is busy: let it finish, or abort it, first`);
732    }
733    if (ending !== undefined) {
734      return await harness.commit(async (tx) => {
735        const moved = (await tx.doc(Conversations))[tid];
736        if (ending === moved.id) delete moved.head;
737        else moved.head = ending;
738      }, context);
739    }
740    const source = await locate(tid, at);
741    if (source === undefined) throw new BerryError(`No entry ${at} in ${tid}`);
742    const agent = await harness.snapshot(AgentDoc, head, context);
743    await source.fork(at as EntryId, {
744      ownership: { kind: "ownerless" },
745      init: async (tx, id) => {
746        // conversationCreated took the branch for a conversation of its own
747        const index = await tx.doc(Conversations);
748        for (const [other, { id: of }] of Object.entries(index)) {
749          if (of === id) delete index[other];
750        }
751        const moved = index[tid];
752        moved.branches = [...(moved.branches ?? []), { id, at, created: Date.now() }];
753        moved.head = id;
754        const copy = await tx.doc(AgentDoc, id);
755        for (const key of Object.keys(copy)) delete copy[key as keyof typeof copy];
756        Object.assign(copy, structuredClone(agent ?? {}));
757      },
758    }, context);
759  }
760
761  /**
762   * A feed of `tid` that follows it onto whichever branch it continues on, opened by `open`: a move reads as the diff
763   * between the branches' values.
764   */
765  async function follow(
766    tid: string,
767    open: (id: ConversationId) => Promise<Feed | undefined>,
768  ): Promise<Feed | undefined> {
769    let id = await idOf(tid);
770    let inner = id === undefined ? undefined : await open(id);
771    if (inner === undefined) return undefined;
772    let value = inner.value;
773    let listener: ((value: unknown, ops: readonly Op[]) => Promise<void>) | undefined;
774    const forward = (feed: Feed) =>
775      feed.start(async (next, ops) => {
776        // a branch moved off may still deliver what it had pending
777        if (feed !== inner) return;
778        value = next;
779        await listener?.(value, ops);
780      });
781    let moving = Promise.resolve();
782    const move = () => {
783      moving = moving.then(async () => {
784        const next = await idOf(tid);
785        if (next === undefined || next === id || listener === undefined) return;
786        const feed = await open(next);
787        if (feed === undefined) return;
788        const previous = inner!;
789        id = next;
790        inner = feed;
791        const ops = diffRevisions(value as JsonValue, feed.value as JsonValue);
792        value = feed.value;
793        // the move's ops go out before anything the new branch's feed delivers, which is never inline
794        forward(feed);
795        await listener(value, ops);
796        await previous.stop();
797      }).catch((error) => console.error(`[berry] following ${tid}:`, error));
798    };
799    const unsubscribe = harness.subscribeCommits((publication) => {
800      const moved = publication.changes.some((change) =>
801        change.type === "document" && change.record.kind === Conversations.definition.kind
802      );
803      if (moved) queueMicrotask(move);
804    });
805    return {
806      get value() {
807        return value;
808      },
809      start(next) {
810        listener = next;
811        forward(inner!);
812      },
813      async stop() {
814        listener = undefined;
815        unsubscribe();
816        await moving;
817        await inner!.stop();
818      },
819    };
820  }
821
822  /** Conversations that chose an extension by a name it went by choose it by its name now. */
823  async function rename(extension: AnyExtension) {
824    const formerly = extension.formerly ?? [];
825    if (formerly.length === 0) return;
826    const index = await harness.snapshot(Conversations, context) ?? {};
827    const stale: ConversationId[] = [];
828    for (const id of Object.values(index).flatMap(branchesOf)) {
829      const agent = structuredClone(await harness.snapshot(AgentDoc, id, context) ?? {});
830      const before = JSON.stringify(agent);
831      adopt(agent, formerly, extension.name);
832      if (JSON.stringify(agent) !== before) stale.push(id);
833    }
834    if (stale.length === 0) return;
835    await harness.commit(async (tx) => {
836      for (const id of stale) adopt(await tx.doc(AgentDoc, id), formerly, extension.name);
837    }, context);
838  }
839
840  function fold(extension: AnyExtension, state: JsonValue, entry: EntryRecord): JsonValue {
841    const own = (ext: string) => ext === extension.name || extension.formerly?.includes(ext);
842    if (EventEntry.is(entry) && own(entry.data.ext) && extension.apply) {
843      state = extension.apply(state, entry.data.event);
844    }
845    return extension.observe ? extension.observe(state, entry) : state;
846  }
847
848  async function stateOf(id: ConversationId, of: Loaded, tx?: Tx): Promise<JsonValue> {
849    const key = `${id}:${of.extension.name}`;
850    const cached = folds.get(key);
851    let { upTo, state } = cached?.loaded === of
852      ? cached
853      : { upTo: 0, state: of.extension.initial() };
854    const history = tx ? undefined : await conversation(id);
855    const query = { order: "ascending", minEntryId: (upTo + 1) as EntryId } as const;
856    let cursor: Cursor | undefined;
857    do {
858      const page = tx
859        ? await tx.scanEntries({ conversationId: id, ...query }, 256, cursor)
860        : await history!.entries(query, 256, cursor, context);
861      for (const entry of page.items) {
862        state = fold(of.extension, state, entry);
863        upTo = entry.id;
864      }
865      cursor = page.next;
866    } while (cursor !== undefined);
867    const latest = folds.get(key);
868    if (latest?.loaded !== of || latest.upTo < upTo) folds.set(key, { loaded: of, upTo, state });
869    return state;
870  }
871
872  async function contribution(id: ConversationId, of: Loaded): Promise<Contribution> {
873    const state = await stateOf(id, of);
874    const env = of.conversation ? await environmentOf(id).catch(() => undefined) : undefined;
875
876    const result: Contribution = {
877      tools: [],
878      prompts: [],
879      commands: [],
880      payloadHooks: [],
881      contextHooks: [],
882    };
883    of.conversation?.({
884      tid: await tidOf(id),
885      state,
886      env,
887      tool: (tool) => {
888        if (tool.name === SUBAGENT || (tool.name === "codemode" && exposure(tool) !== "codemode")) {
889          throw new BerryError(
890            `${of.extension.name}: tool ${tool.name} is reserved for the harness`,
891          );
892        }
893        result.tools.push(tool as AnyTool);
894      },
895      prompt: (render) => void result.prompts.push(render),
896      onPayload: (hook) => void result.payloadHooks.push(hook),
897      onContext: (hook) => void result.contextHooks.push(hook),
898      command: (command) => void result.commands.push(command),
899    });
900    return result;
901  }
902
903  async function providerConversation(sessionId?: string): Promise<ConversationId | undefined> {
904    if (sessionId === undefined) return undefined;
905    // pi-durable exposes the conversation to providers through its stable session identity.
906    if (!providerSessions.has(sessionId)) {
907      const index = await harness.snapshot(Conversations, context) ?? {};
908      for (const id of new Set(Object.values(index).flatMap(branchesOf))) {
909        const provider = await harness.snapshot(ProviderDoc, id, context);
910        if (provider) providerSessions.set(provider.sessionId, id);
911      }
912    }
913    return providerSessions.get(sessionId);
914  }
915
916  async function cacheSession(
917    model: Parameters<ContextHook>[1],
918    id: ConversationId | undefined,
919    options?: ModelsSimpleStreamOptions,
920  ): Promise<ModelsSimpleStreamOptions | undefined> {
921    // codex pools a websocket per session and continues it with previous_response_id, so its session
922    // stays per branch, and payloadOptions patches its cache key in the body instead
923    if (id === undefined || model.api === "openai-codex-responses") return options;
924    const tid = await tidOf(id).catch(() => undefined);
925    return tid === undefined ? options : { ...options, sessionId: tid };
926  }
927
928  async function contextRequest(
929    id: ConversationId | undefined,
930    model: Parameters<ContextHook>[1],
931    request: ModelContext,
932    options?: ModelsSimpleStreamOptions,
933  ): Promise<ModelContext> {
934    let messages = request.messages;
935    if (id !== undefined) {
936      await requireEnvironmentAllowed((await bindingOf(id)).environment);
937      const extensions = await selected(id);
938      let copied = false;
939      for (const of of extensions) {
940        const { contextHooks } = await contribution(id, of);
941        if (contextHooks.length === 0) continue;
942        if (!copied) {
943          messages = structuredClone(messages);
944          copied = true;
945        }
946        await running(of, async () => {
947          for (const hook of contextHooks) {
948            const next = await hook(messages, model);
949            if (next !== undefined) messages = [...next];
950          }
951        });
952      }
953      options?.signal?.throwIfAborted();
954    }
955    // anthropic refuses a failed tool result that is not all text, and so every later request with it
956    // in the history; one with images goes as a success instead, its text still saying it failed
957    if (model.api === "anthropic-messages") {
958      messages = messages.map((message) =>
959        message.role === "toolResult" && message.isError &&
960          message.content.some((block) => block.type === "image")
961          ? { ...message, isError: false }
962          : message
963      );
964    }
965    return messages === request.messages ? request : { ...request, messages };
966  }
967
968  function payloadOptions(
969    id: ConversationId | undefined,
970    options?: ModelsSimpleStreamOptions,
971  ): ModelsSimpleStreamOptions {
972    return {
973      ...options,
974      onPayload: async (payload, model) => {
975        const original = await options?.onPayload?.(payload, model);
976        if (original !== undefined) payload = original;
977        if (id === undefined) return payload;
978        for (const of of await selected(id)) {
979          const { payloadHooks } = await contribution(id, of);
980          await running(of, async () => {
981            for (const hook of payloadHooks) {
982              const next = await hook(payload, model);
983              if (next !== undefined) payload = next;
984            }
985          });
986        }
987        // a provider whose session cannot be the conversation's (codex, see cacheSession) keeps the
988        // conversation's key for its prompt cache here, so a rewind's new branch does not lose it
989        const tid = await tidOf(id).catch(() => undefined);
990        if (
991          tid !== undefined && typeof payload === "object" && payload !== null &&
992          !Array.isArray(payload) &&
993          typeof (payload as Record<string, unknown>).prompt_cache_key === "string"
994        ) {
995          payload = { ...payload, prompt_cache_key: tid };
996        }
997        return payload;
998      },
999    };
1000  }
1001
1002  async function selected(id: ConversationId): Promise<Loaded[]> {
1003    const agent = await (await conversation(id)).agent(context);
1004    return agent.extensions.flatMap(({ name }) => loaded.get(name) ?? []);
1005  }
1006
1007  async function resolveTools(agent: Agent, id: ConversationId): Promise<Agent> {
1008    const tools = new Map<string, Agent["tools"][number]>();
1009    const sources = new Map<string, AnyTool>();
1010    for (const extension of agent.extensions) {
1011      const own = new Map((extension.tools ?? []).map((tool) => [tool.name, tool]));
1012      const of = loaded.get(extension.name);
1013      const available = new Map(of?.tools.map((tool) => [tool.name, tool]));
1014      if (of) {
1015        for (const tool of (await contribution(id, of)).tools) {
1016          available.set(tool.name, tool);
1017          own.delete(tool.name);
1018          if (exposure(tool) !== "codemode") own.set(tool.name, compileTool(of, tool));
1019        }
1020      }
1021      for (const [name, tool] of own) {
1022        tools.set(name, tool);
1023        sources.delete(name);
1024        const source = available.get(name);
1025        if (source) sources.set(name, source);
1026      }
1027    }
1028    if ([...sources.values()].some((tool) => tool.enabled)) {
1029      const env = await environmentOf(id).catch(() => undefined);
1030      for (const [name, tool] of sources) {
1031        if (tool.enabled && !await tool.enabled({ env })) tools.delete(name);
1032      }
1033    }
1034    const filter = (await harness.snapshot(AgentDoc, id, context))?.tools;
1035    const offered = filter === undefined
1036      ? [...tools.values()]
1037      : Array.isArray(filter)
1038      ? [...new Set(filter)].flatMap((name) => tools.get(name) ?? [])
1039      : [...tools.values()].filter((tool) => !filter.remove.includes(tool.name));
1040    return { ...agent, tools: offered };
1041  }
1042
1043  /** Codemode-callable tools of the selected extensions; a later extension's tool replaces an earlier one. */
1044  async function callable(id: ConversationId) {
1045    const byName = new Map<string, { loaded: Loaded; tool: AnyTool }>();
1046    for (const of of await selected(id)) {
1047      const own = [
1048        ...of.tools,
1049        ...(await contribution(id, of)).tools,
1050      ];
1051      for (const tool of own) byName.set(tool.name, { loaded: of, tool });
1052    }
1053    for (const [name, { tool }] of byName) {
1054      if (exposure(tool) === "model") byName.delete(name);
1055    }
1056    if ([...byName.values()].some(({ tool }) => tool.enabled)) {
1057      const env = await environmentOf(id).catch(() => undefined);
1058      for (const [name, { tool }] of byName) {
1059        if (tool.enabled && !await tool.enabled({ env })) byName.delete(name);
1060      }
1061    }
1062    return byName;
1063  }
1064
1065  /** Commands of the selected extensions; a later extension's command replaces an earlier one. */
1066  async function commandsOf(id: ConversationId) {
1067    const byName = new Map<string, { loaded: Loaded; command: AnyCommand }>();
1068    for (const of of await selected(id)) {
1069      for (const command of (await contribution(id, of)).commands) {
1070        byName.set(command.name, { loaded: of, command });
1071      }
1072    }
1073    if ([...byName.values()].some(({ command }) => command.enabled)) {
1074      const env = await environmentOf(id).catch(() => undefined);
1075      for (const [name, { command }] of byName) {
1076        if (command.enabled && !await command.enabled({ env })) byName.delete(name);
1077      }
1078    }
1079    return byName;
1080  }
1081
1082  async function writeEvent(id: ConversationId, ext: string, event: JsonValue, content?: string) {
1083    const target = await conversation(id);
1084    return await target.submit({
1085      type: "write",
1086      entry: {
1087        kind: EventEntry.kind,
1088        data: { ext, event },
1089        ...(content === undefined ? {} : {
1090          model: [{ role: "user", content, timestamp: Date.now() }],
1091        }),
1092      },
1093    }, context);
1094  }
1095
1096  async function delegationOffered(id: ConversationId) {
1097    const agent = await (await conversation(id)).agent(context);
1098    return agent.extensions.some((ext) => ext.name === SUBAGENT);
1099  }
1100
1101  async function declarations(id: ConversationId) {
1102    const tools = [...(await callable(id)).values()].map(({ loaded: of, tool }) =>
1103      declaration(of, tool)
1104    );
1105    if (await delegationOffered(id)) {
1106      tools.push(...subagent.scripted.map(({ declaration }) => declaration));
1107    }
1108    return tools;
1109  }
1110
1111  /** What a script may call, each tool in its own extension's context, with arguments validated like the model's. */
1112  async function nested(id: ConversationId, frame: Frame): Promise<NestedTool[]> {
1113    const tools: NestedTool[] = [...(await callable(id)).values()].map(({ loaded: of, tool }) => ({
1114      ...declaration(of, tool),
1115      call: (args, options) => {
1116        const call = {
1117          type: "toolCall",
1118          id: frame.api.callId,
1119          name: tool.name,
1120          arguments: args,
1121        } as const;
1122        // deno-lint-ignore no-explicit-any
1123        return invoke(of, tool, id, validateToolArguments(tool, call as any), frame, options);
1124      },
1125    }));
1126    if (await delegationOffered(id)) {
1127      for (const { declaration, call } of subagent.scripted) {
1128        tools.push({
1129          ...declaration,
1130          call: (args, options) => {
1131            const toolCall = {
1132              type: "toolCall",
1133              id: frame.api.callId,
1134              name: declaration.name,
1135              arguments: args as ToolCall["arguments"],
1136            } as const;
1137            const validated = validateToolArguments(declaration, toolCall);
1138            return call(validated, frame.api, frame.context, options);
1139          },
1140        });
1141      }
1142    }
1143    return tools;
1144  }
1145
1146  async function commitEvent(frame: Frame, id: ConversationId, ext: string, event: JsonValue) {
1147    await frame.api.commit(
1148      (tx) => tx.appendEntry(id, { kind: EventEntry.kind, data: { ext, event } }),
1149      frame.context,
1150    );
1151  }
1152
1153  async function invoke(
1154    of: Loaded,
1155    tool: AnyTool,
1156    id: ConversationId,
1157    args: unknown,
1158    frame: Frame,
1159    options: CallOptions,
1160  ): Promise<JsonValue | undefined> {
1161    let state = await stateOf(id, of);
1162    // the harness's env option builds one for every call
1163    const env = frame.api.env as Environment | undefined;
1164    if (env === undefined) throw new BerryError("the call has no environment");
1165    if (tool.enabled && !await tool.enabled({ env })) {
1166      throw new BerryError(`Tool ${tool.name} is not enabled in this environment`);
1167    }
1168    const ctx: ToolContext<JsonValue, JsonValue> = {
1169      tid: await tidOf(id),
1170      get state() {
1171        return state;
1172      },
1173      env,
1174      signal: options.signal ?? frame.context.abortSignal ?? NEVER,
1175      emit: async (event) => {
1176        await commitEvent(frame, id, of.extension.name, event);
1177        state = await stateOf(id, of);
1178      },
1179      output: (chunk) => options.output?.(chunk),
1180      progress: (details) => options.progress?.(details),
1181    };
1182    return await running(of, async () => await tool.execute(args, ctx));
1183  }
1184
1185  /** Once the load has no call running; a replaced load takes no new ones. */
1186  function drained(of: Loaded): Promise<void> {
1187    if (of.calls.count === 0) return Promise.resolve();
1188    of.calls.drained ??= Promise.withResolvers();
1189    return of.calls.drained.promise;
1190  }
1191
1192  function compileTool(of: Loaded, tool: AnyTool) {
1193    return defineTool({
1194      name: tool.name,
1195      description: tool.description,
1196      parameters: tool.parameters,
1197      execute: async (args, api, context) => {
1198        await startTool(api, context);
1199        const value = await invoke(of, tool, api.conversationId, args, { api, context }, {
1200          output: (chunk) => api.output(chunk),
1201          // a rejection only means the invocation ended first
1202          progress: (details) => void api.details(details, context).catch(() => {}),
1203        });
1204        return {
1205          output: tool.text ? [{ type: "text", text: tool.text(value) }] : toContent(value),
1206        };
1207      },
1208    });
1209  }
1210
1211  function compile(of: Loaded) {
1212    const { name } = of.extension;
1213    return defineDurableExtension({
1214      name,
1215      tools: of.tools.filter((tool) => exposure(tool) !== "codemode").map((tool) =>
1216        compileTool(of, tool)
1217      ),
1218      sections: [
1219        section(name, async (input) => {
1220          const prose = await resolveProse(name, of.promptProse);
1221          const { prompts } = await contribution(input.conversationId, of);
1222          const env = input.env as Environment | undefined;
1223          const texts = await Promise.all(prompts.map((render) => render({ env })));
1224          const all = [prose, ...texts].filter((t) => t !== undefined);
1225          return all.length === 0 ? undefined : all.join("\n\n");
1226        }),
1227        ...of.prompts.map((prompt) =>
1228          section(`${name}_${prompt.name}`, async (input) =>
1229            await prompt.render({
1230              tid: await tidOf(input.conversationId),
1231              state: await stateOf(input.conversationId, of),
1232              env: input.env as Environment | undefined,
1233            }))
1234        ),
1235      ],
1236    });
1237  }
1238
1239  /** The tightest compaction cap any load reports; a failing getter reads as no cap. */
1240  function effectiveCap(): CompactionCap | undefined {
1241    const seen: (CompactionCap | undefined)[] = [];
1242    for (const of of loaded.values()) {
1243      for (const get of of.compactionCaps) {
1244        try {
1245          seen.push(get());
1246        } catch (error) {
1247          console.error(`[berry] compaction cap of ${of.extension.name}:`, error);
1248        }
1249      }
1250    }
1251    return tightestCap(seen);
1252  }
1253
1254  /** A catalog model with its effective context window under a cap, smaller windows untouched. */
1255  function cappedModel(model: unknown, cap: CompactionCap): unknown {
1256    if (typeof model !== "object" || model === null) return model;
1257    const window = (model as { contextWindow?: unknown }).contextWindow;
1258    const limit = cap.maxContextTokens + DEFAULT_COMPACTION_POLICY.reserveTokens;
1259    if (typeof window !== "number" || !(window > limit)) return model;
1260    return { ...(model as Record<string, unknown>), contextWindow: limit };
1261  }
1262
1263  function releaseProviders(of: Loaded) {
1264    for (const provider of of.providers) {
1265      if (models.getProvider(provider.id) !== provider) continue;
1266      const original = of.replacedProviders.get(provider.id);
1267      if (original) models.setProvider(original);
1268      else models.deleteProvider(provider.id);
1269    }
1270  }
1271
1272  async function dispose(of: Loaded) {
1273    for (const channel of of.open) channel.close();
1274    for (const disposer of of.disposers) {
1275      try {
1276        await disposer();
1277      } catch (error) {
1278        console.error(`[berry] disposing ${of.extension.name}:`, error);
1279      }
1280    }
1281  }
1282
1283  /** An extension's state in one branch as a feed: a snapshot, then diffs whenever history or the extension changes. */
1284  async function stateFeed(
1285    id: ConversationId,
1286    name: string,
1287    configuration = false,
1288  ): Promise<Feed | undefined> {
1289    const initial = loaded.get(name);
1290    if (initial === undefined || (configuration && !initial.configuration)) return undefined;
1291    const read = async (of: Loaded) => {
1292      const state = await stateOf(id, of);
1293      return configuration ? of.configuration?.values(state) ?? null : state;
1294    };
1295    let value = await read(initial);
1296    let listener: ((value: JsonValue, ops: readonly Op[]) => Promise<void>) | undefined;
1297    let dirty = false;
1298    let refreshing = false;
1299
1300    async function refresh() {
1301      refreshing = true;
1302      try {
1303        while (dirty && listener) {
1304          dirty = false;
1305          const of = loaded.get(name);
1306          if (of === undefined) continue;
1307          try {
1308            const next = await read(of);
1309            if (next === value) continue;
1310            const ops = diffRevisions(value, next);
1311            if (ops.length === 0) continue;
1312            value = next;
1313            await listener?.(value, ops);
1314          } catch (error) {
1315            console.error(`[berry] state of ${name} in ${id}:`, error);
1316          }
1317        }
1318      } finally {
1319        refreshing = false;
1320      }
1321    }
1322    const poke = () => {
1323      dirty = true;
1324      if (!refreshing) void refresh();
1325    };
1326
1327    // commit listeners must not call Session APIs inline
1328    const unsubscribe = harness.subscribeCommits((publication) => {
1329      const touched = publication.changes.some((change) =>
1330        change.type === "entry" && change.value.conversationId === id
1331      );
1332      if (touched) queueMicrotask(poke);
1333    });
1334    stateFeeds.add(poke);
1335    return {
1336      get value() {
1337        return value;
1338      },
1339      start(next) {
1340        listener = next;
1341        poke();
1342      },
1343      stop() {
1344        listener = undefined;
1345        unsubscribe();
1346        stateFeeds.delete(poke);
1347        return Promise.resolve();
1348      },
1349    };
1350  }
1351
1352  /** Load a berry extension, as the durable extension it compiles to unless the harness gives its own. */
1353  async function install(
1354    extension: AnyExtension,
1355    durable?: DurableExtension,
1356  ): Promise<void> {
1357    const providers: Provider[] = [];
1358    const compactionCaps: (() => CompactionCap | undefined)[] = [];
1359    const tools: AnyTool[] = [];
1360    const handlers: AnyHandler[] = [];
1361    const channels: AnyChannelHandler[] = [];
1362    const prompts: { name: string; render: SectionRender<JsonValue> }[] = [];
1363    const disposers: (() => void | Promise<void>)[] = [];
1364    let configuration: Configuration<JsonValue, JsonValue> | undefined;
1365    let promptProse: PromptProse | undefined;
1366    const conversation = extension.setup({
1367      provider: (provider) => void providers.push(provider),
1368      auth: (provider, signal) => models.getAuth(provider, { signal }),
1369      tool: (tool) => void tools.push(tool),
1370      handle: (handler) => void handlers.push(handler as AnyHandler),
1371      channel: (handler) => void channels.push(handler as AnyChannelHandler),
1372      prompt: (name, render) => void prompts.push({ name, render }),
1373      promptProse: (given) => void (promptProse = given),
1374      compactionCap: (getter) => void compactionCaps.push(getter),
1375      configuration: (given) =>
1376        void (configuration = given as unknown as Configuration<JsonValue, JsonValue>),
1377      onDispose: (disposer) => void disposers.push(disposer),
1378    });
1379    const replacedProviders = new Map<string, Provider>();
1380    const next: Loaded = {
1381      extension,
1382      providers,
1383      replacedProviders,
1384      compactionCaps,
1385      tools,
1386      handlers,
1387      channels,
1388      open: new Set(),
1389      prompts,
1390      disposers,
1391      calls: idle(),
1392      ...(conversation ? { conversation } : {}),
1393      ...(configuration ? { configuration } : {}),
1394      ...(promptProse ? { promptProse } : {}),
1395    };
1396    // the model resolves same-named tools to the later extension, which would shadow the harness's own
1397    if (tools.some((tool) => tool.name === "codemode" && exposure(tool) !== "codemode")) {
1398      await dispose(next);
1399      throw new BerryError(`${extension.name}: the model's codemode tool is the harness's own`);
1400    }
1401    if (tools.some((tool) => tool.name === SUBAGENT)) {
1402      await dispose(next);
1403      throw new BerryError(`${extension.name}: the subagent tool is the harness's own`);
1404    }
1405    const previous = loaded.get(extension.name);
1406    const claimed = new Set<string>();
1407    for (const provider of providers) {
1408      if (
1409        claimed.has(provider.id) ||
1410        [...loaded.values()].some((of) =>
1411          of !== previous && of.providers.some((owned) => owned.id === provider.id)
1412        )
1413      ) {
1414        await dispose(next);
1415        throw new BerryError(`${extension.name}: provider ${provider.id} is already registered`);
1416      }
1417      claimed.add(provider.id);
1418    }
1419    if (previous) releaseProviders(previous);
1420    for (const provider of providers) {
1421      const original = models.getProvider(provider.id);
1422      if (original) replacedProviders.set(provider.id, original);
1423      models.setProvider(provider);
1424    }
1425    loaded.set(extension.name, next);
1426    registry.install(durable ?? compile(next));
1427    await rename(extension);
1428    if (providers.length) {
1429      const refreshed = await models.refresh({
1430        providers: providers.map((provider) => provider.id),
1431      });
1432      for (const [id, error] of refreshed.errors) {
1433        console.error(`[berry] refreshing ${id}:`, error);
1434      }
1435    }
1436    for (const refresh of stateFeeds) refresh();
1437    // calls the old load started finish on it, and may still need what it disposes
1438    if (previous) void drained(previous).then(() => dispose(previous));
1439  }
1440
1441  /** Where a conversation works now. */
1442  async function bindingOf(id: ConversationId, tx?: Tx): Promise<Binding> {
1443    return await stateOf(id, loaded.get(ENVIRONMENT)!, tx) as Binding;
1444  }
1445
1446  async function requireEnvironmentAllowed(name: string) {
1447    if (
1448      name === LOCAL &&
1449      (await harness.snapshot(Settings, context))?.allowLocalEnvironment === false
1450    ) {
1451      throw new BerryError("The local environment is disabled in settings");
1452    }
1453  }
1454
1455  async function environmentOf(id: ConversationId, during: Context = context) {
1456    const { environment, cwd } = await bindingOf(id);
1457    return await environments.open(environment, cwd, during);
1458  }
1459
1460  /** Move a conversation to another machine, which only an idle one may do. */
1461  async function rebind(id: ConversationId, events: readonly BindingEvent[]) {
1462    // mid-run, calls the model made for one machine would reach the other
1463    await harness.commit(async (tx) => {
1464      if ((await tx.doc(LiveDoc, id)).run || (await tx.doc(ShellDoc, id)).run) {
1465        throw new BerryError("busy: let it finish, or abort it, before changing its environment");
1466      }
1467      for (const event of events) {
1468        await tx.appendEntry(id, { kind: EventEntry.kind, data: { ext: ENVIRONMENT, event } });
1469      }
1470    }, context);
1471  }
1472
1473  await install(environmentExtension(environments));
1474  const subagent = subagentExtension({
1475    harness,
1476    tidOf,
1477    enabled: async () => (await harness.snapshot(Settings, context))?.models ?? [],
1478    workers: async (id) => await stateOf(id, loaded.get(SUBAGENT)!) as Subagent,
1479    async title(tx, id, task) {
1480      const info = Object.values(await tx.doc(Conversations))
1481        .find((info) => branchesOf(info).includes(id));
1482      if (info) info.title = task.replace(/\s+/g, " ").trim();
1483    },
1484    supported,
1485    async prose() {
1486      return await resolveProse(SUBAGENT, loaded.get(SUBAGENT)?.promptProse);
1487    },
1488    async resolve(tid) {
1489      const info = (await harness.snapshot(Conversations, context))?.[tid];
1490      return info &&
1491        { id: headOf(info), ...(info.owner === undefined ? {} : { owner: info.owner }) };
1492    },
1493    async prepare(api, model, thinkingLevel, during) {
1494      const agent = await api.agent(during);
1495      const extensions = agent.extensions.filter((ext) => ext.name !== SUBAGENT);
1496      const events: { ext: string; event: JsonValue }[] = [];
1497      for (const { name } of extensions) {
1498        const of = loaded.get(name);
1499        if (!of?.configuration || name === ENVIRONMENT) continue;
1500        const state = await stateOf(api.conversationId, of);
1501        const values = of.configuration.values(state);
1502        const seeds = await configurationEvents(of.configuration, values, of.extension.initial());
1503        for (const event of seeds) events.push({ ext: name, event });
1504      }
1505      const env = api.env as Environment;
1506      events.push({
1507        ext: ENVIRONMENT,
1508        event: { type: "bind", environment: env.name, cwd: env.cwd },
1509      });
1510      return {
1511        model,
1512        thinkingLevel: supported(model, thinkingLevel ?? agent.thinkingLevel),
1513        extensions: extensions
1514          .filter((ext) => !loaded.get(ext.name)?.extension.alwaysOn)
1515          .map((ext) => ext.name),
1516        events,
1517      };
1518    },
1519  });
1520  await install(subagent.workers, subagent.extension);
1521
1522  async function stateAt(tid: string, name: string, at?: EntryId): Promise<JsonValue | undefined> {
1523    const id = at === undefined ? await idOf(tid) : (await locate(tid, at))?.id;
1524    const of = loaded.get(name);
1525    if (id === undefined || of === undefined) return undefined;
1526    if (at === undefined) return await stateOf(id, of);
1527    let state = of.extension.initial();
1528    let cursor;
1529    do {
1530      const page = await (await conversation(id)).entries(
1531        { order: "ascending", maxEntryId: at },
1532        256,
1533        cursor,
1534        context,
1535      );
1536      for (const entry of page.items) state = fold(of.extension, state, entry);
1537      cursor = page.next;
1538    } while (cursor !== undefined);
1539    return state;
1540  }
1541
1542  return {
1543    harness,
1544
1545    async changeQueuedInput(tid: string, id: SubmissionId, text: string | null) {
1546      return await harness.commit(async (tx) => {
1547        const info = (await tx.doc(Conversations))[tid];
1548        if (info === undefined) return "not_found" as const;
1549        const conversationId = headOf(info);
1550        // public Tx omits submission-by-id; this read still holds the harness's commit line.
1551        const record = await storage.submission(id, context);
1552        if (record?.conversationId !== conversationId || record.type !== "input") {
1553          return "not_found" as const;
1554        }
1555        if (record.status !== "queued") return "not_queued" as const;
1556        const inbox = await tx.doc(InboxDoc, conversationId);
1557        const index = inbox.items.findIndex((item) => item.id === id);
1558        const item = inbox.items[index];
1559        if (item === undefined) return "not_queued" as const;
1560        if (item.mode === "write") return "not_found" as const;
1561        if (text === null) {
1562          tx.settleSubmission(id, { status: "unanswered", reason: "aborted" });
1563          inbox.items.splice(index, 1);
1564        } else {
1565          const images = typeof item.content === "string"
1566            ? []
1567            : item.content.filter((block) => block.type === "image");
1568          if (!text.trim() && images.length === 0) return "empty" as const;
1569          const content = userContent(text, images);
1570          if (new TextEncoder().encode(JSON.stringify({ content })).length > MAX_MESSAGE_BYTES) {
1571            return "too_large" as const;
1572          }
1573          item.content = content;
1574        }
1575        return "ok" as const;
1576      }, context);
1577    },
1578
1579    /** Load an extension, replacing the installed one with its name. A failing setup changes nothing. */
1580    async install(extension: AnyExtension): Promise<void> {
1581      if (
1582        extension.name === Builtin.name || extension.name === ENVIRONMENT ||
1583        extension.name === SUBAGENT
1584      ) {
1585        throw new BerryError(`${extension.name} is the harness's own`);
1586      }
1587      await install(extension);
1588    },
1589
1590    /** Remove an extension: conversations that select it go without it, until one by its name is installed again. */
1591    uninstall(name: string) {
1592      const previous = loaded.get(name);
1593      if (previous === undefined || previous === own || name === ENVIRONMENT || name === SUBAGENT) {
1594        return;
1595      }
1596      loaded.delete(name);
1597      releaseProviders(previous);
1598      const installed = registry.snapshot().extension(name);
1599      if (installed) registry.uninstall(installed);
1600      for (const refresh of stateFeeds) refresh();
1601      void drained(previous).then(() => dispose(previous));
1602    },
1603
1604    extensions: () =>
1605      [...loaded.values()].flatMap((of) =>
1606        of === own || of.extension.name === ENVIRONMENT || of.extension.name === SUBAGENT
1607          ? []
1608          : [of.extension]
1609      ),
1610
1611    /** The harness's own extensions that the ui shows as extensions: those with configuration. */
1612    builtins: (): readonly Pick<
1613      AnyExtension,
1614      "name" | "description" | "alwaysOn" | "defaultEnabled" | "sortOrder" | "formerly"
1615    >[] => [
1616      loaded.get(ENVIRONMENT)!.extension,
1617      loaded.get(SUBAGENT)!.extension,
1618    ],
1619
1620    /** What a conversation starting with the extension is asked, as a json schema; `null` when nothing. */
1621    configurationParameters: (name: string) => loaded.get(name)?.configuration?.parameters ?? null,
1622
1623    /** The tightest compaction cap any installed extension reports, if any. */
1624    compactionCap: () => effectiveCap(),
1625
1626    /** Model catalog as generation sees it: effective windows under the tightest cap. */
1627    effectiveModels,
1628
1629    async saveProject(id: string | undefined, input: ProjectInput): Promise<string> {
1630      const projects = await harness.snapshot(Projects, context) ?? {};
1631      if (id !== undefined && !Object.hasOwn(projects, id)) {
1632        throw new BerryError(`No project ${id}`);
1633      }
1634      const key = id ?? crypto.randomUUID();
1635      try {
1636        const project = await resolveProject(
1637          input,
1638          id ? projects[id] : undefined,
1639          environments,
1640          context,
1641        );
1642        await harness.commit(async (tx) => {
1643          const index = await tx.doc(Projects);
1644          if (id !== undefined && !Object.hasOwn(index, id)) {
1645            throw new BerryError(`No project ${id}`);
1646          }
1647          validateRoots(index, key, project);
1648          const previous = index[key];
1649          if (input.moveConversations && previous) {
1650            const removed = previous.roots.filter((root) =>
1651              !project.roots.some((saved) =>
1652                saved.environment === root.environment &&
1653                workspacePath(saved.cwd, saved.platform) === workspacePath(root.cwd, root.platform)
1654              )
1655            );
1656            if (removed.length) {
1657              const destination = project.roots[project.defaultRoot];
1658              const moving: ConversationId[] = [];
1659              for (const info of Object.values(await tx.doc(Conversations))) {
1660                const conversationId = headOf(info);
1661                const binding = await bindingOf(conversationId, tx);
1662                if (binding.cwd === null && binding.environment !== LOCAL) continue;
1663                const location = {
1664                  environment: binding.environment,
1665                  cwd: binding.cwd ?? homedir(),
1666                };
1667                if (projectFor(index, location) !== key) continue;
1668                const workspace = workspaceFor(previous, location);
1669                if (!workspace || !removed.includes(workspace)) continue;
1670                if (
1671                  (await tx.doc(LiveDoc, conversationId)).run ||
1672                  (await tx.doc(ShellDoc, conversationId)).run
1673                ) {
1674                  throw new BerryError(
1675                    "busy: let affected conversations finish, or abort them, before moving their workspaces",
1676                  );
1677                }
1678                moving.push(conversationId);
1679              }
1680              const event: BindingEvent = {
1681                type: "bind",
1682                environment: destination.environment,
1683                cwd: destination.cwd,
1684              };
1685              for (const conversationId of moving) {
1686                await tx.appendEntry(conversationId, {
1687                  kind: EventEntry.kind,
1688                  data: { ext: ENVIRONMENT, event },
1689                });
1690              }
1691            }
1692          }
1693          index[key] = project;
1694        }, context);
1695        return key;
1696      } catch (error) {
1697        throw surfaced(error);
1698      }
1699    },
1700
1701    async deleteProject(id: string) {
1702      await harness.commit(async (tx) => {
1703        const projects = await tx.doc(Projects);
1704        if (!Object.hasOwn(projects, id)) throw new BerryError(`No project ${id}`);
1705        delete projects[id];
1706      }, context);
1707    },
1708
1709    async folders(
1710      environment: string,
1711      cwd: string | null,
1712      prefix: string,
1713      during: Context = context,
1714    ) {
1715      try {
1716        const env = await environments.open(environment, cwd, during);
1717        return await folders(env, prefix, during);
1718      } catch (error) {
1719        throw surfaced(error);
1720      }
1721    },
1722
1723    /** Paths a reference could extend to from `cwd`, before any conversation works there. */
1724    async references(
1725      environment: string,
1726      cwd: string | null,
1727      prefix: string,
1728      during: Context = context,
1729    ) {
1730      try {
1731        const env = await environments.open(environment, cwd, during);
1732        return await references(env, prefix, during);
1733      } catch (error) {
1734        throw surfaced(error);
1735      }
1736    },
1737
1738    async create(options: CreateOptions): Promise<string> {
1739      if (options.model) await requireEnabled(options.model);
1740      const selected = selection(Object.keys(options.extensions));
1741      const seeds = await Promise.all(
1742        Object.entries(options.extensions).map(async ([given, values]) => {
1743          const name = current(given);
1744          const of = loaded.get(name);
1745          try {
1746            const events = of?.configuration
1747              ? await configurationEvents(of.configuration, values, of.extension.initial())
1748              : [];
1749            return [name, events] as const;
1750          } catch (error) {
1751            throw surfaced(error, `${name}: `);
1752          }
1753        }),
1754      );
1755      const thinkingLevel = options.model
1756        ? supported(options.model, options.thinkingLevel ?? "high")
1757        : options.thinkingLevel;
1758      const created = await harness.createConversation({
1759        ownership: { kind: "ownerless" },
1760        agent: {
1761          extensions: selected,
1762          ...(options.model ? { model: options.model } : {}),
1763          ...(thinkingLevel ? { thinkingLevel } : {}),
1764        },
1765        init: async (tx, id) => {
1766          for (const [ext, events] of seeds) {
1767            for (const event of events) {
1768              await tx.appendEntry(id, { kind: EventEntry.kind, data: { ext, event } });
1769            }
1770          }
1771        },
1772      }, context);
1773      return await tidOf(created.id);
1774    },
1775
1776    conversation: conversationOf,
1777
1778    state: stateAt,
1779
1780    /** The environment `tid` is bound to, now or as of its entry `at`. */
1781    async environment(tid: string, at?: EntryId): Promise<Environment> {
1782      const binding = await stateAt(tid, ENVIRONMENT, at) as Binding | undefined;
1783      if (binding === undefined) throw new BerryError(`No entry ${at} in ${tid}`);
1784      return await environments.open(binding.environment, binding.cwd, context);
1785    },
1786
1787    async configure(tid: string, change: AgentChange) {
1788      const target = await conversationOf(tid);
1789      if (target === undefined) throw new BerryError(`No conversation ${tid}`);
1790      if (change.model) await requireEnabled(change.model);
1791      const extensions = change.extensions && selection(change.extensions);
1792      // a model change carries the level over as far as the new model allows
1793      const agent = await target.agent(context);
1794      const model = change.model === undefined ? agent.model : change.model ?? undefined;
1795      const requested = change.thinkingLevel === undefined
1796        ? agent.thinkingLevel
1797        : change.thinkingLevel ?? "off";
1798      const thinkingLevel = model && (change.model !== undefined || change.thinkingLevel)
1799        ? supported(model, requested)
1800        : change.thinkingLevel;
1801      await target.configure({
1802        ...(change.model === undefined ? {} : { model: change.model }),
1803        ...(thinkingLevel === undefined ? {} : { thinkingLevel }),
1804        ...(extensions === undefined ? {} : { extensions }),
1805      }, context);
1806    },
1807
1808    /** An extension's state as a feed, following the conversation from branch to branch. */
1809    watchState: (tid: string, name: string) => follow(tid, (id) => stateFeed(id, name)),
1810
1811    watchConfiguration: (tid: string, name: string) =>
1812      follow(tid, (id) => stateFeed(id, name, true)),
1813
1814    /** The durable view of a conversation as a feed, following it from branch to branch. */
1815    watch: (tid: string) =>
1816      follow(tid, async (id) => await (await harness.conversation(id, context))?.watch(context)),
1817
1818    locate,
1819
1820    /** Acknowledge only the message the frontend saw, never a newer completed turn. */
1821    async markRead(tid: string, entryId: number) {
1822      const found = await locate(tid, entryId);
1823      if (found === undefined) throw new BerryError(`No entry ${entryId} in ${tid}`);
1824      await harness.commit(async (tx) => {
1825        const entry = await tx.entry(entryId as EntryId);
1826        if (entry?.kind !== "pi.user" && entry?.kind !== "pi.assistant") {
1827          throw new BerryError(`Entry ${entryId} is not a message`);
1828        }
1829        const info = (await tx.doc(Conversations))[tid];
1830        if (info === undefined) throw new BerryError(`No conversation ${tid}`);
1831        info.readThrough = Math.max(info.readThrough ?? 0, entryId);
1832      }, context);
1833    },
1834
1835    /** Set a sidebar name, or restore the message preview with an empty name. */
1836    async rename(tid: string, title: string | null) {
1837      await harness.commit(async (tx) => {
1838        const info = (await tx.doc(Conversations))[tid];
1839        if (info === undefined) throw new BerryError(`No conversation ${tid}`);
1840        const name = title?.trim();
1841        if (name) info.title = name;
1842        else delete info.title;
1843      }, context);
1844    },
1845
1846    /** Put a conversation out of the way, or bring it back. */
1847    async archive(tid: string, archived: boolean) {
1848      await harness.commit(async (tx) => {
1849        const info = (await tx.doc(Conversations))[tid];
1850        if (info === undefined) throw new BerryError(`No conversation ${tid}`);
1851        if (!archived) delete info.archived;
1852        else info.archived ??= Date.now();
1853      }, context);
1854    },
1855
1856    branch,
1857
1858    /**
1859     * Have the agent write its message `at` afresh: continue `tid` from just before it, on a run without new input,
1860     * which pi-durable starts as its own `startRun` does.
1861     */
1862    async regenerate(tid: string, at: number) {
1863      const source = await locate(tid, at);
1864      if (source === undefined) throw new BerryError(`No entry ${at} in ${tid}`);
1865      const one = async (query: { minEntryId?: EntryId; maxEntryId: EntryId }) =>
1866        (await source.entries({ order: "descending", ...query }, 1, undefined, context)).items[0];
1867      const entry = await one({ minEntryId: at as EntryId, maxEntryId: at as EntryId });
1868      if (entry?.kind !== "pi.assistant") {
1869        throw new BerryError(`Entry ${at} is not the agent's message`);
1870      }
1871      const before = await one({ maxEntryId: (at - 1) as EntryId });
1872      if (before === undefined) throw new BerryError("Nothing comes before this message");
1873      await branch(tid, before.id);
1874      const id = await existing(tid);
1875      await harness.commit(async (tx) => {
1876        const live = await tx.doc(LiveDoc, id);
1877        if (live.run || (await tx.doc(ShellDoc, id)).run) {
1878          throw new BerryError(`${tid} is busy: let it finish, or abort it, first`);
1879        }
1880        const taskId = await tx.createTask(GenerationTask, {}, {
1881          ownership: { kind: "conversation" },
1882          conversationId: id,
1883        });
1884        live.run = { taskId, inputs: [] };
1885      }, context);
1886    },
1887
1888    /** A host-originated event: a write submission, placed at the next boundary while busy. */
1889    async emit(tid: string, ext: string, event: JsonValue) {
1890      const id = await existing(tid);
1891      return await writeEvent(id, ext, event);
1892    },
1893
1894    async configureExtension(tid: string, name: string, change: Record<string, JsonValue>) {
1895      const id = await existing(tid);
1896      const of = (await selected(id)).find((of) => of.extension.name === name);
1897      if (!of?.configuration) throw new BerryError(`${name} has no enabled configuration`);
1898      const configuration = of.configuration;
1899      try {
1900        for (const key of Object.keys(change)) {
1901          if (!Object.hasOwn(configuration.parameters.properties, key)) {
1902            throw new Error(`unknown configuration field: ${key}`);
1903          }
1904        }
1905        const state = await stateOf(id, of);
1906        const events = await configurationEvents(configuration, {
1907          ...configuration.values(state),
1908          ...change,
1909        }, state);
1910        const moving = name === ENVIRONMENT &&
1911          (events as readonly BindingEvent[]).some((event) =>
1912            event.environment !== (state as Binding).environment
1913          );
1914        if (moving) await rebind(id, events as readonly BindingEvent[]);
1915        else {
1916          const submissions = await Promise.all(
1917            events.map((event) => writeEvent(id, name, event)),
1918          );
1919          await Promise.all(submissions.map((submission) => submission.wait(context)));
1920        }
1921        return configuration.values(await stateOf(id, of));
1922      } catch (error) {
1923        throw surfaced(error);
1924      }
1925    },
1926
1927    async commands(tid: string) {
1928      const id = await existing(tid);
1929      return [...(await commandsOf(id)).values()].map(({ loaded, command }) => ({
1930        name: command.name,
1931        extension: loaded.extension.name,
1932        description: command.description,
1933      }));
1934    },
1935
1936    /** Hand a channel the frontend opened to the extension it names; refusals surface as `BerryError`. */
1937    async channel(
1938      tid: string,
1939      extension: string,
1940      name: string,
1941      args: JsonValue | undefined,
1942      channel: Channel,
1943      signal: AbortSignal,
1944    ): Promise<void> {
1945      const id = await existing(tid);
1946      const of = (await selected(id)).find((of) => of.extension.name === extension);
1947      if (of === undefined) throw new BerryError(`${extension} is not in this conversation`);
1948      const handler = of.channels.findLast((handler) => handler.name === name);
1949      if (handler === undefined) throw new BerryError(`${extension} has no channel named ${name}`);
1950      try {
1951        const validated = handlerArguments(handler.parameters, args);
1952        const state = await stateOf(id, of);
1953        const opened = await environmentOf(id).then(
1954          (env) => ({ env }),
1955          (error: unknown) => ({ error }),
1956        );
1957        of.open.add(channel);
1958        signal.addEventListener("abort", () => of.open.delete(channel), { once: true });
1959        await handler.open(validated, channel, {
1960          tid,
1961          state,
1962          get env() {
1963            if ("error" in opened) throw opened.error;
1964            return opened.env;
1965          },
1966          signal,
1967        });
1968      } catch (error) {
1969        throw surfaced(error);
1970      }
1971    },
1972
1973    /** Answer an extension's frontend with one of its handlers; its failures surface as `BerryError`. */
1974    async request(
1975      tid: string,
1976      extension: string,
1977      name: string,
1978      args: JsonValue | undefined,
1979      signal: AbortSignal = NEVER,
1980    ): Promise<JsonValue> {
1981      const id = await existing(tid);
1982      const of = (await selected(id)).find((of) => of.extension.name === extension);
1983      if (of === undefined) throw new BerryError(`${extension} is not in this conversation`);
1984      const handler = of.handlers.findLast((handler) => handler.name === name);
1985      if (handler === undefined) throw new BerryError(`${extension} has no handler named ${name}`);
1986      try {
1987        const validated = handlerArguments(handler.parameters, args);
1988        const state = await stateOf(id, of);
1989        // a handler that does not use the environment still answers while it cannot be reached
1990        const opened = await environmentOf(id).then(
1991          (env) => ({ env }),
1992          (error: unknown) => ({ error }),
1993        );
1994        return await running(of, async () =>
1995          await handler.execute(validated, {
1996            tid,
1997            state,
1998            get env() {
1999              if ("error" in opened) throw opened.error;
2000              return opened.env;
2001            },
2002            signal,
2003          }) ?? null);
2004      } catch (error) {
2005        throw surfaced(error);
2006      }
2007    },
2008
2009    /** Run a command; its failures are the user's to read, so they surface as `BerryError`. */
2010    async command(tid: string, name: string, args: string): Promise<JsonValue | undefined> {
2011      const id = await existing(tid);
2012      const found = (await commandsOf(id)).get(name);
2013      if (found === undefined) throw new BerryError(`No command /${name}`);
2014      const { loaded: of, command } = found;
2015      // a command that does not use the environment still runs while it cannot be reached
2016      const opened = await environmentOf(id).then(
2017        (env) => ({ env }),
2018        (error: unknown) => ({ error }),
2019      );
2020      const envOf = () => {
2021        if ("error" in opened) throw opened.error;
2022        return opened.env;
2023      };
2024      try {
2025        return await command.execute(args, {
2026          tid,
2027          state: await stateOf(id, of),
2028          get env() {
2029            return envOf();
2030          },
2031          emit: async (event, options) => {
2032            const submission = await writeEvent(id, of.extension.name, event, options?.content);
2033            await submission.wait(context);
2034          },
2035          async cd(path, options) {
2036            const env = envOf();
2037            const cwd = await directory(env, path, context);
2038            if (options?.inheritProject) {
2039              await harness.commit(async (tx) => {
2040                const projects = await tx.doc(Projects);
2041                const projectId = projectFor(projects, { environment: env.name, cwd: env.cwd });
2042                if (projectId === null) return;
2043                const project = projects[projectId];
2044                if (
2045                  project.roots.some((root) =>
2046                    root.environment === env.name &&
2047                    workspacePath(root.cwd, root.platform) === workspacePath(cwd, env.platform)
2048                  )
2049                ) return;
2050                const updated = {
2051                  ...project,
2052                  roots: [...project.roots, { environment: env.name, cwd, platform: env.platform }],
2053                };
2054                validateRoots(projects, projectId, updated);
2055                projects[projectId] = updated;
2056              }, context);
2057            }
2058            const binding = await bindingOf(id);
2059            if (cwd !== binding.cwd) {
2060              const event: BindingEvent = { type: "bind", environment: binding.environment, cwd };
2061              await writeEvent(id, ENVIRONMENT, event);
2062            }
2063            return cwd;
2064          },
2065          compact: async (instructions) => {
2066            await (await conversation(id)).compact(instructions, context);
2067          },
2068        });
2069      } catch (error) {
2070        throw surfaced(error);
2071      }
2072    },
2073
2074    tidOf,
2075
2076    async shell(tid: string, command: string, excludeFromContext = false) {
2077      if (!command.trim()) throw new BerryError("Expected a shell command after !");
2078      const id = await existing(tid);
2079      if (!(await selected(id)).some((of) => of.extension.name === "code")) {
2080        throw new BerryError("Shell commands require the code extension");
2081      }
2082      const taskId = await harness.commit(async (tx) => {
2083        const live = await tx.doc(LiveDoc, id);
2084        const state = await tx.doc(ShellDoc, id);
2085        if (live.run || state.run) {
2086          throw new BerryError("Busy: let it finish, or abort it, before running a shell command");
2087        }
2088        const taskId = await tx.createTask(shell, { command, excludeFromContext }, {
2089          ownership: { kind: "conversation" },
2090          conversationId: id,
2091        });
2092        state.run = {
2093          taskId,
2094          command,
2095          excludeFromContext,
2096          status: "running",
2097          result: null,
2098          details: null,
2099          error: null,
2100          startedAt: Date.now(),
2101          durationMs: null,
2102        };
2103        return taskId;
2104      }, context);
2105      harness.resume();
2106      return taskId;
2107    },
2108
2109    watchShell: (tid: string) =>
2110      follow(tid, async (id) => {
2111        await harness.commit(async (tx) => {
2112          await tx.doc(ShellDoc, id);
2113        }, context);
2114        return await harness.watchDoc(ShellDoc, id, context);
2115      }),
2116
2117    /** Replace the enabled models; a model listed twice counts once. */
2118    async enableModels(models: readonly ModelRef[]) {
2119      const unique = [...new Map(models.map((m) => [`${m.provider}/${m.modelId}`, m])).values()];
2120      await harness.commit(async (tx) => {
2121        (await tx.doc(Settings)).models = unique.map(({ provider, modelId }) => ({
2122          provider,
2123          modelId,
2124        }));
2125      }, context);
2126    },
2127
2128    async setAllowLocalEnvironment(allowed: boolean) {
2129      await harness.commit(async (tx) => {
2130        (await tx.doc(Settings)).allowLocalEnvironment = allowed;
2131      }, context);
2132    },
2133
2134    /** What an extension's prose is unless settings override it; `null` when it registers none. */
2135    promptProseDefault: (name: string) => loaded.get(name)?.promptProse?.default ?? null,
2136
2137    async setSystemPrompt(patch: Partial<SystemPromptSettings>) {
2138      await harness.commit(async (tx) => {
2139        const settings = await tx.doc(Settings);
2140        const current = { ...DEFAULT_SYSTEM_PROMPT, ...settings.systemPrompt };
2141        settings.systemPrompt = {
2142          ...current,
2143          ...patch,
2144          promptProse: { ...current.promptProse, ...patch.promptProse },
2145        };
2146      }, context);
2147    },
2148
2149    /** Replace the machines conversations may work on besides this one. */
2150    async setEnvironments(configured: Readonly<Record<string, EnvironmentSettings>>) {
2151      for (const [name, { ssh, deno }] of Object.entries(configured)) {
2152        if (name === LOCAL || !ENVIRONMENT_NAME.test(name)) {
2153          throw new BerryError(`${JSON.stringify(name)} cannot name an environment`);
2154        }
2155        // the destination is an argument of its own, so this is only ssh mistaking it for an option
2156        if (!ssh.trim() || ssh.startsWith("-")) {
2157          throw new BerryError(`${name}: ${JSON.stringify(ssh)} is not an ssh destination`);
2158        }
2159        if (deno !== undefined && !deno.trim()) throw new BerryError(`${name}: deno is empty`);
2160      }
2161      await harness.commit(async (tx) => {
2162        (await tx.doc(Settings)).environments = Object.fromEntries(
2163          Object.entries(configured).map(([name, { ssh, deno }]) => [
2164            name,
2165            { ssh: ssh.trim(), ...(deno === undefined ? {} : { deno: deno.trim() }) },
2166          ]),
2167        );
2168      }, context);
2169    },
2170
2171    async close() {
2172      await stopSettled();
2173      await harness.close(context);
2174      // a browser session, mcp servers, and each machine are independent, so none waits out another
2175      await Promise.allSettled(
2176        [...loaded.values()].map(async (of) => {
2177          releaseProviders(of);
2178          await dispose(of);
2179        }),
2180      );
2181      await environments.close();
2182    },
2183  };
2184}
2185
2186export type Berry = Awaited<ReturnType<typeof openBerry>>;