char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somspeed up conversation switchingd1e4a76

main
4.6 KiB131 linesraw
1import { Signal } from "@char/aftercare";
2import { applyImmutable, type Op } from "@earendil-works/chord/delta";
3
4const TOKEN_KEY = "berry.token";
5
6export const token = new Signal<string | null>(localStorage.getItem(TOKEN_KEY));
7export const connected = new Signal(false);
8
9export function setToken(value: string | null) {
10  if (value === null) localStorage.removeItem(TOKEN_KEY);
11  else localStorage.setItem(TOKEN_KEY, value);
12  token.set(value);
13}
14
15export class ApiError extends Error {
16  constructor(readonly status: number, message: string) {
17    super(message);
18  }
19}
20
21export async function api<T = unknown>(
22  method: string,
23  path: string,
24  body?: unknown,
25  signal?: AbortSignal,
26): Promise<T> {
27  const response = await fetch(path, {
28    method,
29    signal,
30    headers: {
31      authorization: `Bearer ${token.get()}`,
32      ...(body === undefined ? {} : { "content-type": "application/json" }),
33    },
34    ...(body === undefined ? {} : { body: JSON.stringify(body) }),
35  });
36  const json = await response.json().catch(() => null);
37  if (response.status === 401) setToken(null);
38  if (!response.ok) throw new ApiError(response.status, json?.error ?? response.statusText);
39  return json as T;
40}
41
42type Topic = {
43  value?: { current: unknown };
44  listeners: Set<(value: unknown) => void>;
45  /** A snapshot still taking its older entries, and whether its listeners see it meanwhile. */
46  filling?: { value: { entries: unknown[] }; older: number; shown: boolean };
47};
48
49const topics = new Map<string, Topic>();
50let socket: WebSocket | null = null;
51let retry = 0;
52
53const send = (message: object) => {
54  if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
55};
56
57function connect() {
58  const current = new WebSocket(
59    `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`,
60  );
61  socket = current;
62  current.onopen = () => current.send(JSON.stringify({ t: "auth", token: token.get() }));
63  current.onmessage = (event) => {
64    const message = JSON.parse(event.data);
65    if (message.t === "ready") {
66      retry = 0;
67      connected.set(true);
68      for (const name of topics.keys()) send({ t: "sub", topic: name });
69    } else if (message.t === "snapshot") {
70      const topic = topics.get(message.topic);
71      if (topic === undefined) return;
72      // a first snapshot shows as it arrives, newest first; a later one, such as a reconnect's, once it is whole
73      topic.filling = message.older
74        ? { value: message.value, older: message.older, shown: topic.value === undefined }
75        : undefined;
76      if (topic.filling?.shown !== false) publish(topic, message.value);
77    } else if (message.t === "older") {
78      const topic = topics.get(message.topic);
79      const filling = topic?.filling;
80      if (filling === undefined) return;
81      const { value } = filling;
82      filling.value = { ...value, entries: [...message.entries, ...value.entries] };
83      filling.older -= message.entries.length;
84      if (filling.older <= 0) topic!.filling = undefined;
85      if (filling.shown || filling.older <= 0) publish(topic, filling.value);
86    } else if (message.t === "ops") {
87      const topic = topics.get(message.topic);
88      if (topic?.value) publish(topic, applyImmutable(topic.value.current, message.ops as Op[]));
89    } else if (message.t === "error") {
90      console.error(`[berry] ${message.topic ?? "socket"}: ${message.message}`);
91    }
92  };
93  current.onclose = (event) => {
94    if (socket !== current) return;
95    socket = null;
96    connected.set(false);
97    if (event.code === 4001) setToken(null);
98    else if (token.get() !== null) setTimeout(connect, Math.min(10_000, 250 * 2 ** retry++));
99  };
100}
101
102token.subscribeImmediate((value) => {
103  if (value !== null && socket === null) connect();
104  if (value === null) socket?.close();
105});
106
107function publish(topic: Topic | undefined, value: unknown) {
108  if (topic === undefined) return;
109  topic.value = { current: value };
110  for (const listener of topic.listeners) listener(value);
111}
112
113/**
114 * Follow a replicated json value: `listener` gets its snapshot once there is one, and then each revision, whose
115 * unchanged parts keep their identity. Returns the release, for when its view goes away.
116 */
117export function watch<T>(name: string, listener: (value: T) => void): () => void {
118  if (!topics.has(name)) {
119    topics.set(name, { listeners: new Set() });
120    send({ t: "sub", topic: name });
121  }
122  const topic = topics.get(name)!;
123  const own = (value: unknown) => listener(value as T);
124  topic.listeners.add(own);
125  if (topic.value) own(topic.value.current);
126  return () => {
127    if (!topic.listeners.delete(own) || topic.listeners.size > 0) return;
128    topics.delete(name);
129    send({ t: "unsub", topic: name });
130  };
131}