@frockbot/kernel-do 0.3.22 → 0.3.24

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": "@frockbot/kernel-do",
3
- "version": "0.3.22",
3
+ "version": "0.3.24",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,8 +12,8 @@
12
12
  "typecheck": "tsc --noEmit -p tsconfig.json"
13
13
  },
14
14
  "dependencies": {
15
- "@frockbot/kernel-composition": "0.3.22",
16
- "@frockbot/kernel-contracts": "0.3.22",
15
+ "@frockbot/kernel-composition": "0.3.24",
16
+ "@frockbot/kernel-contracts": "0.3.24",
17
17
  "cordis": "4.0.0-rc.8"
18
18
  },
19
19
  "devDependencies": {
package/src/authority.ts CHANGED
@@ -1177,19 +1177,58 @@ export class BotDurableAuthority<Snapshot> {
1177
1177
  return this.readRunFrom(this.ctx.storage, runId);
1178
1178
  }
1179
1179
 
1180
+ /**
1181
+ * The run record alone, with no journal behind it.
1182
+ *
1183
+ * Deciding whether a Turn belongs to the conversation being read, and
1184
+ * whether a person is meant to see it, needs the record and nothing else. A
1185
+ * transcript page scans many more candidates than it keeps, and hydrating
1186
+ * every candidate's events to discard it was the whole cost of that scan.
1187
+ * The returned record carries an empty `events` array and its `eventRange`;
1188
+ * anything that reads the journal calls {@link readStoredRun} or
1189
+ * {@link readStoredRunForDisplay}.
1190
+ */
1191
+ async readRunHeader(
1192
+ runId: string,
1193
+ ): Promise<StoredRunV1<Snapshot> | undefined> {
1194
+ return this.codec.optional(
1195
+ await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${runId}`),
1196
+ );
1197
+ }
1198
+
1199
+ /**
1200
+ * A run hydrated for the conversation surface: exact for everything the
1201
+ * transcript renders, and each normalized model request left as its durable
1202
+ * projection. See `SessionEventLog.readDisplayRange`.
1203
+ */
1204
+ async readStoredRunForDisplay(
1205
+ runId: string,
1206
+ ): Promise<StoredRunV1<Snapshot> | undefined> {
1207
+ return this.readRunFrom(this.ctx.storage, runId, "display");
1208
+ }
1209
+
1180
1210
  private async readRunFrom(
1181
1211
  storage: SessionEventLogStorage,
1182
1212
  runId: string,
1213
+ fidelity: "exact" | "display" = "exact",
1183
1214
  ): Promise<StoredRunV1<Snapshot> | undefined> {
1184
1215
  const run = this.codec.optional(
1185
1216
  await storage.get<unknown>(`${RUN_PREFIX}${runId}`),
1186
1217
  );
1187
1218
  if (!run?.eventRange) return run;
1188
- const events = await new SessionEventLog(storage).readRange(
1189
- run.sessionId,
1190
- run.eventRange.startSeq,
1191
- run.eventRange.endSeq,
1192
- );
1219
+ const log = new SessionEventLog(storage);
1220
+ const events =
1221
+ fidelity === "display"
1222
+ ? await log.readDisplayRange(
1223
+ run.sessionId,
1224
+ run.eventRange.startSeq,
1225
+ run.eventRange.endSeq,
1226
+ )
1227
+ : await log.readRange(
1228
+ run.sessionId,
1229
+ run.eventRange.startSeq,
1230
+ run.eventRange.endSeq,
1231
+ );
1193
1232
  if (events.length !== run.eventRange.endSeq - run.eventRange.startSeq) {
1194
1233
  throw new Error(`run "${run.runId}" has an incomplete event range`);
1195
1234
  }
@@ -1203,9 +1242,10 @@ export class BotDurableAuthority<Snapshot> {
1203
1242
 
1204
1243
  /**
1205
1244
  * The bounded durable event projections for a run. This is the inspection
1206
- * path: recovery and client transcript projection use `readStoredRun` and
1207
- * therefore receive exact events, while a debug snapshot never hydrates a
1208
- * multi-megabyte prompt merely to cut it again.
1245
+ * path: recovery, compaction and audit use `readStoredRun` and therefore
1246
+ * receive exact events, the transcript uses `readStoredRunForDisplay`, and a
1247
+ * debug snapshot never hydrates a multi-megabyte prompt merely to cut it
1248
+ * again (ADR 0038).
1209
1249
  */
1210
1250
  async readRunEventProjections(runId: string): Promise<
1211
1251
  | {
@@ -250,6 +250,138 @@ describe("the paged Session event log", () => {
250
250
  expect(await log.readRange(SESSION_ID, 2, 3)).toEqual([repaired[2]]);
251
251
  });
252
252
 
253
+ test("reads a range without hydrating unrelated model requests", async () => {
254
+ const storage = new MemoryStorage();
255
+ const session = new Session(SESSION_ID, () => {});
256
+ for (let turn = 1; turn <= 12; turn += 1) {
257
+ session.appendBatch([
258
+ { type: "turn/start", turn },
259
+ {
260
+ type: "model/request",
261
+ turn,
262
+ step: 1,
263
+ request: {
264
+ requestId: `request-${turn}`,
265
+ provider: "fake",
266
+ model: "large-context",
267
+ system: "s".repeat(80_000),
268
+ messages: [],
269
+ tools: [],
270
+ },
271
+ },
272
+ { type: "turn/end", turn, outcome: "completed" },
273
+ ]);
274
+ }
275
+ const log = new SessionEventLog(storage);
276
+ await log.rewrite(SESSION_ID, [...session.events]);
277
+
278
+ const reads: string[] = [];
279
+ const get = storage.get.bind(storage);
280
+ storage.get = <T>(key: string): Promise<T | undefined> => {
281
+ reads.push(key);
282
+ return get<T>(key);
283
+ };
284
+
285
+ const range = await log.readRange(SESSION_ID, 0, 1);
286
+
287
+ expect(range).toEqual([session.events[0]!]);
288
+ expect(
289
+ reads.filter((key) =>
290
+ key.startsWith(sessionEventPayloadPrefixV1(SESSION_ID)),
291
+ ),
292
+ ).toEqual([]);
293
+ // The index, the bisected pages, and nothing else: the cost of a range is
294
+ // the range, not every request the conversation has ever retained.
295
+ expect(reads.length).toBeLessThanOrEqual(4);
296
+ });
297
+
298
+ test("hydrates only the payloads its own range references", async () => {
299
+ const storage = new MemoryStorage();
300
+ const session = new Session(SESSION_ID, () => {});
301
+ for (let turn = 1; turn <= 12; turn += 1) {
302
+ session.appendBatch([
303
+ { type: "turn/start", turn },
304
+ {
305
+ type: "model/request",
306
+ turn,
307
+ step: 1,
308
+ request: {
309
+ requestId: `request-${turn}`,
310
+ provider: "fake",
311
+ model: "large-context",
312
+ system: "s".repeat(80_000),
313
+ messages: [],
314
+ tools: [],
315
+ },
316
+ },
317
+ { type: "turn/end", turn, outcome: "completed" },
318
+ ]);
319
+ }
320
+ const log = new SessionEventLog(storage);
321
+ await log.rewrite(SESSION_ID, [...session.events]);
322
+
323
+ const reads: string[] = [];
324
+ const get = storage.get.bind(storage);
325
+ storage.get = <T>(key: string): Promise<T | undefined> => {
326
+ reads.push(key);
327
+ return get<T>(key);
328
+ };
329
+
330
+ const range = await log.readRange(SESSION_ID, 30, 33);
331
+
332
+ expect(range).toEqual(session.events.slice(30, 33));
333
+ const payloads = new Set(
334
+ reads
335
+ .filter((key) =>
336
+ key.startsWith(sessionEventPayloadPrefixV1(SESSION_ID)),
337
+ )
338
+ .map((key) => key.slice(0, key.lastIndexOf(":"))),
339
+ );
340
+ const requestSeq = session.events
341
+ .slice(30, 33)
342
+ .find((event) => event.type === "model/request")!.seq;
343
+ expect([...payloads]).toEqual([
344
+ `${sessionEventPayloadPrefixV1(SESSION_ID)}${String(requestSeq).padStart(12, "0")}`,
345
+ ]);
346
+ });
347
+
348
+ test("leaves exact model requests off the display range", async () => {
349
+ const storage = new MemoryStorage();
350
+ const log = new SessionEventLog(storage);
351
+ const events = journal();
352
+ await log.rewrite(SESSION_ID, events);
353
+
354
+ const reads: string[] = [];
355
+ const get = storage.get.bind(storage);
356
+ storage.get = <T>(key: string): Promise<T | undefined> => {
357
+ reads.push(key);
358
+ return get<T>(key);
359
+ };
360
+
361
+ const display = await log.readDisplayRange(SESSION_ID, 0, events.length);
362
+
363
+ expect(display.map((event) => event.type)).toEqual(
364
+ events.map((event) => event.type),
365
+ );
366
+ expect(display.map((event) => event.seq)).toEqual(
367
+ events.map((event) => event.seq),
368
+ );
369
+ expect(
370
+ reads.filter((key) =>
371
+ key.startsWith(sessionEventPayloadPrefixV1(SESSION_ID)),
372
+ ),
373
+ ).toEqual([]);
374
+ const request = display.find((event) => event.type === "model/request");
375
+ expect(request?.type === "model/request" && request.request.requestId).toBe(
376
+ "request-1",
377
+ );
378
+ // The excerpt says what it is; the exact request stays on the audit path.
379
+ expect(
380
+ request?.type === "model/request" && request.request.system.length,
381
+ ).toBeLessThan(80_000);
382
+ expect(await log.readRange(SESSION_ID, 0, events.length)).toEqual(events);
383
+ });
384
+
253
385
  test("migrates the legacy single value on demand", async () => {
254
386
  const storage = new MemoryStorage();
255
387
  const events = journal(1_900_000);
@@ -338,10 +338,47 @@ export class SessionEventLog {
338
338
  return events;
339
339
  }
340
340
 
341
+ /**
342
+ * The exact events of one half-open range.
343
+ *
344
+ * A range read seeks to the pages that cover it and hydrates only the
345
+ * payloads those pages reference. Reading the whole conversation to slice
346
+ * three events out of it made a transcript read cost one storage operation
347
+ * per retained model request — a Bot with a hundred 80 KiB requests paid for
348
+ * all of them to draw one Turn.
349
+ */
341
350
  async readRange(
342
351
  sessionId: string,
343
352
  startSeq: number,
344
353
  endSeq: number,
354
+ ): Promise<SessionEvent[]> {
355
+ return this.rangeEvents(sessionId, startSeq, endSeq, "exact");
356
+ }
357
+
358
+ /**
359
+ * The same range at display fidelity: every event the conversation shows,
360
+ * with each exact model request left on the audit path.
361
+ *
362
+ * The transcript projection reads tool calls, tool results, sends and
363
+ * assistant chunks; it has never rendered a normalized model request. ADR
364
+ * 0033 keeps a bounded projection of that request beside its chunked exact
365
+ * payload precisely so a reader that does not need the exact bytes need not
366
+ * pay for them, and this is that reader. Recovery, compaction, audit, and
367
+ * model-history derivation stay on {@link readRange}.
368
+ */
369
+ async readDisplayRange(
370
+ sessionId: string,
371
+ startSeq: number,
372
+ endSeq: number,
373
+ ): Promise<SessionEvent[]> {
374
+ return this.rangeEvents(sessionId, startSeq, endSeq, "display");
375
+ }
376
+
377
+ private async rangeEvents(
378
+ sessionId: string,
379
+ startSeq: number,
380
+ endSeq: number,
381
+ fidelity: "exact" | "display",
345
382
  ): Promise<SessionEvent[]> {
346
383
  if (
347
384
  !Number.isSafeInteger(startSeq) ||
@@ -351,7 +388,88 @@ export class SessionEventLog {
351
388
  ) {
352
389
  throw new Error("Session event range is invalid");
353
390
  }
354
- return (await this.read(sessionId)).slice(startSeq, endSeq);
391
+ const index = requireIndex(
392
+ await this.storage.get<SessionEventLogIndexV1>(
393
+ sessionEventLogIndexKeyV1(sessionId),
394
+ ),
395
+ sessionId,
396
+ );
397
+ if (!index) return (await this.read(sessionId)).slice(startSeq, endSeq);
398
+ const last = Math.min(endSeq, index.eventCount);
399
+ if (startSeq >= last) return [];
400
+ const located = await this.pageContaining(sessionId, index, startSeq);
401
+ if (!located) {
402
+ throw new Error(
403
+ `Session event pages for "${sessionId}" are not contiguous`,
404
+ );
405
+ }
406
+ const events: SessionEvent[] = [];
407
+ for (let page = located.page; page < index.pageCount; page += 1) {
408
+ const stored =
409
+ page === located.page
410
+ ? located.stored
411
+ : requirePage(
412
+ await this.storage.get<StoredSessionEventPageV1>(
413
+ sessionEventLogPageKey(sessionId, page),
414
+ ),
415
+ sessionId,
416
+ page,
417
+ );
418
+ for (const entry of stored.entries) {
419
+ const seq = storedEventSeq(entry);
420
+ if (seq < startSeq) continue;
421
+ if (seq >= last) return events;
422
+ if (seq !== startSeq + events.length) {
423
+ throw new Error(
424
+ `Session event log for "${sessionId}" is not contiguous`,
425
+ );
426
+ }
427
+ events.push(
428
+ fidelity === "display"
429
+ ? await this.displayEvent(sessionId, entry)
430
+ : await this.exactEvent(sessionId, entry),
431
+ );
432
+ }
433
+ if (events.length === last - startSeq) break;
434
+ }
435
+ if (events.length !== last - startSeq) {
436
+ throw new Error(`Session event log for "${sessionId}" is not contiguous`);
437
+ }
438
+ return events;
439
+ }
440
+
441
+ /**
442
+ * The page holding `seq`, found by bisecting the page keys.
443
+ *
444
+ * Pages carry their own `startSeq` and are written in sequence order, so the
445
+ * accessor can seek without an extra durable page directory to keep
446
+ * consistent with the pages themselves.
447
+ */
448
+ private async pageContaining(
449
+ sessionId: string,
450
+ index: SessionEventLogIndexV1,
451
+ seq: number,
452
+ ): Promise<{ page: number; stored: StoredSessionEventPageV1 } | undefined> {
453
+ let low = 0;
454
+ let high = index.pageCount - 1;
455
+ let found: { page: number; stored: StoredSessionEventPageV1 } | undefined;
456
+ while (low <= high) {
457
+ const middle = Math.floor((low + high) / 2);
458
+ const stored = requirePage(
459
+ await this.storage.get<StoredSessionEventPageV1>(
460
+ sessionEventLogPageKey(sessionId, middle),
461
+ ),
462
+ sessionId,
463
+ middle,
464
+ );
465
+ if (stored.startSeq <= seq) {
466
+ found = { page: middle, stored };
467
+ low = middle + 1;
468
+ } else {
469
+ high = middle - 1;
470
+ }
471
+ }
472
+ return found;
355
473
  }
356
474
 
357
475
  async readProjections(
@@ -522,6 +640,23 @@ export class SessionEventLog {
522
640
  };
523
641
  }
524
642
 
643
+ /**
644
+ * One stored entry at display fidelity.
645
+ *
646
+ * Only a cut `model/request` is answered from its durable projection: every
647
+ * other cut event carries content the conversation renders, so it is
648
+ * hydrated exactly and costs storage in proportion to what is shown.
649
+ */
650
+ private async displayEvent(
651
+ sessionId: string,
652
+ entry: StoredSessionEventV1,
653
+ ): Promise<SessionEvent> {
654
+ if (entry.storage === "cut" && entry.projection.type === "model/request") {
655
+ return decodeSessionEvent(displayModelRequest(entry.projection));
656
+ }
657
+ return this.exactEvent(sessionId, entry);
658
+ }
659
+
525
660
  private async exactEvent(
526
661
  sessionId: string,
527
662
  entry: StoredSessionEventV1,
@@ -717,6 +852,42 @@ export class SessionEventLog {
717
852
  }
718
853
  }
719
854
 
855
+ function storedEventSeq(entry: StoredSessionEventV1): number {
856
+ return entry.storage === "inline" ? entry.event.seq : entry.projection.seq;
857
+ }
858
+
859
+ /**
860
+ * A `model/request` event rebuilt from its durable projection.
861
+ *
862
+ * The system prompt is the projection's excerpt and carries its own cut
863
+ * marker; messages and tools are empty because their counts, not their
864
+ * contents, are what the projection retained. This is deliberately not the
865
+ * request the model ran on, and it never reaches recovery, compaction, or
866
+ * audit — only the transcript, which does not render model requests at all.
867
+ */
868
+ function displayModelRequest(
869
+ projection: StoredSessionEventCutV1["projection"],
870
+ ): Record<string, unknown> {
871
+ const request = (projection.request ?? {}) as Record<string, unknown>;
872
+ const excerpt = (request.excerpt ?? {}) as Record<string, unknown>;
873
+ return {
874
+ type: "model/request",
875
+ seq: projection.seq,
876
+ timestamp: projection.timestamp,
877
+ turn: typeof projection.turn === "number" ? projection.turn : 0,
878
+ step: typeof projection.step === "number" ? projection.step : 0,
879
+ request: {
880
+ requestId: typeof request.requestId === "string" ? request.requestId : "",
881
+ provider: typeof request.provider === "string" ? request.provider : "",
882
+ model: typeof request.model === "string" ? request.model : "",
883
+ system: typeof excerpt.system === "string" ? excerpt.system : "",
884
+ messages: [],
885
+ tools: [],
886
+ ...(request.modelBinding ? { modelBinding: request.modelBinding } : {}),
887
+ },
888
+ };
889
+ }
890
+
720
891
  function isEventRange(
721
892
  value: unknown,
722
893
  ): value is { startSeq: number; endSeq: number } {