char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somadd background subagents (!!!)2d39747

main
9.1 KiB230 linesraw
1// What the list of conversations shows of each beyond its index entry: whether it is answering, and how it began.
2
3import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context";
4import { homedir } from "node:os";
5import { type Binding, ENVIRONMENT } from "./environment.ts";
6import { EventEntry } from "./entries.ts";
7import { imageSummary } from "@berry/api/images.ts";
8import {
9  type ConversationId,
10  type Cursor,
11  type EntryId,
12  InboxDoc,
13  LiveDoc,
14} from "@earendil-works/pi-durable";
15import { type Berry, type ConversationInfo, Conversations, headOf } from "./berry.ts";
16import { type Feed, published } from "./pubsub.ts";
17import { clip, textOf, userText } from "./tree.ts";
18import { SettledDoc, settledThrough } from "./settled.ts";
19
20export type Summary = {
21  /** Whether it is generating or running tools. */
22  busy: boolean;
23  /** Its first message, on one line; empty before there is one. */
24  preview: string;
25  /** Its newest answer, on one line; empty before one has arrived. */
26  reply: string;
27  /** A settled turn whose newest message has not been viewed. */
28  unread: boolean;
29  /** Its newest user message or return of control to the user, or creation time before either. */
30  updated: number;
31  environment: string;
32  cwd: string;
33};
34
35/** Every listed conversation's summary as one feed, kept up as commits land. */
36export async function summaries(berry: Berry): Promise<{ feed(): Feed; stop(): void }> {
37  const { harness } = berry;
38  const value = published<Record<string, Summary>>({});
39  // a branch's first message never changes once it has one
40  const previews = new Map<ConversationId, string>();
41
42  async function previewOf(id: ConversationId): Promise<string> {
43    const known = previews.get(id);
44    if (known !== undefined) return known;
45    const conversation = await harness.conversation(id, context);
46    let cursor: Cursor | undefined;
47    do {
48      const page = await conversation!.entries({ order: "ascending" }, 16, cursor, context);
49      const first = page.items.find((entry) => entry.kind === "pi.user")?.model?.[0];
50      if (first?.role === "user") {
51        const text = clip(
52          160,
53          [userText(first.content), imageSummary(first.content)].filter(Boolean).join(" "),
54        );
55        previews.set(id, text);
56        return text;
57      }
58      cursor = page.next;
59    } while (cursor !== undefined);
60    return "";
61  }
62
63  async function busyOf(head: ConversationId) {
64    return (await harness.snapshot(LiveDoc, head, context))?.run !== undefined ||
65      ((await harness.snapshot(InboxDoc, head, context))?.items.length ?? 0) > 0;
66  }
67
68  async function summarize(info: ConversationInfo): Promise<Summary> {
69    await settledThrough(harness);
70    const head = headOf(info);
71    const settled = await harness.snapshot(SettledDoc, head, context);
72    const busy = await busyOf(head);
73    let unread = false;
74    let updated = settled?.updated ?? info.created;
75    const conversation = (await harness.conversation(head, context))!;
76    // scanned on its own, since the loop below stops as soon as it has what it needs of the branch
77    const replyOf = async () => {
78      const page = await conversation.entries({ order: "descending" }, 16, undefined, context);
79      for (const entry of page.items) {
80        const message = entry.model?.[0];
81        if (
82          entry.kind === "pi.assistant" && message?.role === "assistant" &&
83          message.stopReason !== "toolUse"
84        ) {
85          return clip(160, textOf(message.content));
86        }
87      }
88      return "";
89    };
90    let cursor: Cursor | undefined;
91    let boundary: EntryId | undefined;
92    let found = false;
93    let timeFound = settled !== undefined && head === info.id && info.parent === undefined;
94    let userTime: number | undefined;
95    let userFound = false;
96    do {
97      const page = await conversation.entries(
98        { order: "descending" },
99        16,
100        cursor,
101        context,
102      );
103      for (const entry of page.items) {
104        // match the active transcript: a reset must not resurrect unread older messages
105        boundary ??= entry.head;
106        const message = entry.model?.[0];
107        if (
108          !timeFound && entry.kind === "pi.assistant" && message?.role === "assistant" &&
109          message.stopReason !== "toolUse"
110        ) {
111          if (settled === undefined || entry.conversationId !== head) {
112            // a fork at the answer precedes the separate commit that records its completion
113            const task = entry.byTaskId === undefined
114              ? undefined
115              : await harness.getTask(entry.byTaskId, context);
116            updated = Math.max(settled?.updated ?? 0, task?.endedAt ?? message.timestamp);
117          }
118          timeFound = true;
119        }
120        if (!userFound && entry.kind === "pi.user" && message?.role === "user") {
121          userTime = message.timestamp;
122          userFound = true;
123        }
124        if (!found && (entry.kind === "pi.assistant" || entry.kind === "pi.user")) {
125          unread = !busy && (boundary === undefined || entry.id >= boundary) &&
126            entry.id > (info.readThrough ?? 0);
127          found = true;
128        }
129        if (found && timeFound && userFound) break;
130      }
131      cursor = page.next;
132    } while ((!found || !timeFound || !userFound) && cursor !== undefined);
133    if (userTime !== undefined) updated = Math.max(updated, userTime);
134    const binding = await berry.state(await berry.tidOf(head), ENVIRONMENT) as Binding;
135    return {
136      busy,
137      preview: await previewOf(head),
138      reply: await replyOf(),
139      unread,
140      updated,
141      environment: binding.environment,
142      cwd: binding.cwd ?? homedir(),
143    };
144  }
145
146  // what changed since the last refresh: some conversations' ids, or everything
147  let dirty: Set<ConversationId> | "all" = "all";
148  // each summary's index entry as last summarized; an index commit restales only those it changed, as a read receipt
149  const summarized = new Map<string, string>();
150  let reindexed = false;
151  let refreshing = false;
152  let stopped = false;
153  async function refresh() {
154    if (refreshing || stopped) return;
155    refreshing = true;
156    try {
157      while (!stopped && (reindexed || dirty === "all" || dirty.size > 0)) {
158        const changed = dirty;
159        dirty = new Set();
160        reindexed = false;
161        const index = (await harness.snapshot(Conversations, context)) ?? {};
162        // an owned conversation shows while it is busy, then leaves the feed: check only
163        // whether it is working before doing its full summarize, so idle history cannot pile up
164        const listed: [string, ConversationInfo][] = [];
165        for (const [tid, info] of Object.entries(index)) {
166          const head = headOf(info);
167          const touched = value.value[tid] !== undefined || changed === "all" || reindexed ||
168            changed.has(head);
169          if (info.owner === undefined || (touched && await busyOf(head))) listed.push([tid, info]);
170        }
171        const next: Record<string, Summary> = {};
172        for (const [tid, info] of listed) {
173          const previous = value.value[tid];
174          const entry = JSON.stringify(info);
175          const stale = changed === "all" || previous === undefined ||
176            summarized.get(tid) !== entry || changed.has(headOf(info));
177          next[tid] = stale ? await summarize(info) : previous;
178          summarized.set(tid, entry);
179        }
180        for (const tid of summarized.keys()) if (!(tid in next)) summarized.delete(tid);
181        value.set(next);
182      }
183    } catch (error) {
184      if (!stopped) console.error("[berry] summaries:", error);
185    } finally {
186      refreshing = false;
187    }
188  }
189
190  const live = LiveDoc.definition.kind;
191  const inbox = InboxDoc.definition.kind;
192  const settled = SettledDoc.definition.kind;
193  const index = Conversations.definition.kind;
194  // commit listeners must not call Session APIs inline
195  const unsubscribe = harness.subscribeCommits((publication) => {
196    let touched = false;
197    for (const change of publication.changes) {
198      if (change.type === "document" && change.record.kind === index) reindexed = true;
199      else if (
200        change.type === "document" &&
201        (change.record.kind === live || change.record.kind === inbox ||
202          change.record.kind === settled) &&
203        change.conversationId
204      ) {
205        if (dirty !== "all") dirty.add(change.conversationId);
206      } else if (
207        change.type === "entry" &&
208        (change.value.kind === "pi.user" || change.value.kind === "pi.assistant" ||
209          change.value.head !== undefined ||
210          (EventEntry.is(change.value) &&
211            (change.value.data.ext === ENVIRONMENT ||
212              ((change.value.data.ext === "code" || change.value.data.ext === "coding") &&
213                typeof change.value.data.event === "object" && change.value.data.event !== null &&
214                !Array.isArray(change.value.data.event) && change.value.data.event.type === "cd"))))
215      ) {
216        if (dirty !== "all") dirty.add(change.value.conversationId);
217      } else continue;
218      touched = true;
219    }
220    if (touched) queueMicrotask(() => void refresh());
221  });
222  await refresh();
223  return {
224    feed: () => value.feed(),
225    stop() {
226      stopped = true;
227      unsubscribe();
228    },
229  };
230}