replica-bridge 0.3.15 → 0.3.17

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replica-bridge",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "description": "Lets the ChatReplica iPhone app use Codex on this computer through a relay, end-to-end encrypted.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,14 @@ const CATCH_UP_PAGES = 1000;
23
23
  /** More changed tasks than this in one event, and the phone reads the whole board instead. */
24
24
  export const CHANGED_ITEMS_MAX = 200;
25
25
  /** What this bridge can do beyond reading, for board/state (PROTOCOL.md "看板"). */
26
- export const BOARD_CAPS = ["post", "delta"] ;
26
+ export const BOARD_CAPS = ["post", "delta", "items", "tasks"] ;
27
+ /** Up to this many changed tasks in one event come with their contents (`task`); past it the phone reads them itself. */
28
+ export const CARRIED_TASKS_MAX = 50;
29
+ /** A task bigger than this is left for the phone to read. */
30
+ const CARRIED_TASK_MAX_BYTES = 256 * 1024;
31
+
32
+ /** What the backend says a changed task now is: its view, or gone (404). Missing when the read failed. */
33
+
27
34
 
28
35
  /** The backend's own cap on a request body (agens-backend src/http.mjs BODY_MAX). */
29
36
  export const POST_BODY_MAX_BYTES = 16 * 1024;
@@ -31,10 +38,11 @@ const REC = "[A-Za-z0-9_.:-]+";
31
38
 
32
39
  /**
33
40
  * GETs (#367). /session also writes TASK.md in the task's working folder, which is what 开工 asks for; /share and /me
34
- * only read. Not /assist, /mcp, /search, /changes (the bridge's own poll) or anything under /ui.
41
+ * only read. From 0.3.16 also one item, a task's source (`?offset=` pages its text), as the backend's own page opens it.
42
+ * Not /assist, /mcp, /search, /context, /changes (the bridge's own poll) or anything under /ui.
35
43
  */
36
44
  const READS = [/^\/v1\/status$/, /^\/v1\/today$/, /^\/v1\/recommendations$/, new RegExp(`^/v1/recommendations/${REC}$`),
37
- new RegExp(`^/v1/recommendations/${REC}/(share|session)$`), /^\/v1\/me$/];
45
+ new RegExp(`^/v1/recommendations/${REC}/(share|session)$`), /^\/v1\/me$/, new RegExp(`^/v1/items/${REC}$`)];
38
46
 
39
47
  /** POSTs (#367): a task's follow-up actions, cancelling its preparation note, and muting a conversation. Nothing else. */
40
48
  const WRITES = [new RegExp(`^/v1/recommendations/${REC}/actions$`), new RegExp(`^/v1/recommendations/${REC}/prep/cancel$`), /^\/v1\/me\/muted$/];
@@ -110,10 +118,17 @@ const DEFAULT_STREAM_RETRY_SECONDS = 30;
110
118
 
111
119
 
112
120
  /** Changes as a phone gets them: one per task, the latest, or a reset when there are too many. */
