char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somshow timestamps in /tree and /forkc582293

main
12.4 KiB368 linesraw
1import { Signal } from "@char/aftercare";
2import { api } from "./client.ts";
3import type { CommandInfo } from "./conversation.tsx";
4import { Scope, swap, when } from "./ui.ts";
5
6export type TreeEntry = {
7  id: number;
8  parent: number | null;
9  branch: number;
10  kind: string;
11  text: string;
12  time?: number;
13  imageCount?: number;
14  tools?: string[];
15  error?: true;
16};
17export type Branch = {
18  id: number;
19  created: number;
20  tip: number | null;
21};
22export type ConversationTree = { head: number; branches: Branch[]; entries: TreeEntry[] };
23
24/**
25 * A conversation, or a draft that continues it from an entry, or with `fork` a new conversation from one; and what its
26 * composer starts with.
27 */
28export type Place = { tid: string; at?: number; fork?: boolean; text?: string };
29
30type Filter = "messages" | "user" | "all";
31const FILTERS: Record<Filter, { label: string; shows: (entry: TreeEntry) => boolean }> = {
32  messages: {
33    label: "messages",
34    shows: (entry) =>
35      entry.kind === "pi.user" || entry.kind === "pi.compaction" || entry.kind === "pi.reset" ||
36      (entry.kind === "pi.assistant" && (entry.text !== "" || entry.error === true)),
37  },
38  user: { label: "yours", shows: (entry) => entry.kind === "pi.user" },
39  all: { label: "everything", shows: () => true },
40};
41
42type Row = { entry: TreeEntry; lead: string; branches: Branch[]; onPath: boolean };
43
44/**
45 * The rows of a tree as tree(1) draws one, except that a run without branch points keeps its indent, as pi's does.
46 * An entry `shows` leaves out hands its children to its nearest shown ancestor; a branch's end always shows.
47 */
48function layout(
49  tree: ConversationTree,
50  shows: (entry: TreeEntry) => boolean,
51  path: Set<number>,
52): Row[] {
53  const byId = new Map(tree.entries.map((entry) => [entry.id, entry]));
54  const ends = Map.groupBy(tree.branches, (branch) => branch.tip);
55  const shown = (id: number) => {
56    const entry = byId.get(id);
57    return entry !== undefined && (shows(entry) || ends.has(id));
58  };
59  const children = new Map<number | null, TreeEntry[]>();
60  for (const entry of tree.entries) {
61    if (!shown(entry.id)) continue;
62    let parent = entry.parent;
63    while (parent !== null && !shown(parent)) parent = byId.get(parent)?.parent ?? null;
64    children.set(parent, [...children.get(parent) ?? [], entry]);
65  }
66
67  const rows: Row[] = [];
68  const stack: { entry: TreeEntry; lead: string; indent: string }[] = [];
69  const descend = (list: readonly TreeEntry[], indent: string) => {
70    // the way to where we are comes first
71    const kids = [...list.filter((e) => path.has(e.id)), ...list.filter((e) => !path.has(e.id))];
72    if (kids.length === 1) return void stack.push({ entry: kids[0], lead: indent, indent });
73    for (let i = kids.length - 1; i >= 0; i--) {
74      const last = i === kids.length - 1;
75      stack.push({
76        entry: kids[i],
77        lead: indent + (last ? "└─ " : "├─ "),
78        indent: indent + (last ? "   " : "│  "),
79      });
80    }
81  };
82  descend(children.get(null) ?? [], "");
83  while (stack.length > 0) {
84    const { entry, lead, indent } = stack.pop()!;
85    rows.push({ entry, lead, branches: ends.get(entry.id) ?? [], onPath: path.has(entry.id) });
86    descend(children.get(entry.id) ?? [], indent);
87  }
88  return rows;
89}
90
91/** A row's label, by kind, and its text. */
92export function describe(entry: TreeEntry): [label: string, text: string] {
93  switch (entry.kind) {
94    case "pi.user":
95      return [
96        "user",
97        [
98          entry.text,
99          entry.imageCount ? `[${entry.imageCount} image${entry.imageCount === 1 ? "" : "s"}]` : "",
100        ].filter(Boolean).join(" "),
101      ];
102    case "pi.assistant":
103      return ["assistant", entry.text || `→ ${entry.tools?.join(", ") ?? ""}`];
104    case "pi.tool-result":
105      return [entry.tools?.[0] ?? "result", entry.text];
106    case "pi.system":
107      return ["prompt", ""];
108    case "pi.reset":
109      return ["context reset", entry.text];
110    case "pi.compaction":
111      return ["compacted", entry.text];
112    case "berry.event":
113      return ["event", entry.text];
114    case "berry.start":
115      return ["start", ""];
116    case "berry.subagent-report":
117      return ["report", entry.text];
118    default:
119      return [entry.kind, entry.text];
120  }
121}
122
123/** A move along a conversation's own tree, which its view shows once its transcript has caught up. */
124export type Move = {
125  how: "continue" | "edit" | "regenerate";
126  /** The entry chosen: one to continue after, a message of the user's to edit, or the agent's to write afresh. */
127  entry: TreeEntry;
128  /** Where the conversation continues from, and where it was. */
129  at: number;
130  previous: number | null;
131  /** Whether a transcript ending at `latest` shows it, which a regenerating run may already have gone past. */
132  landed: (latest: number | null) => boolean;
133};
134
135/** A picker for continuing a conversation or starting a separate one from any entry. */
136function open(
137  tree: ConversationTree,
138  place: Place,
139  mode: "tree" | "fork",
140  go: (place: Place) => void,
141  moved?: (move: Move) => Promise<void>,
142) {
143  const scope = new Scope();
144  const dialog = <dialog class="dialog tree-dialog" /> as HTMLDialogElement;
145  dialog.addEventListener("close", () => {
146    scope.dispose();
147    dialog.remove();
148  });
149  // a tap outside closes it too, for screens without escape
150  dialog.addEventListener("click", (event) => {
151    const box = dialog.getBoundingClientRect();
152    const outside = event.clientX < box.left || event.clientX > box.right ||
153      event.clientY < box.top || event.clientY > box.bottom;
154    if (event.target === dialog && outside) dialog.close();
155  });
156
157  const byId = new Map(tree.entries.map((entry) => [entry.id, entry]));
158  const here = place.at ?? tree.branches.find((b) => b.id === tree.head)?.tip ?? null;
159  const path = new Set<number>();
160  for (let id = here; id !== null; id = byId.get(id)?.parent ?? null) path.add(id);
161  const newest = tree.entries.at(-1)?.id ?? 0;
162
163  const failure = <p class="tree-error" role="alert" hidden /> as HTMLParagraphElement;
164  const fail = (message: string) => {
165    failure.textContent = message;
166    failure.hidden = false;
167  };
168  let choosing = false;
169  // the picker stays open until the transcript shows the choice, so it never closes onto the old one
170  const choose = async (entry: TreeEntry, pending: Element, regenerate = false) => {
171    if (choosing) return;
172    failure.hidden = true;
173    const how = regenerate ? "regenerate" : entry.kind === "pi.user" ? "edit" : "continue";
174    const at = how === "continue" ? entry.id : entry.parent;
175    if (at === null) return fail("nothing comes before this message to branch from");
176    const text = how === "edit" ? { text: entry.text } : {};
177    choosing = true;
178    pending.setAttribute("aria-busy", "true");
179    try {
180      if (mode === "fork") {
181        const { tid } = await api<{ tid: string }>(
182          "POST",
183          `/api/conversations/${place.tid}/fork`,
184          { entryId: at },
185        );
186        dialog.close();
187        return go({ tid, ...text });
188      }
189      if (how === "regenerate") {
190        await api("POST", `/api/conversations/${place.tid}/regenerate`, { entryId: entry.id });
191      } else {
192        await api("POST", `/api/conversations/${place.tid}/branch`, { entryId: at });
193      }
194      await moved?.({
195        how,
196        entry,
197        at,
198        previous: here,
199        landed: (latest) => latest === at || (latest !== null && latest > newest),
200      });
201      dialog.close();
202      go({ tid: place.tid, ...text });
203    } catch (error) {
204      fail(error instanceof Error ? error.message : String(error));
205    } finally {
206      choosing = false;
207      pending.removeAttribute("aria-busy");
208    }
209  };
210  const row = ({ entry, lead, branches, onPath }: Row) => {
211    const [label, text] = describe(entry);
212    const element = (
213      <div class="tree-entry">
214        <button
215          type="button"
216          class={entry.id === here ? "tree-row here" : "tree-row"}
217          dataset={{ id: String(entry.id), kind: entry.kind, error: String(entry.error ?? false) }}
218          _onclick={() => choose(entry, element)}
219        >
220          <span class="lead">{lead}{onPath ? "• " : ""}</span>
221          <span class="label">{label}</span>
222          <span class="text">{text}</span>
223          {branches.map((branch) => (
224            <time class={branch.id === tree.head ? "branch current" : "branch"}>
225              {when(new Date(branch.created))}
226            </time>
227          ))}
228        </button>
229        {mode === "tree" && entry.kind === "pi.assistant"
230          ? (
231            <button
232              type="button"
233              class="tree-regenerate"
234              dataset={{ id: String(entry.id) }}
235              disabled={entry.parent === null}
236              aria-label={`regenerate: ${text}`}
237              _onclick={() => choose(entry, element, true)}
238            >
239              regenerate
240            </button>
241          )
242          : ""}
243        {entry.time === undefined
244          ? ""
245          : (
246            <time class="time" title={new Date(entry.time).toLocaleString()}>
247              {when(new Date(entry.time), { seconds: true })}
248            </time>
249          )}
250      </div>
251    );
252    return element;
253  };
254
255  // focus follows the reader: a filter that hides their row moves them to its nearest shown ancestor
256  let selected = here;
257  const list = <div class="tree-rows" /> as HTMLDivElement;
258  list.addEventListener("focusin", (event) => {
259    const id = (event.target as HTMLElement).dataset.id;
260    if (id !== undefined) selected = Number(id);
261  });
262  list.addEventListener("keydown", (event) => {
263    const rows = [...list.querySelectorAll<HTMLElement>(".tree-row")];
264    const step = ({
265      ArrowUp: -1,
266      ArrowDown: 1,
267      PageUp: -15,
268      PageDown: 15,
269      Home: -Infinity,
270      End: Infinity,
271    } as Record<string, number>)[event.key];
272    if (step === undefined) return;
273    event.preventDefault();
274    const at = rows.findIndex((row) => Number(row.dataset.id) === selected);
275    rows[Math.max(0, Math.min(rows.length - 1, at + step))]?.focus();
276  });
277  const refocus = () => {
278    let id = selected;
279    while (id !== null && !list.querySelector(`.tree-row[data-id="${id}"]`)) {
280      id = byId.get(id)?.parent ?? null;
281    }
282    const target = id === null
283      ? list.querySelector<HTMLElement>(".tree-row")
284      : list.querySelector<HTMLElement>(`.tree-row[data-id="${id}"]`);
285    target?.focus({ preventScroll: true });
286    target?.scrollIntoView({ block: "center" });
287  };
288
289  const filter = new Signal<Filter>("messages");
290  swap(scope, list, filter, (chosen) => layout(tree, FILTERS[chosen].shows, path).map(row));
291  scope.add(filter.subscribe(() => queueMicrotask(refocus)).unsubscribe);
292
293  dialog.append(
294    <h2>{mode}</h2>,
295    <p class="desc">
296      {mode === "tree" ? "continue from any point" : "fork this conversation from any point"}
297    </p>,
298    <div class="tree-filters">
299      {(Object.keys(FILTERS) as Filter[]).map((key) => (
300        <label>
301          <input
302            type="radio"
303            name="tree-filter"
304            checked={key === filter.get()}
305            _onchange={() => filter.set(key)}
306          />
307          {FILTERS[key].label}
308        </label>
309      ))}
310    </div>,
311    list,
312    failure,
313    <div class="hints">
314      <span>
315        <kbd>↑</kbd>
316        <kbd>↓</kbd> move
317      </span>
318      <span>
319        <kbd>enter</kbd> choose
320      </span>
321      <span>
322        <kbd>esc</kbd> close
323      </span>
324    </div>,
325  );
326  document.body.append(dialog);
327  dialog.showModal();
328  refocus();
329}
330
331/** pi's `/tree`, `/fork` and `/clone`, for the view at `place`. */
332export function branchCommands(
333  place: Place,
334  go: (place: Place) => void,
335  moved?: (move: Move) => Promise<void>,
336): CommandInfo[] {
337  const show = (mode: "tree" | "fork") => async () => {
338    const tree = await api<ConversationTree>("GET", `/api/conversations/${place.tid}/tree`);
339    open(tree, place, mode, go, moved);
340  };
341  return [
342    {
343      name: "tree",
344      extension: "berry",
345      description: "continue from any point of conversation history",
346      run: show("tree"),
347    },
348    {
349      name: "fork",
350      extension: "berry",
351      description: "fork this conversation from any point of history",
352      run: show("fork"),
353    },
354    {
355      name: "clone",
356      extension: "berry",
357      description: "copy this conversation into a new one",
358      run: async () => {
359        const { tid } = await api<{ tid: string }>(
360          "POST",
361          `/api/conversations/${place.tid}/fork`,
362          place.at === undefined ? {} : { entryId: place.at },
363        );
364        go({ tid });
365      },
366    },
367  ];
368}