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
5.2 KiB140 linesraw
1import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context";
2import type { ConversationId, Cursor, EntryId, EntryRecord } from "@earendil-works/pi-durable";
3import { type Berry, BerryError, Conversations, EventEntry, headOf } from "./berry.ts";
4
5/** One entry of a conversation's branches, as a tree of them shows it. */
6export type TreeEntry = {
7  id: number;
8  /**
9   * The entry it follows: the one before it in its branch, or for a branch's first entry, the entry the branch
10   * continues from.
11   */
12  parent: number | null;
13  /** The branch it was written in, or for what a fork inherited, the branch it began as. */
14  branch: number;
15  kind: string;
16  /**
17   * A user's message in full, to be edited again; another message's text, clipped to a line's worth; an event's
18   * extension.
19   */
20  text: string;
21  /** When its message was written; entries without one, like events, have none. */
22  time?: number;
23  imageCount?: number;
24  /** The tools an assistant message called, or the tool a result answers. */
25  tools?: string[];
26  error?: true;
27};
28
29export type Branch = {
30  id: number;
31  created: number;
32  /** Its newest entry, which may be its parent's when it has none of its own. */
33  tip: number | null;
34};
35
36/** A conversation's branches, and which one it continues on. */
37export type ConversationTree = { head: number; branches: Branch[]; entries: TreeEntry[] };
38
39const PREVIEW = 240;
40
41/** `text` on one line, cut to `length` characters. */
42export function clip(length: number, text: string): string {
43  const flat = text.replace(/\s+/g, " ").trim();
44  return flat.length > length ? `${flat.slice(0, length - 1)}…` : flat;
45}
46
47export const textOf = (content: string | readonly { type: string; text?: string }[]) =>
48  typeof content === "string"
49    ? content
50    : content.flatMap((block) =>
51      block.type === "text" ? [block.text ?? ""] : block.type === "image" ? ["[image]"] : []
52    ).join(" ");
53
54/** A user's message as they wrote it, without its images. */
55export const userText = (content: string | readonly { type: string; text?: string }[]) =>
56  typeof content === "string"
57    ? content
58    : content.flatMap((block) => (block.type === "text" ? [block.text ?? ""] : [])).join("\n");
59
60function preview(
61  entry: EntryRecord,
62): Pick<TreeEntry, "text" | "tools" | "error" | "imageCount" | "time"> {
63  const message = entry.model?.[0];
64  const written = message?.timestamp === undefined ? {} : { time: message.timestamp };
65  if (message?.role === "assistant") {
66    const tools = message.content.flatMap((
67      block,
68    ) => (block.type === "toolCall" ? [block.name] : []));
69    const text = clip(PREVIEW, textOf(message.content)) ||
70      (message.stopReason === "aborted" ? "(aborted)" : clip(PREVIEW, message.errorMessage ?? ""));
71    return {
72      ...written,
73      text,
74      ...(tools.length > 0 ? { tools } : {}),
75      ...(message.stopReason === "error" ? { error: true } : {}),
76    };
77  }
78  if (message?.role === "toolResult") {
79    return {
80      ...written,
81      text: clip(PREVIEW, textOf(message.content)),
82      tools: [message.toolName],
83      ...(message.isError ? { error: true } : {}),
84    };
85  }
86  if (message?.role === "user") {
87    const imageCount = typeof message.content === "string"
88      ? 0
89      : message.content.filter((block) => block.type === "image").length;
90    return {
91      ...written,
92      text: entry.kind === "pi.user"
93        ? userText(message.content)
94        : clip(PREVIEW, textOf(message.content)),
95      ...(imageCount > 0 ? { imageCount } : {}),
96    };
97  }
98  if (EventEntry.is(entry)) return { ...written, text: entry.data.ext };
99  return { ...written, text: "" };
100}
101
102/**
103 * Every branch of `tid` as one tree of entries. Entry IDs are session-wide and a branch continues from one entry of
104 * another, so the branches' own entries join into one.
105 */
106export async function conversationTree(berry: Berry, tid: string): Promise<ConversationTree> {
107  const info = (await berry.harness.snapshot(Conversations, context))?.[tid];
108  if (info === undefined) throw new BerryError(`No conversation ${tid}`);
109  const all = [{ id: info.id, at: null, created: info.created }, ...(info.branches ?? [])];
110
111  const branches = await Promise.all(all.map(async ({ id, at, created }) => {
112    const conversation = (await berry.harness.conversation(id as ConversationId, context))!;
113    // a branch's own entries are those after the entry it continues from; the first has all of its history, what
114    // a fork inherited included
115    const query = { order: "ascending", minEntryId: ((at ?? 0) + 1) as EntryId } as const;
116    const entries: TreeEntry[] = [];
117    let previous: number | null = at;
118    let cursor: Cursor | undefined;
119    do {
120      const page = await conversation.entries(query, 256, cursor, context);
121      for (const entry of page.items) {
122        entries.push({
123          id: entry.id,
124          parent: previous,
125          branch: id,
126          kind: entry.kind,
127          ...preview(entry),
128        });
129        previous = entry.id;
130      }
131      cursor = page.next;
132    } while (cursor !== undefined);
133    return { branch: { id, created, tip: previous }, entries };
134  }));
135  return {
136    head: headOf(info),
137    branches: branches.map(({ branch }) => branch),
138    entries: branches.flatMap(({ entries }) => entries).sort((a, b) => a.id - b.id),
139  };
140}