113
- function changedEvent(items ) {
121
+ /** The latest change of each task, in the order they last changed. */
122
+ function latestOf(items ) {
114
123
  const latest = new Map ();
115
124
  for (const item of items) { latest.delete(item.recId); latest.set(item.recId, item); }
116
- return latest.size > CHANGED_ITEMS_MAX ? { t: "board/changed", reset: true } : { t: "board/changed", items: [...latest.values()] };
125
+ return [...latest.values()];
126
+ }
127
+
128
+ function changedEvent(items , carried = new Map ()) {
129
+ const latest = latestOf(items);
130
+ if (latest.length > CHANGED_ITEMS_MAX) return { t: "board/changed", reset: true };
131
+ return { t: "board/changed", items: latest.map((item) => ({ ...item, ...carried.get(item.recId) })) };
117
132
  }
118
133
 
119
134
  function changeOf(value ) {
@@ -135,6 +150,8 @@ export class BoardReader {
135
150
  round = 0;
136
151
  /** The backend's push stream: open, unknown (try it), or not there (an older backend answers 404). */
137
152
  stream = null;
153
+ /** Changes go out one batch at a time and in order, each after its tasks were read. */
154
+ delivery = Promise.resolve();
138
155
  streamSupported = null;
139
156
  /** Not before this time (ms) after the backend refused a stream for a passing reason. */
140
157
  streamRetryAt = 0;
@@ -215,6 +232,7 @@ export class BoardReader {
215
232
  this.end = null;
216
233
  this.up = null;
217
234
  this.polling = false;
235
+ this.delivery = Promise.resolve();
218
236
  this.round++;
219
237
  }
220
238
 
@@ -259,20 +277,63 @@ export class BoardReader {
259
277
  return;
260
278
  }
261
279
  if (round !== this.round || !this.subs.has(sub)) return;
280
+ // What changed comes with the tasks as they are now; reading them takes a moment, in which more may change, so
281
+ // read the log again up to where the shared reader got before this subscriber starts hearing from it.
282
+ let carried = new Map ();
283
+ if (!reset && missed.length) {
284
+ carried = await this.carry(missed);
285
+ try {
286
+ for (let page = 0; cursor !== null && cursor < this.end && page < CATCH_UP_PAGES; page++) {
287
+ const { items, next } = await this.changes(cursor);
288
+ missed.push(...items);
289
+ if (next === cursor && items.length === 0) break;
290
+ cursor = next;
291
+ }
292
+ } catch { /* the late ones go without contents; the phone reads them */ }
293
+ if (round !== this.round || !this.subs.has(sub)) return;
294
+ }
262
295
  sub.admitting = false;
263
296
  sub.cursor = Math.max(cursor ?? this.end , this.end );
264
297
  sub.listener(this.state(true, sub.cursor));
265
298
  if (reset) sub.listener({ t: "board/changed", reset: true });
266
- else if (missed.length) sub.listener(changedEvent(missed));
299
+ else if (missed.length) sub.listener(changedEvent(missed, carried));
300
+ }
301
+
302
+ /**
303
+ * Reads each changed task (its latest change only) from the backend, so the phone needs no read of its own. Only while
304
+ * a phone is subscribed, which is while its 任务 page is on screen, so marking them seen is what the phone's own read
305
+ * did before. Too many at once, and the phone reads them (or the whole board) itself.
306
+ */
307
+ async carry(items ) {
308
+ const carried = new Map ();
309
+ const latest = latestOf(items);
310
+ if (latest.length > CARRIED_TASKS_MAX) return carried;
311
+ await Promise.all(latest.map(async ({ recId }) => {
312
+ const answer = await this.answer(`/v1/recommendations/${encodeURIComponent(recId)}`);
313
+ if ("error" in answer) return;
314
+ if (answer.status === 404) { carried.set(recId, { gone: true }); return; }
315
+ if (answer.status !== 200 || Buffer.byteLength(answer.body, "utf8") > CARRIED_TASK_MAX_BYTES) return;
316
+ try { carried.set(recId, { task: JSON.parse(answer.body) }); } catch { /* the phone reads it */ }
317
+ }));
318
+ return carried;
319
+ }
320
+
321
+ /** Reads the changed tasks, then tells each subscriber; batches stay in order. */
322
+ send(items , end ) {
323
+ const round = this.round;
324
+ this.delivery = this.delivery.then(async () => {
325
+ const carried = await this.carry(items);
326
+ if (round === this.round) this.deliver(items, end, carried);
327
+ });
267
328
  }
268
329
 
269
330
  /** New changes up to `end`: each subscriber gets those past its own place. */
270
- deliver(items , end ) {
331
+ deliver(items , end , carried = new Map ()) {
271
332
  for (const sub of this.subs) {
272
333
  if (sub.cursor === null) continue;
273
334
  const fresh = items.filter((item) => item.seq > sub.cursor );
274
335
  sub.cursor = Math.max(sub.cursor, end);
275
- if (fresh.length) sub.listener(changedEvent(fresh));
336
+ if (fresh.length) sub.listener(changedEvent(fresh, carried));
276
337
  }
277
338
  }
278
339
 
@@ -312,7 +373,7 @@ export class BoardReader {
312
373
  if (up) this.end = end;
313
374
  this.setUp(up);
314
375
  if (!up) return;
315
- if (items.length) this.deliver(items, end );
376
+ if (items.length) this.send(items, end );
316
377
  for (const sub of this.subs) if (sub.cursor === null) void this.admit(sub);
317
378
  if (this.streamSupported !== false && Date.now() >= this.streamRetryAt) void this.openStream(round);
318
379
  }
@@ -363,7 +424,7 @@ export class BoardReader {
363
424
  pending = [];
364
425
  const end = Math.max(this.end ?? 0, ...batch.map((item) => item.seq));
365
426
  this.end = end;
366
- this.deliver(batch, end);
427
+ this.send(batch, end);
367
428
  };
368
429
  try {
369
430
  const decoder = new TextDecoder();
package/src/protocol.js CHANGED
@@ -249,13 +249,15 @@ export class FrameCipher {
249
249
 
250
250
  /** One task that changed, from agens-backend's change log (0.3.14). */
251
251
 
252
+ /** As a phone gets it from 0.3.17 on: the task as the backend now has it (`GET /v1/recommendations/:id`), or gone. */
253
+
252
254
  /**
253
255
  * What a subscriber hears. From 0.3.14 a board/state that says up also gives `seq` (where in the change log the phone
254
256
  * starts), `caps` and the bridge's version, and board/changed names the tasks, or says `reset` when there are too many.
255
257
  */
256
258
 
257
259
 
258
-
260
+
259
261
 
260
262
 
261
263
  /** One segment of a long line: `data` is base64 of bytes [segment * chunk size, …) of the payload, `size` its length. */