@hachej/boring-agent 0.4.47 → 0.4.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { diffRevisions } from '@earendil-works/chord/delta';
1
2
  import { RequestGuardError, hasJsonContentType, readJsonBody } from '@hachej/boring-files/request-guard';
2
3
  /** Wire protocol shared with the browser client in `@hachej/boring-ui-kit/remote-chat`. */
3
4
  export const CHAT_TRANSPORT = Object.freeze({ schema: 'boring.chat-transport', version: 1, maxRequestBytes: 8_388_608, maxContextBytes: 4096, maxHistoryPage: 200, maxPendingFrames: 100, heartbeatMs: 15_000 });
@@ -60,9 +61,31 @@ function reads(conversation, context, project) {
60
61
  },
61
62
  };
62
63
  }
63
- /** Serialize one watch frame; with `project` a view is projected first (a throw means `projection_failed`). */
64
- function encodeFrame(frame, project) {
65
- return JSON.stringify(frame.kind === 'view' && project ? { kind: 'view', view: project(frame.view) } : frame);
64
+ /**
65
+ * Serialize the watch frames of one connection; with `project` a view is projected first (a throw means `projection_failed`).
66
+ * A projected viewer used to get a complete view per update, which grows with the conversation (a 60 KB prompt, then every earlier
67
+ * entry, ten times a second). The first frame is still the complete view; after it, an update is the operation batch between the
68
+ * last view this connection received and the new projected one (`diffRevisions`), which the client already replays like native
69
+ * operations, and the complete view when that is not smaller. Raw operations never leave the host: the batch is computed from projected views.
70
+ */
71
+ function frameEncoder(project) {
72
+ let sent;
73
+ return frame => {
74
+ if (frame.kind !== 'view' || !project)
75
+ return JSON.stringify(frame);
76
+ // Round-tripped, so the diff is between exactly what the client holds (no `undefined` fields).
77
+ const full = JSON.stringify({ kind: 'view', view: project(frame.view) });
78
+ const next = JSON.parse(full).view;
79
+ if (sent !== undefined) {
80
+ const batch = JSON.stringify({ kind: 'ops', ops: diffRevisions(sent, next) });
81
+ if (batch.length < full.length / 2) {
82
+ sent = next;
83
+ return batch;
84
+ }
85
+ }
86
+ sent = next;
87
+ return full;
88
+ };
66
89
  }
67
90
  /** Open the conversation watch for an authenticated access, refusing a revoked one. Shared by the HTTP watch and the socket. */
68
91
  async function openWatch(access, signal) {
@@ -76,9 +99,11 @@ async function openWatch(access, signal) {
76
99
  /**
77
100
  * Stream newline-delimited JSON. A trusted viewer receives one view and then the exact native operation batches,
78
101
  * replayed in the browser with `@earendil-works/chord/delta`. With `project`, raw operations never leave the host:
79
- * the viewer receives complete projected views, and a slow reader gets the latest one rather than a backlog.
102
+ * the viewer receives one projected view, then batches between projected views (`frameEncoder`), and a slow reader gets the
103
+ * batch to the latest one rather than a backlog.
80
104
  */
81
105
  function stream(watch, project, signal, heartbeatMs) {
106
+ const encode = frameEncoder(project);
82
107
  let pending = [{ kind: 'view', view: watch.value }], wake, end, finished = false;
83
108
  // `beat` asks the next pull for a heartbeat. The timer restarts after every frame, so it fires only on an idle stream, and it is
84
109
  // cleared when the stream finishes or is cancelled: nothing outlives the response.
@@ -120,7 +145,7 @@ function stream(watch, project, signal, heartbeatMs) {
120
145
  if (frame) {
121
146
  let line;
122
147
  try {
123
- line = encodeFrame(frame, project);
148
+ line = encode(frame);
124
149
  }
125
150
  catch {
126
151
  finished = true;
@@ -416,9 +441,10 @@ export function createConversationSocketHandler(options) {
416
441
  return null;
417
442
  }
418
443
  const { project } = access;
444
+ const encode = frameEncoder(project);
419
445
  const frame = (value) => {
420
446
  try {
421
- send(socket, encodeFrame(value, project));
447
+ send(socket, encode(value));
422
448
  }
423
449
  catch {
424
450
  void watch.stop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-agent",
3
- "version": "0.4.47",
3
+ "version": "0.4.48",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -188,8 +188,8 @@
188
188
  "@earendil-works/pi-durable": "~1.4.0",
189
189
  "@earendil-works/pi-ai": "~1.4.0",
190
190
  "@earendil-works/chord": "~1.4.0",
191
- "@hachej/boring-files": "^0.4.47",
192
- "@hachej/boring-ui-kit": "^0.4.47",
191
+ "@hachej/boring-files": "^0.4.48",
192
+ "@hachej/boring-ui-kit": "^0.4.48",
193
193
  "typebox": "1.3.27",
194
194
  "xlsx": "^0.20.3"
195
195
  },