@ixo/oracle-runtime-workers 0.12.0 → 0.15.0

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.
Files changed (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +14 -9
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +198 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +1883 -264
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +523 -111
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +529 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
@@ -71,11 +71,24 @@ export const DEFAULT_TIER_MAX_SEGMENTS_PER_PASS = 64;
71
71
  * with a clear error rather than thrashing the clean cache.
72
72
  */
73
73
  export const DEFAULT_TIER_MISS_PIN_BYTES = 32 * 1024 * 1024;
74
- /** Orphan sweep (list + delete unreferenced keys) every this many maintenance runs. */
75
- const ORPHAN_SWEEP_EVERY = 16;
74
+ /**
75
+ * A complete orphan sweep (list the prefix, delete segment objects the map
76
+ * does not reference) at most this often. The time of the last complete
77
+ * sweep is persisted, so isolate restarts and object evictions do not
78
+ * postpone it. Orphans are rare (a crash between a segment upload and its
79
+ * map commit, a failed delete batch, deletes queued in memory by a
80
+ * truncate/import that never reached maintenance), so a weekly listing is
81
+ * enough: each sweep step is one R2 list (Class A).
82
+ */
83
+ export const DEFAULT_TIER_ORPHAN_SWEEP_INTERVAL_MS = 7 * 24 * 60 * 60_000;
84
+ /** Keys one sweep step lists (one R2 list page) — and so at most deletes. */
85
+ const SWEEP_STEP_KEYS = 1000;
76
86
  /** R2 `delete()` accepts up to 1000 keys per call. */
77
87
  const R2_DELETE_BATCH = 1000;
78
88
 
89
+ /** What `PageTier.maintenance` sweeps besides the queued deletes. */
90
+ export type SweepStep = 'none' | 'step' | 'full';
91
+
79
92
  export interface PageTierOptions {
80
93
  bucket: R2Bucket;
81
94
  /** Key prefix owned by this object (the DO id string); everything under it is ours to delete. */
@@ -86,6 +99,8 @@ export interface PageTierOptions {
86
99
  maxSegmentsPerPass?: number;
87
100
  /** Cold bytes one statement/transaction may pin while it is retried (default 32 MiB). */
88
101
  missPinBytes?: number;
102
+ /** Minimum time between complete orphan sweeps (default 7 days). */
103
+ orphanSweepIntervalMs?: number;
89
104
  /** Clock, injectable for tests. */
90
105
  now?: () => number;
91
106
  }
@@ -98,6 +113,7 @@ export interface ResolvedPageTierOptions {
98
113
  evictAfterPeriods: number;
99
114
  maxSegmentsPerPass: number;
100
115
  missPinBytes: number;
116
+ orphanSweepIntervalMs: number;
101
117
  now: () => number;
102
118
  }
103
119
 
@@ -114,6 +130,8 @@ export function resolvePageTierOptions(
114
130
  maxSegmentsPerPass:
115
131
  options.maxSegmentsPerPass ?? DEFAULT_TIER_MAX_SEGMENTS_PER_PASS,
116
132
  missPinBytes: options.missPinBytes ?? DEFAULT_TIER_MISS_PIN_BYTES,
133
+ orphanSweepIntervalMs:
134
+ options.orphanSweepIntervalMs ?? DEFAULT_TIER_ORPHAN_SWEEP_INTERVAL_MS,
117
135
  now: options.now ?? (() => Date.now()),
118
136
  };
119
137
  }
@@ -152,6 +170,11 @@ interface AccessRow extends Record<string, SqlStorageValue> {
152
170
  bits: ArrayBuffer;
153
171
  }
154
172
 
173
+ interface SweepRow extends Record<string, SqlStorageValue> {
174
+ last_complete_at: number | null;
175
+ after_key: string | null;
176
+ }
177
+
155
178
  export function segmentOf(chunkno: number): number {
156
179
  return Math.floor(chunkno / SEGMENT_CHUNKS);
157
180
  }
@@ -197,7 +220,6 @@ export class PageTier {
197
220
  private readonly segments = new Map<string, Map<number, SegmentEntry>>();
198
221
  private schemaReady = false;
199
222
  private readonly pendingDeletes = new Set<string>();
200
- private maintenanceRuns = 0;
201
223
  /** Tiny LRU of raw segment bytes for sequential readers (snapshot windows). */
202
224
  private readonly segmentCache = new Map<string, Uint8Array>();
203
225
  private static readonly SEGMENT_CACHE_ENTRIES = 2;
@@ -243,6 +265,11 @@ export class PageTier {
243
265
  this.storage.sql.exec(
244
266
  'CREATE TABLE IF NOT EXISTS vfs2_tier_meta (file TEXT PRIMARY KEY, max_gen INTEGER NOT NULL)',
245
267
  );
268
+ // Orphan sweep progress (one row): when the last complete sweep ended,
269
+ // and the last key a sweep still in progress has examined.
270
+ this.storage.sql.exec(
271
+ 'CREATE TABLE IF NOT EXISTS vfs2_tier_sweep (id INTEGER PRIMARY KEY CHECK (id = 0), last_complete_at INTEGER, after_key TEXT)',
272
+ );
246
273
  this.schemaReady = true;
247
274
  }
248
275
 
@@ -474,17 +501,12 @@ export class PageTier {
474
501
  // R2 (async)
475
502
  // ---------------------------------------------------------------------------
476
503
 
477
- /** The segment object bytes (exactly SEGMENT_BYTES, zero-padded), or null when the map has no entry. */
478
- async getSegment(file: string, segno: number): Promise<Uint8Array | null> {
479
- const entry = this.segmentsOf(file).get(segno);
480
- if (entry === undefined) return null;
481
- return this.getSegmentAt(file, segno, entry);
482
- }
483
-
484
504
  /**
485
- * The bytes of a specific generation of a segment — what a snapshot took
486
- * at open time even if the map has moved on since (the old object is
487
- * only deleted once no snapshot is open).
505
+ * The bytes (exactly SEGMENT_BYTES, zero-padded) of a specific generation
506
+ * of a segment — what a snapshot took at open time even if the map has
507
+ * moved on since (the old object is only deleted once no snapshot is
508
+ * open). Callers that need the CURRENT generation re-check the map after
509
+ * the await (see `DoVfs.currentSegment`).
488
510
  */
489
511
  async getSegmentAt(
490
512
  file: string,
@@ -543,13 +565,16 @@ export class PageTier {
543
565
  }
544
566
 
545
567
  /**
546
- * Delete queued keys (objects the map no longer references) and, every
547
- * `ORPHAN_SWEEP_EVERY` runs or when `sweep` is set, list the prefix and
548
- * delete anything the map does not reference — the leftovers of a crash
549
- * between a put and its map update. Never throws.
568
+ * Delete queued keys (objects the map no longer references), then sweep
569
+ * orphans: `'step'` examines one listing page of the prefix (continuing
570
+ * where the previous step stopped), `'full'` the whole prefix now. Only
571
+ * keys shaped like this tier's segment objects are ever swept — the
572
+ * prefix is shared with other users of the bucket (the result store
573
+ * keeps `<prefix>/results/…`). The CALLER guarantees no segment upload
574
+ * is in flight while a sweep runs (see `DoVfs`). Never throws.
550
575
  */
551
576
  async maintenance(
552
- opts: { sweep?: boolean } = {},
577
+ opts: { sweep?: SweepStep } = {},
553
578
  ): Promise<{ deleted: number }> {
554
579
  let deleted = 0;
555
580
  const keys = Array.from(this.pendingDeletes);
@@ -562,13 +587,14 @@ export class PageTier {
562
587
  `[page-tier] delete of ${keys.length} object(s) failed: ${errorMessage(err)}`,
563
588
  );
564
589
  }
565
- this.maintenanceRuns++;
566
- if (
567
- opts.sweep === true ||
568
- this.maintenanceRuns % ORPHAN_SWEEP_EVERY === 0
569
- ) {
590
+ const sweep = opts.sweep ?? 'none';
591
+ if (sweep !== 'none') {
570
592
  try {
571
- deleted += await this.sweepOrphans();
593
+ for (;;) {
594
+ const step = await this.sweepStep(sweep === 'full');
595
+ deleted += step.deleted;
596
+ if (sweep === 'step' || step.complete) break;
597
+ }
572
598
  } catch (err) {
573
599
  console.warn(`[page-tier] orphan sweep failed: ${errorMessage(err)}`);
574
600
  }
@@ -577,6 +603,89 @@ export class PageTier {
577
603
  return { deleted };
578
604
  }
579
605
 
606
+ /**
607
+ * Whether a sweep step is due: one is in progress, or the last complete
608
+ * sweep is older than `orphanSweepIntervalMs` (or there never was one).
609
+ */
610
+ sweepDue(now = this.options.now()): boolean {
611
+ const state = this.sweepState();
612
+ if (state.after_key !== null) return true;
613
+ return (
614
+ state.last_complete_at === null ||
615
+ now - state.last_complete_at >= this.options.orphanSweepIntervalMs
616
+ );
617
+ }
618
+
619
+ private sweepState(): SweepRow {
620
+ this.ensureSchema();
621
+ return (
622
+ this.storage.sql
623
+ .exec<SweepRow>(
624
+ 'SELECT last_complete_at, after_key FROM vfs2_tier_sweep WHERE id = 0',
625
+ )
626
+ .toArray()[0] ?? { last_complete_at: null, after_key: null }
627
+ );
628
+ }
629
+
630
+ /**
631
+ * One listing page of the prefix: delete the segment-shaped keys the map
632
+ * does not reference and persist how far the sweep got. `restart` starts
633
+ * from the beginning of the prefix (a full sweep).
634
+ */
635
+ private async sweepStep(
636
+ restart: boolean,
637
+ ): Promise<{ deleted: number; complete: boolean }> {
638
+ const afterKey = restart ? null : this.sweepState().after_key;
639
+ const page = await this.options.bucket.list({
640
+ prefix: `${this.options.prefix}/`,
641
+ limit: SWEEP_STEP_KEYS,
642
+ ...(afterKey !== null && { startAfter: afterKey }),
643
+ });
644
+ this.stats.r2Lists++;
645
+ // The map is read after the listing: a segment committed meanwhile is
646
+ // referenced by then (and uploads in flight are excluded by the caller).
647
+ const referenced = this.referencedKeys();
648
+ const orphans = page.objects
649
+ .map((object) => object.key)
650
+ .filter((key) => this.isSegmentKey(key) && !referenced.has(key));
651
+ const deleted = await this.deleteKeys(orphans);
652
+ const lastKey = page.objects.at(-1)?.key;
653
+ const complete = !page.truncated || lastKey === undefined;
654
+ if (complete) {
655
+ this.storage.sql.exec(
656
+ 'INSERT INTO vfs2_tier_sweep (id, last_complete_at, after_key) VALUES (0, ?, NULL) ON CONFLICT(id) DO UPDATE SET last_complete_at = excluded.last_complete_at, after_key = NULL',
657
+ this.options.now(),
658
+ );
659
+ } else {
660
+ this.storage.sql.exec(
661
+ 'INSERT INTO vfs2_tier_sweep (id, last_complete_at, after_key) VALUES (0, NULL, ?) ON CONFLICT(id) DO UPDATE SET after_key = excluded.after_key',
662
+ lastKey,
663
+ );
664
+ }
665
+ return { deleted, complete };
666
+ }
667
+
668
+ /** Keys of every segment object the map references, across files. */
669
+ private referencedKeys(): Set<string> {
670
+ this.ensureSchema();
671
+ const referenced = new Set<string>();
672
+ const rows = this.storage.sql
673
+ .exec<
674
+ SegmentRow & { file: string }
675
+ >('SELECT file, segno, gen, mask FROM vfs2_tier_segments')
676
+ .toArray();
677
+ for (const row of rows)
678
+ referenced.add(this.key(row.file, row.segno, row.gen));
679
+ return referenced;
680
+ }
681
+
682
+ /** `<prefix>/<file>/<segno>.<gen>` — the only keys a sweep may delete. */
683
+ private isSegmentKey(key: string): boolean {
684
+ const head = `${this.options.prefix}/`;
685
+ if (!key.startsWith(head)) return false;
686
+ return /^.+\/\d+\.\d+$/.test(key.slice(head.length));
687
+ }
688
+
580
689
  /** Every object under the prefix (keys). */
581
690
  async listKeys(): Promise<string[]> {
582
691
  const keys: string[] = [];
@@ -593,22 +702,6 @@ export class PageTier {
593
702
  return keys;
594
703
  }
595
704
 
596
- private async sweepOrphans(): Promise<number> {
597
- const referenced = new Set<string>();
598
- this.ensureSchema();
599
- const rows = this.storage.sql
600
- .exec<
601
- SegmentRow & { file: string }
602
- >('SELECT file, segno, gen, mask FROM vfs2_tier_segments')
603
- .toArray();
604
- for (const row of rows)
605
- referenced.add(this.key(row.file, row.segno, row.gen));
606
- const orphans = (await this.listKeys()).filter(
607
- (key) => !referenced.has(key),
608
- );
609
- return this.deleteKeys(orphans);
610
- }
611
-
612
705
  /** Delete everything under the prefix (the object's working copy is being wiped). */
613
706
  async deleteAll(): Promise<number> {
614
707
  this.pendingDeletes.clear();
@@ -12,6 +12,20 @@
12
12
  * and LangGraph `Send` objects. `stringify` is fast-safe-stringify's
13
13
  * circular-safe variant, typed.
14
14
  */
15
+ import { TURN_TIME_NOTE_KWARG } from '../core/turn-time-note';
16
+
17
+ /**
18
+ * `additional_kwargs` key of the tool calls a mid-turn summary carries for
19
+ * the turn it condensed (core/middlewares/turn-boundary.ts).
20
+ */
21
+ export const TURN_CARRY_KWARG = 'turn_carry';
22
+
23
+ /**
24
+ * JSON characters of a carry kept on a stored message. Each carried result
25
+ * is already cut short by the summarizer; the cap bounds how many calls a
26
+ * long turn can pile onto one row. The newest calls are kept.
27
+ */
28
+ export const TURN_CARRY_MAX_CHARS = 64 * 1024;
15
29
 
16
30
  type Replacer = (this: unknown, key: string, value: unknown) => unknown;
17
31
 
@@ -243,6 +257,10 @@ export interface CleanAdditionalKwargs {
243
257
  oracleName: string;
244
258
  /** Provenance marker set by LangChain middlewares (e.g. `"summarization"`). */
245
259
  lc_source?: string;
260
+ /** The exact time note the turn's content starts with (see core/turn-time-note.ts). */
261
+ turn_time_note?: string;
262
+ /** Tool calls of the turn a mid-turn summary condensed (newest kept, capped). */
263
+ turn_carry?: unknown[];
246
264
  reasoning?: string;
247
265
  reasoningDetails?: ReasoningDetail[];
248
266
  attachment?: AttachmentMeta;
@@ -302,6 +320,21 @@ function extractReasoning(additionalKwargs: Record<string, unknown>): {
302
320
  : { reasoning };
303
321
  }
304
322
 
323
+ /** The newest entries of a carry list whose JSON fits `TURN_CARRY_MAX_CHARS`. */
324
+ function cappedTurnCarry(value: unknown): unknown[] | undefined {
325
+ if (!Array.isArray(value)) return undefined;
326
+ const kept: unknown[] = [];
327
+ let chars = 2; // the brackets
328
+ for (let i = value.length - 1; i >= 0; i--) {
329
+ const entry: unknown = value[i];
330
+ const size = (JSON.stringify(entry) ?? '').length + (kept.length ? 1 : 0);
331
+ if (chars + size > TURN_CARRY_MAX_CHARS) break;
332
+ chars += size;
333
+ kept.unshift(entry);
334
+ }
335
+ return kept;
336
+ }
337
+
305
338
  /**
306
339
  * Reduce a message's `additional_kwargs` to the persisted allowlist. An
307
340
  * existing `timestamp` is preserved: every checkpoint `put` rewrites every
@@ -324,6 +357,13 @@ export function cleanAdditionalKwargs(
324
357
  };
325
358
  if (typeof additionalKwargs.lc_source === 'string')
326
359
  cleaned.lc_source = additionalKwargs.lc_source;
360
+ // Kept so readers can take the note off the stored content again.
361
+ const turnTimeNote = additionalKwargs[TURN_TIME_NOTE_KWARG];
362
+ if (typeof turnTimeNote === 'string')
363
+ cleaned[TURN_TIME_NOTE_KWARG] = turnTimeNote;
364
+ // Kept so a run resumed after the summary still knows what this turn ran.
365
+ const carry = cappedTurnCarry(additionalKwargs[TURN_CARRY_KWARG]);
366
+ if (carry !== undefined) cleaned[TURN_CARRY_KWARG] = carry;
327
367
  if (isAttachmentMeta(additionalKwargs.attachment))
328
368
  cleaned.attachment = additionalKwargs.attachment;
329
369
  if (
@@ -201,15 +201,23 @@ export class SessionsStore {
201
201
  params.push(excludeIdPrefix.length, excludeIdPrefix);
202
202
  }
203
203
  const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
204
- params.push(limit, offset);
205
204
  const rows = await this.db.exec<SessionRowWithTotal>(
206
205
  `SELECT ${SESSION_COLUMNS}, COUNT(*) OVER() AS total
207
206
  FROM sessions ${where}
208
207
  ORDER BY last_updated_at DESC, rowid DESC
209
208
  LIMIT ? OFFSET ?`,
209
+ [...params, limit, offset],
210
+ );
211
+ const first = rows[0];
212
+ if (first !== undefined)
213
+ return { sessions: rows.map(rowToSession), total: first.total };
214
+ // A page past the last row carries no window count: count separately.
215
+ if (offset <= 0) return { sessions: [], total: 0 };
216
+ const count = await this.db.get<{ n: number }>(
217
+ `SELECT COUNT(*) AS n FROM sessions ${where}`,
210
218
  params,
211
219
  );
212
- return { sessions: rows.map(rowToSession), total: rows[0]?.total ?? 0 };
220
+ return { sessions: [], total: Number(count?.n ?? 0) };
213
221
  }
214
222
 
215
223
  async getSession(sessionId: string): Promise<SessionRecord | undefined> {
@@ -19,6 +19,7 @@
19
19
  * - `channel_values.messages` is stripped from the stored checkpoint and each
20
20
  * message is upserted into `messages` (keyed by message id, so the table is
21
21
  * the full transcript: rows survive checkpoint pruning and summarization).
22
+ * An unchanged message keeps its row in place (see `writeMessageRows`).
22
23
  * - `put` prunes a thread to `maxCheckpointsPerThread` newest checkpoints (and
23
24
  * their writes) once it exceeds the cap by `PRUNE_SLACK`.
24
25
  * - `pending_sends` migration for pre-v4 checkpoints.
@@ -39,6 +40,7 @@ import {
39
40
  type CheckpointMetadata,
40
41
  type CheckpointTuple,
41
42
  copyCheckpoint,
43
+ emptyCheckpoint,
42
44
  maxChannelVersion,
43
45
  type PendingWrite,
44
46
  type SerializerProtocol,
@@ -78,6 +80,9 @@ export const PRUNE_SLACK = 5;
78
80
 
79
81
  const DEFAULT_ORACLE_NAME = 'IXO Oracle';
80
82
 
83
+ /** Position of `message_id` in the parameter rows `toMessageRow` returns. */
84
+ const MESSAGE_ID_COLUMN = 3;
85
+
81
86
  type CheckpointRow = {
82
87
  thread_id: string;
83
88
  checkpoint_ns: string;
@@ -137,7 +142,12 @@ interface CheckpointWithMessages extends Checkpoint {
137
142
  * Compile-time guard: the metadata keys `list()` accepts as filters must stay
138
143
  * in sync with `CheckpointMetadata`. If this stops compiling, update the list.
139
144
  */
140
- const checkpointMetadataKeys = ['source', 'step', 'parents'] as const;
145
+ const checkpointMetadataKeys = [
146
+ 'source',
147
+ 'step',
148
+ 'parents',
149
+ 'counters_since_delta_snapshot',
150
+ ] as const;
141
151
  type MetadataKey = keyof CheckpointMetadata;
142
152
  type CheckKeys<K extends readonly MetadataKey[]> = [MetadataKey] extends [
143
153
  K[number],
@@ -176,7 +186,23 @@ const migration001: Migration = {
176
186
  },
177
187
  };
178
188
 
179
- const MIGRATIONS: readonly Migration[] = [migration001];
189
+ /**
190
+ * Migration 002: drop `idx_messages_thread_id` and `idx_messages_checkpoint_id`.
191
+ * No query uses them — every `messages` query filters on `thread_id` (served
192
+ * by `idx_messages_thread_created` / `idx_messages_lookup`, whose leading
193
+ * column it is) or looks a row up by `message_id` — yet every message write
194
+ * updated both b-trees.
195
+ */
196
+ const migration002: Migration = {
197
+ version: 2,
198
+ name: 'drop_unused_message_indexes',
199
+ up: async (db) => {
200
+ await db.run('DROP INDEX IF EXISTS idx_messages_thread_id');
201
+ await db.run('DROP INDEX IF EXISTS idx_messages_checkpoint_id');
202
+ },
203
+ };
204
+
205
+ const MIGRATIONS: readonly Migration[] = [migration001, migration002];
180
206
 
181
207
  // `writes.value` blobs may be gzipped, so they travel as hex() — a
182
208
  // CAST(... AS TEXT) of compressed bytes would mangle them irreversibly.
@@ -228,6 +254,17 @@ export class SqliteSaver extends BaseCheckpointSaver {
228
254
  protected readonly maxCheckpointsPerThread: number;
229
255
  protected readonly oracleName: string;
230
256
  private setupPromise: Promise<void> | undefined;
257
+ /**
258
+ * The stored blob of each message object last encoded, with the JSON it
259
+ * was compressed from. LangGraph hands the same message objects to every
260
+ * `put` of a run; one whose JSON is unchanged reuses its blob instead of
261
+ * running a gzip pipeline again. The JSON itself is always recomputed —
262
+ * it is what proves the object did not change.
263
+ */
264
+ private readonly encoded = new WeakMap<
265
+ BaseMessage,
266
+ { json: string; blob: Uint8Array }
267
+ >();
231
268
 
232
269
  constructor(
233
270
  db: DoSqliteDatabase,
@@ -298,12 +335,6 @@ export class SqliteSaver extends BaseCheckpointSaver {
298
335
  PRIMARY KEY (message_id)
299
336
  )`);
300
337
  await this.runMigrations();
301
- await db.run(
302
- `CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(thread_id)`,
303
- );
304
- await db.run(
305
- `CREATE INDEX IF NOT EXISTS idx_messages_checkpoint_id ON messages(checkpoint_id)`,
306
- );
307
338
  await db.run(
308
339
  `CREATE INDEX IF NOT EXISTS idx_messages_lookup ON messages(thread_id, checkpoint_ns, checkpoint_id)`,
309
340
  );
@@ -637,8 +668,10 @@ export class SqliteSaver extends BaseCheckpointSaver {
637
668
  checkpointNs: string,
638
669
  checkpointId: string,
639
670
  ): Promise<BaseMessage[]> {
671
+ // Rowid order is the order of the checkpoint's message array (see
672
+ // `writeMessageRows`); the lookup index already yields it, no sort.
640
673
  const rows = await this.db.exec<MessageRow>(
641
- 'SELECT message FROM messages WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?',
674
+ 'SELECT message FROM messages WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? ORDER BY rowid',
642
675
  [threadId, checkpointNs, checkpointId],
643
676
  );
644
677
  return Promise.all(rows.map((row) => this.loadMessage(row.message)));
@@ -696,6 +729,39 @@ export class SqliteSaver extends BaseCheckpointSaver {
696
729
  }
697
730
  }
698
731
 
732
+ /** Append a deterministic turn without running or replacing pending graph work. */
733
+ async appendTurnMessages(
734
+ threadId: string,
735
+ messages: BaseMessage[],
736
+ ): Promise<void> {
737
+ const config = { configurable: { thread_id: threadId } };
738
+ const existing = await this.getTupleWithoutMessages(config);
739
+ if (!existing) {
740
+ const checkpoint = emptyCheckpoint();
741
+ checkpoint.channel_values.messages = messages;
742
+ await this.put(config, checkpoint, {
743
+ source: 'update',
744
+ step: -1,
745
+ parents: {},
746
+ });
747
+ return;
748
+ }
749
+ const rows = await Promise.all(
750
+ messages.map((message) =>
751
+ this.toMessageRow(message, threadId, '', existing.checkpoint.id),
752
+ ),
753
+ );
754
+ await this.db.transaction(async () => {
755
+ for (const row of rows) {
756
+ await this.db.run(
757
+ `INSERT OR IGNORE INTO messages (thread_id, checkpoint_ns, checkpoint_id, message_id, message_type, message_content, message, created_at)
758
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
759
+ row,
760
+ );
761
+ }
762
+ });
763
+ }
764
+
699
765
  async put(
700
766
  config: RunnableConfig,
701
767
  checkpointIn: Checkpoint,
@@ -748,13 +814,7 @@ export class SqliteSaver extends BaseCheckpointSaver {
748
814
  serializedMetadata,
749
815
  ],
750
816
  );
751
- for (const row of messageRows) {
752
- await this.db.run(
753
- `INSERT OR REPLACE INTO messages (thread_id, checkpoint_ns, checkpoint_id, message_id, message_type, message_content, message, created_at)
754
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
755
- row,
756
- );
757
- }
817
+ await this.writeMessageRows(messageRows);
758
818
  });
759
819
 
760
820
  await this.pruneThread(threadId, checkpointNs);
@@ -768,6 +828,60 @@ export class SqliteSaver extends BaseCheckpointSaver {
768
828
  };
769
829
  }
770
830
 
831
+ /**
832
+ * Upsert a checkpoint's message rows (in array order). A checkpoint's
833
+ * messages are read back in rowid order (`loadCheckpointMessages`), so
834
+ * rowids must increase along the array. A message whose row exists keeps
835
+ * it — updated in place, which leaves the pages whose bytes did not
836
+ * change untouched — as long as that holds; from the first message that
837
+ * is new or would break the order (a summary placed before the history
838
+ * it kept) on, every row is written with `INSERT OR REPLACE`, which
839
+ * gives it a new rowid after all others, exactly as every row used to be.
840
+ * Inside the caller's transaction.
841
+ */
842
+ private async writeMessageRows(rows: SqlParam[][]): Promise<void> {
843
+ if (rows.length === 0) return;
844
+ const ids = rows.map((row) => row[MESSAGE_ID_COLUMN]);
845
+ const existing = new Map<string, number>();
846
+ for (const row of await this.db.exec<{
847
+ message_id: string;
848
+ rowid: number;
849
+ }>(
850
+ 'SELECT m.message_id, m.rowid FROM json_each(?) AS j JOIN messages AS m ON m.message_id = j.value',
851
+ [JSON.stringify(ids)],
852
+ ))
853
+ existing.set(row.message_id, row.rowid);
854
+ let lastRowid = -1;
855
+ let appending = false;
856
+ for (const row of rows) {
857
+ const id = row[MESSAGE_ID_COLUMN];
858
+ const rowid = typeof id === 'string' ? existing.get(id) : undefined;
859
+ if (!appending && rowid !== undefined && rowid > lastRowid) {
860
+ await this.db.run(
861
+ `INSERT INTO messages (thread_id, checkpoint_ns, checkpoint_id, message_id, message_type, message_content, message, created_at)
862
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
863
+ ON CONFLICT(message_id) DO UPDATE SET
864
+ thread_id = excluded.thread_id,
865
+ checkpoint_ns = excluded.checkpoint_ns,
866
+ checkpoint_id = excluded.checkpoint_id,
867
+ message_type = excluded.message_type,
868
+ message_content = excluded.message_content,
869
+ message = excluded.message,
870
+ created_at = excluded.created_at`,
871
+ row,
872
+ );
873
+ lastRowid = rowid;
874
+ continue;
875
+ }
876
+ appending = true;
877
+ await this.db.run(
878
+ `INSERT OR REPLACE INTO messages (thread_id, checkpoint_ns, checkpoint_id, message_id, message_type, message_content, message, created_at)
879
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
880
+ row,
881
+ );
882
+ }
883
+ }
884
+
771
885
  /** Normalize a message's `additional_kwargs` and serialize it exactly as the Node runtime does. */
772
886
  private async toMessageRow(
773
887
  message: BaseMessage,
@@ -798,9 +912,15 @@ export class SqliteSaver extends BaseCheckpointSaver {
798
912
  }
799
913
  message.additional_kwargs = merged;
800
914
 
801
- const serialized = await compressForStorage(
802
- stringify(message, (_key, value) => _default(value)),
803
- );
915
+ const json = stringify(message, (_key, value) => _default(value));
916
+ const cached = this.encoded.get(message);
917
+ let serialized: Uint8Array;
918
+ if (cached !== undefined && cached.json === json) {
919
+ serialized = cached.blob;
920
+ } else {
921
+ serialized = await compressForStorage(json);
922
+ this.encoded.set(message, { json, blob: serialized });
923
+ }
804
924
  const messageId =
805
925
  message.id ??
806
926
  (typeof message.lc_kwargs.id === 'string'