@ixo/oracle-runtime-workers 0.37.2 → 0.38.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.
@@ -268,10 +268,13 @@ export function markPlatformFallbackFailure(error: unknown): void {
268
268
  if (error && typeof error === 'object') PLATFORM_FALLBACK_FAILURES.add(error);
269
269
  }
270
270
 
271
- /** The error, or one it wraps (`cause`, as LangChain's MiddlewareError does). */
271
+ /**
272
+ * The error, or one it wraps (`cause`, as LangChain's MiddlewareError does,
273
+ * once per middleware layer).
274
+ */
272
275
  function isPlatformFallbackFailure(error: unknown): boolean {
273
276
  let current: unknown = error;
274
- for (let depth = 0; depth < 8; depth += 1) {
277
+ for (let depth = 0; depth < 32; depth += 1) {
275
278
  if (!current || typeof current !== 'object') return false;
276
279
  if (PLATFORM_FALLBACK_FAILURES.has(current)) return true;
277
280
  current = 'cause' in current ? current.cause : undefined;
@@ -83,11 +83,12 @@ export class ProviderStallError extends Error {
83
83
  /**
84
84
  * The `ProviderStallError` in `error` or its `cause` chain — the OpenAI SDK
85
85
  * wraps a failed `fetch` in `APIConnectionError` with the original as
86
- * `cause`, LangChain's middleware errors wrap again.
86
+ * `cause`, LangChain's middleware errors wrap again, once per middleware
87
+ * layer (a dozen on the main agent), so the walk goes deep.
87
88
  */
88
89
  export function findProviderStall(error: unknown): ProviderStallError | null {
89
90
  let current: unknown = error;
90
- for (let depth = 0; depth < 8; depth += 1) {
91
+ for (let depth = 0; depth < 32; depth += 1) {
91
92
  if (current instanceof ProviderStallError) return current;
92
93
  if (!current || typeof current !== 'object' || !('cause' in current))
93
94
  return null;
@@ -24,9 +24,11 @@ import {
24
24
  listPendingSends,
25
25
  MatrixBotDO,
26
26
  optionsFromEnv,
27
+ parseEncryptedFileInfo,
27
28
  Semaphore,
28
29
  withRateLimitRetry,
29
30
  type BotMessage,
31
+ type EncryptedFileInfo,
30
32
  type BotTimelineEvent,
31
33
  type MatrixBotOptions,
32
34
  type StartResult,
@@ -37,6 +39,7 @@ import {
37
39
  userOracleRoomAlias,
38
40
  type BotCredentials,
39
41
  type CreateRoomOptions,
42
+ type EventMediaInfo,
40
43
  type GatewayStatus,
41
44
  type GroupRoomInfo,
42
45
  type JsonString,
@@ -99,11 +102,7 @@ import {
99
102
  } from './user-homeserver';
100
103
  import { replyPartTxnId, roomReplyMessages } from './reply-parts';
101
104
  import { VerifiedRoomAliases } from './room-alias';
102
- import {
103
- boundedSignal,
104
- raceSignal,
105
- streamWithDeadline,
106
- } from './media-deadline';
105
+ import { boundedSignal, raceSignal } from './media-deadline';
107
106
 
108
107
  const TYPING_REFRESH_MS = 20_000;
109
108
  const TYPING_TIMEOUT_MS = 30_000;
@@ -122,11 +121,19 @@ const MEMO_MAX_ENTRIES = 5_000;
122
121
  */
123
122
  const STATUS_CACHE_MS = 5_000;
124
123
  /**
125
- * Upper bound on one media download (response and body), unless the caller
126
- * asks for less. A stalled homeserver stream must not hold the reader — and
127
- * with it the user's turn — open indefinitely.
124
+ * Upper bound on one media event lookup (`eventMediaInfo`), unless the caller
125
+ * asks for less. A stalled homeserver must not hold the user's turn open
126
+ * indefinitely.
128
127
  */
129
128
  export const MEDIA_DOWNLOAD_TIMEOUT_MS = 60_000;
129
+ /**
130
+ * `sendEvent` calls with `priority: 'background'` (channel text mirrors and
131
+ * file events) in the SDK's send path at once. The SDK 0.7.0 `sendEvent`
132
+ * dispatches every event `interactive`, so this lane is what keeps a burst
133
+ * of them from queueing ahead of replies in the rate-limited sender: at most
134
+ * this many sit there, the rest wait here.
135
+ */
136
+ export const BACKGROUND_SEND_CONCURRENCY = 2;
130
137
  /**
131
138
  * A live message whose sender's homeserver could not be looked up stays in
132
139
  * the inbox and is replayed on its own after this delay (each replay is
@@ -184,6 +191,90 @@ interface OracleGatewayConfig {
184
191
  oracleRoomDid: string;
185
192
  }
186
193
 
194
+ /** A JSON object's own property, or undefined. */
195
+ function prop(value: unknown, name: string): unknown {
196
+ return typeof value === 'object' && value !== null && name in value
197
+ ? Reflect.get(value, name)
198
+ : undefined;
199
+ }
200
+
201
+ /** Whether event content (JSON) carries an attachment key (`file.key`, `EncryptedFile`). */
202
+ function carriesAttachmentKey(content: JsonString): boolean {
203
+ let parsed: unknown;
204
+ try {
205
+ parsed = JSON.parse(content);
206
+ } catch {
207
+ // The SDK refuses content that is not a JSON object.
208
+ return false;
209
+ }
210
+ return prop(prop(parsed, 'file'), 'key') !== undefined;
211
+ }
212
+
213
+ /**
214
+ * The `EncryptedFile` fields of an event's `file`, validated
215
+ * (`parseEncryptedFileInfo` throws on a malformed one) and rebuilt from the
216
+ * checked values, without `url`, `mimetype` and `size`.
217
+ */
218
+ function encryptedFileOf(file: unknown): EncryptedFileInfo {
219
+ parseEncryptedFileInfo(file);
220
+ const key = prop(file, 'key');
221
+ const k = prop(key, 'k');
222
+ const iv = prop(file, 'iv');
223
+ const sha256 = prop(prop(file, 'hashes'), 'sha256');
224
+ if (
225
+ typeof k !== 'string' ||
226
+ typeof iv !== 'string' ||
227
+ typeof sha256 !== 'string'
228
+ )
229
+ throw new Error('encrypted attachment: malformed EncryptedFile');
230
+ const ops = prop(key, 'key_ops');
231
+ return {
232
+ v: 'v2',
233
+ key: {
234
+ kty: 'oct',
235
+ key_ops: Array.isArray(ops)
236
+ ? ops.filter((op): op is string => typeof op === 'string')
237
+ : ['encrypt', 'decrypt'],
238
+ alg: 'A256CTR',
239
+ k,
240
+ ext: true,
241
+ },
242
+ iv,
243
+ hashes: { sha256 },
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Where a decrypted media event's file lives (`eventMediaInfo`): null for a
249
+ * redaction, a redacted (empty) event or one without an `mxc://` URL.
250
+ */
251
+ export function mediaInfoOfEvent(
252
+ event: unknown,
253
+ eventId: string,
254
+ ): EventMediaInfo | null {
255
+ if (prop(event, 'type') === 'm.room.redaction') return null;
256
+ const content = prop(event, 'content');
257
+ if (typeof content !== 'object' || content === null) return null;
258
+ if (Object.keys(content).length === 0) return null;
259
+ const file = prop(content, 'file');
260
+ const mxc = file !== undefined ? prop(file, 'url') : prop(content, 'url');
261
+ if (typeof mxc !== 'string' || !mxc.startsWith('mxc://'))
262
+ throw new Error(
263
+ `MatrixGatewayDO: media event ${eventId} carries no mxc url`,
264
+ );
265
+ const info = prop(content, 'info');
266
+ const mimetype = prop(info, 'mimetype');
267
+ const size = prop(info, 'size');
268
+ const filename = prop(content, 'filename') ?? prop(content, 'body');
269
+ return {
270
+ mxc,
271
+ ...(file !== undefined ? { file: encryptedFileOf(file) } : {}),
272
+ ...(typeof mimetype === 'string' ? { mimetype } : {}),
273
+ ...(typeof filename === 'string' ? { filename } : {}),
274
+ ...(typeof size === 'number' ? { size } : {}),
275
+ };
276
+ }
277
+
187
278
  /** The plugins' device credentials as stored (`identity:bot-client`). */
188
279
  interface StoredDevice {
189
280
  userId: string;
@@ -197,9 +288,9 @@ interface AliasCacheEntry {
197
288
  at: number;
198
289
  }
199
290
 
200
- /** A media download's bound. `signal` only works for a caller in this isolate: an `AbortSignal` cannot cross an RPC boundary. */
291
+ /** A media event lookup's bound. `signal` only works for a caller in this isolate: an `AbortSignal` cannot cross an RPC boundary. */
201
292
  export interface MediaDownloadOptions {
202
- /** Upper bound on the whole transfer (default `MEDIA_DOWNLOAD_TIMEOUT_MS`). */
293
+ /** Upper bound on the whole lookup (default `MEDIA_DOWNLOAD_TIMEOUT_MS`). */
203
294
  timeoutMs?: number;
204
295
  signal?: AbortSignal;
205
296
  }
@@ -371,6 +462,9 @@ export class MatrixGatewayDO
371
462
  gateSizeFromEnv(this.env.MATRIX_TURN_CONCURRENCY, DEFAULT_TURN_CONCURRENCY),
372
463
  );
373
464
  private pluginDevice: BotCredentials | null = null;
465
+ /** The plugin device lookup in flight: concurrent first callers share one `whoami` / login. */
466
+ private pluginDeviceLoad: Promise<BotCredentials> | null = null;
467
+ private readonly backgroundSends = new Semaphore(BACKGROUND_SEND_CONCURRENCY);
374
468
  /** Memoised `getOracleSecretsKey` result (private JWK JSON). */
375
469
  private oracleSecretsKeyJson: string | null = null;
376
470
  private oracleSigningMnemonic: string | null = null;
@@ -1535,7 +1629,7 @@ export class MatrixGatewayDO
1535
1629
  }
1536
1630
 
1537
1631
  // -------------------------------------------------------------------------
1538
- // User snapshots (SQLite files as room media) and media downloads
1632
+ // User snapshots (SQLite files as room media) and media event lookups
1539
1633
  // -------------------------------------------------------------------------
1540
1634
 
1541
1635
  async uploadUserSnapshotStream(
@@ -1648,105 +1742,28 @@ export class MatrixGatewayDO
1648
1742
  }
1649
1743
 
1650
1744
  /**
1651
- * A media message (`m.image` / `m.file` / …) for the attachments pipeline,
1652
- * as stored (see `mediaStream`). Null when the event is unknown or redacted.
1745
+ * See `MatrixGatewayObject.eventMediaInfo`: the media event fetched and
1746
+ * decrypted here, its file left on the homeserver for the caller to read.
1653
1747
  */
1654
- async downloadEventMediaStream(
1748
+ async eventMediaInfo(
1655
1749
  roomId: string,
1656
1750
  eventId: string,
1657
1751
  opts: MediaDownloadOptions = {},
1658
- ): Promise<MediaStream | null> {
1752
+ ): Promise<EventMediaInfo | null> {
1659
1753
  const bound = boundedSignal(
1660
1754
  opts.timeoutMs ?? MEDIA_DOWNLOAD_TIMEOUT_MS,
1661
1755
  opts.signal,
1662
1756
  );
1663
- let media: MediaStream | null;
1664
1757
  try {
1665
- // The SDK opens the download without a signal: race it, and release a
1666
- // stream that arrives after the bound.
1667
- media = await raceSignal(
1668
- this.mediaStream(roomId, eventId),
1758
+ const raw = await raceSignal(
1759
+ this.getEvent(roomId, eventId),
1669
1760
  bound.signal,
1670
- (late) => void late?.stream.cancel().catch(() => undefined),
1761
+ () => undefined,
1671
1762
  );
1672
- } catch (err) {
1673
- bound.clear();
1674
- throw err;
1675
- }
1676
- if (!media) {
1677
- bound.clear();
1678
- return null;
1679
- }
1680
- return { ...media, stream: streamWithDeadline(media.stream, bound) };
1681
- }
1682
-
1683
- /**
1684
- * Authenticated download of an `mxc://` URI as a stream
1685
- * (`/_matrix/client/v1/media/download`, legacy fallback on 404), bounded
1686
- * by `opts.timeoutMs` (default `MEDIA_DOWNLOAD_TIMEOUT_MS`) from the
1687
- * request to the last byte.
1688
- */
1689
- async downloadMxcMediaStream(
1690
- mxc: string,
1691
- opts: MediaDownloadOptions = {},
1692
- ): Promise<ReadableStream<Uint8Array>> {
1693
- const bound = boundedSignal(
1694
- opts.timeoutMs ?? MEDIA_DOWNLOAD_TIMEOUT_MS,
1695
- opts.signal,
1696
- );
1697
- try {
1698
- const body = await this.fetchMxc(mxc, bound.signal);
1699
- return streamWithDeadline(body, bound);
1700
- } catch (err) {
1763
+ return raw ? mediaInfoOfEvent(JSON.parse(raw), eventId) : null;
1764
+ } finally {
1701
1765
  bound.clear();
1702
- throw err;
1703
- }
1704
- }
1705
-
1706
- private async fetchMxc(
1707
- mxc: string,
1708
- signal: AbortSignal,
1709
- ): Promise<ReadableStream<Uint8Array>> {
1710
- const client = await this.startedClient();
1711
- const token = client.getAccessToken();
1712
- const headers: Record<string, string> = token
1713
- ? { Authorization: `Bearer ${token}` }
1714
- : {};
1715
- const authed = client.mxcUrlToHttp(
1716
- mxc,
1717
- undefined,
1718
- undefined,
1719
- undefined,
1720
- false,
1721
- true,
1722
- true,
1723
- );
1724
- if (!authed) throw new Error(`MatrixGatewayDO: invalid mxc url ${mxc}`);
1725
- let res = await fetch(authed, { headers, signal });
1726
- if (res.status === 404) {
1727
- const legacy = client.mxcUrlToHttp(
1728
- mxc,
1729
- undefined,
1730
- undefined,
1731
- undefined,
1732
- false,
1733
- true,
1734
- false,
1735
- );
1736
- if (legacy) {
1737
- await res.body?.cancel().catch(() => undefined);
1738
- res = await fetch(legacy, { headers, signal });
1739
- }
1740
- }
1741
- if (!res.ok) {
1742
- await res.body?.cancel().catch(() => undefined);
1743
- throw new Error(
1744
- `MatrixGatewayDO: media download failed: ${res.status} ${res.statusText}`,
1745
- );
1746
1766
  }
1747
- if (!res.body)
1748
- throw new Error('MatrixGatewayDO: media download has no body');
1749
- return res.body;
1750
1767
  }
1751
1768
 
1752
1769
  // -------------------------------------------------------------------------
@@ -1801,13 +1818,31 @@ export class MatrixGatewayDO
1801
1818
  /**
1802
1819
  * See `MatrixGatewayObject.botCredentials`: a second password device with
1803
1820
  * NO crypto store, for plugins that drive a raw matrix-js-sdk client
1804
- * (editor, flows) — it must never be the gateway's own E2EE device, whose
1805
- * one-time keys and store are the SDK's. Its id is pinned in storage so a
1806
- * re-login (token rejected) keeps the same device. Cached per instance
1807
- * after one `whoami`.
1821
+ * (editor, flows) and the user objects' media transfers — it must never
1822
+ * be the gateway's own E2EE device, whose one-time keys and store are the
1823
+ * SDK's. Its id is pinned in storage so a re-login keeps the same device.
1824
+ * Cached per instance after one `whoami`; concurrent callers share one
1825
+ * lookup (a login rotates the token, so a herd would invalidate each
1826
+ * other's). `rejected` drops the cache only when it is the cached token.
1808
1827
  */
1809
- async botCredentials(): Promise<BotCredentials> {
1828
+ async botCredentials(
1829
+ opts: { rejected?: string } = {},
1830
+ ): Promise<BotCredentials> {
1831
+ if (opts.rejected && this.pluginDevice?.accessToken === opts.rejected) {
1832
+ this.log(
1833
+ 'warn',
1834
+ 'plugin device token rejected by a caller; re-validating',
1835
+ );
1836
+ this.pluginDevice = null;
1837
+ }
1810
1838
  if (this.pluginDevice) return this.pluginDevice;
1839
+ this.pluginDeviceLoad ??= this.loadPluginDevice().finally(() => {
1840
+ this.pluginDeviceLoad = null;
1841
+ });
1842
+ return this.pluginDeviceLoad;
1843
+ }
1844
+
1845
+ private async loadPluginDevice(): Promise<BotCredentials> {
1811
1846
  const cfg = this.cfg();
1812
1847
  const stored = await this.ctx.storage.get<StoredDevice>(
1813
1848
  BOT_CLIENT_IDENTITY_KEY,
@@ -1841,6 +1876,33 @@ export class MatrixGatewayDO
1841
1876
  return this.pluginDevice;
1842
1877
  }
1843
1878
 
1879
+ /**
1880
+ * See `MatrixGatewayObject.sendEvent`: the SDK's send, behind the
1881
+ * background lane for `priority: 'background'`, and never an attachment
1882
+ * key into a room this gateway does not see as end-to-end encrypted.
1883
+ */
1884
+ override async sendEvent(
1885
+ roomId: string,
1886
+ type: string,
1887
+ content: JsonString,
1888
+ opts: { txnId?: string; priority?: 'interactive' | 'background' } = {},
1889
+ ): Promise<string> {
1890
+ if (carriesAttachmentKey(content) && !(await this.isRoomEncrypted(roomId)))
1891
+ throw new Error(
1892
+ `MatrixGatewayDO: refusing to send an encrypted attachment's key into ${roomId}, which is not end-to-end encrypted`,
1893
+ );
1894
+ const send = (): Promise<string> =>
1895
+ super.sendEvent(
1896
+ roomId,
1897
+ type,
1898
+ content,
1899
+ opts.txnId ? { txnId: opts.txnId } : {},
1900
+ );
1901
+ return opts.priority === 'background'
1902
+ ? this.backgroundSends.run(send)
1903
+ : send();
1904
+ }
1905
+
1844
1906
  // -------------------------------------------------------------------------
1845
1907
  // Operator surface
1846
1908
  // -------------------------------------------------------------------------
@@ -85,9 +85,10 @@ export interface IngestDeps {
85
85
  */
86
86
  userServerName(userDid: string): string | null;
87
87
  /**
88
- * The bot's Matrix user id. Its first occurrence in a message is rewritten
89
- * to `(USER MENTIONED YOU @AI_AGENT)` before the agent sees it, as the Node
90
- * bridge does; omit to leave messages as sent.
88
+ * The bot's Matrix user id. Its own messages are dropped (`'own'`), and its
89
+ * first occurrence in a message is rewritten to `(USER MENTIONED YOU
90
+ * @AI_AGENT)` before the agent sees it, as the Node bridge does; omit to
91
+ * leave messages as sent.
91
92
  */
92
93
  botUserId?: string;
93
94
  debounceMs?: number;
@@ -210,6 +211,7 @@ export type OfferOutcome =
210
211
  | 'queued'
211
212
  | 'unverified'
212
213
  | 'empty'
214
+ | 'own'
213
215
  | 'unmapped'
214
216
  | 'foreign';
215
217
 
@@ -229,6 +231,13 @@ export class IngestPipeline {
229
231
  * for none); omitted, `canonicalAlias` is asked.
230
232
  */
231
233
  offer(msg: InboundMessage, alias?: string | null): OfferOutcome {
234
+ // The oracle's own events — its room mirrors and the channel files it
235
+ // posts in a session thread — are never a turn. The bot SDK already
236
+ // drops them before `onMessage`; this holds on every path that offers a
237
+ // message here (inbox replays included), and keeps a DID-shaped bot
238
+ // account from being attributed to itself.
239
+ if (this.deps.botUserId !== undefined && msg.sender === this.deps.botUserId)
240
+ return 'own';
232
241
  if (!msg.body.trim() && !msg.attachment) return 'empty';
233
242
  const attribution = attributeUser({
234
243
  alias: alias === undefined ? this.deps.canonicalAlias(msg.roomId) : alias,
@@ -1,12 +1,9 @@
1
1
  /**
2
- * Deadlines for the gateway's media downloads. A homeserver that stops
3
- * sending mid-body would otherwise keep the reading side — a user object
4
- * assembling an attachment for a turn — waiting for as long as the
5
- * connection stays open.
6
- *
7
- * The bound covers the whole transfer: opening the download and every byte
8
- * of the body. When it passes (or the caller's own signal aborts), the
9
- * source is cancelled and the stream handed out errors with the reason.
2
+ * Deadlines for the gateway's media event lookups (`eventMediaInfo`). A
3
+ * homeserver that never answers would otherwise keep the asking side — a
4
+ * user object assembling an attachment for a turn — waiting for as long as
5
+ * the connection stays open. When the bound passes (or the caller's own
6
+ * signal aborts), the lookup rejects with the reason.
10
7
  */
11
8
 
12
9
  export interface BoundedSignal {
@@ -89,42 +86,3 @@ export function raceSignal<T>(
89
86
  );
90
87
  });
91
88
  }
92
-
93
- /**
94
- * `source` behind the deadline: the returned stream carries the same bytes,
95
- * and errors with the signal's reason (the source cancelled) when the bound
96
- * passes before the last byte. The timer is cleared once the transfer ends
97
- * either way, including when the reader cancels.
98
- */
99
- export function streamWithDeadline(
100
- source: ReadableStream<Uint8Array>,
101
- bound: BoundedSignal,
102
- ): ReadableStream<Uint8Array> {
103
- const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>();
104
- const writer = writable.getWriter();
105
- const reader = source.getReader();
106
- const stop = (reason: unknown): void => {
107
- void writer.abort(reason).catch(() => undefined);
108
- void reader.cancel(reason).catch(() => undefined);
109
- };
110
- const onAbort = (): void => stop(bound.signal.reason);
111
- if (bound.signal.aborted) onAbort();
112
- else bound.signal.addEventListener('abort', onAbort, { once: true });
113
- void (async () => {
114
- try {
115
- for (;;) {
116
- const { done, value } = await reader.read();
117
- if (done || bound.signal.aborted) break;
118
- await writer.write(value);
119
- }
120
- if (!bound.signal.aborted) await writer.close();
121
- } catch (err) {
122
- // The reader went away (cancelled) or the source failed: end both.
123
- stop(err);
124
- } finally {
125
- bound.signal.removeEventListener('abort', onAbort);
126
- bound.clear();
127
- }
128
- })();
129
- return readable;
130
- }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Attachments plugin — the agent-facing half of attachment payload
3
- * retention (`src/attachments/retention.ts`), Workers-only: the Node runtime
4
- * keeps every attachment inline for the life of a session.
2
+ * Attachments plugin — the agent-facing half of keeping attachment bytes
3
+ * out of storage (`src/attachments/retention.ts`), Workers-only: the Node
4
+ * runtime keeps every attachment inline for the life of a session.
5
5
  *
6
6
  * Contributes one request-time tool, `view_attachment`, and only on turns
7
7
  * whose session already has an offloaded payload (the host surface says so),
@@ -1,6 +1,7 @@
1
1
  /**
2
- * `view_attachment` — fetch an attachment of this session again after its
3
- * inline payload was offloaded (see `src/attachments/retention.ts`).
2
+ * `view_attachment` — fetch an attachment of this session again: its bytes
3
+ * are not stored with the conversation, the stored message holds a
4
+ * placeholder instead (see `src/attachments/retention.ts`).
4
5
  *
5
6
  * Two result shapes, decided by the host surface with the same routing as
6
7
  * the turn pipeline:
@@ -11,8 +12,9 @@
11
12
  * OpenAI-compatible wire, so the tool returns a LangGraph `Command` that
12
13
  * appends its tool message AND a human message holding the block — the
13
14
  * same proven shape a fresh attachment turn uses. That message is tagged
14
- * `lc_source: attachment_view`: transcripts hide it, retention treats it
15
- * as part of the current user turn and offloads it again later.
15
+ * `lc_source: attachment_view`: transcripts hide it, the checkpointer
16
+ * stores it with the placeholder like any other, and the image recall
17
+ * counts it with the user turn before it.
16
18
  */
17
19
  import { HumanMessage, ToolMessage } from '@langchain/core/messages';
18
20
  import { Command } from '@langchain/langgraph';
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The checkpointer's storage form of a value: every human message that
3
+ * carries inline attachment bytes (base64 content blocks, see
4
+ * `src/attachments/retention.ts`) is replaced by its placeholder copy
5
+ * (`offloadInlinePayloads`), wherever it sits in the value.
6
+ *
7
+ * Every byte the saver writes passes through here: message rows
8
+ * (`encodeMessageRow`, which `put` and `appendTurnMessages` use), the
9
+ * checkpoint blob's other channel values and every pending write
10
+ * (`putWrites` — a node's output and LangGraph's `Send` packets, which
11
+ * carry a copy of the whole state, messages included). The graph's
12
+ * in-memory state keeps the real blocks: nothing here mutates its input,
13
+ * and a container is copied only when something inside it was replaced,
14
+ * so a value without inline payloads comes back as the same object.
15
+ *
16
+ * Only human messages are replaced: a tool or AI message that carries
17
+ * base64 (an MCP screenshot) is stored as it is, since nothing could bring
18
+ * its bytes back. The saver scans what it serializes for the payload text
19
+ * (`containsInlinePayload`) and logs a write that still holds one, so a
20
+ * shape this walk misses is visible.
21
+ */
22
+ import { isBaseMessage } from '@langchain/core/messages';
23
+ import { offloadInlinePayloads } from '../attachments/retention';
24
+
25
+ /**
26
+ * How deep a value is searched. A `Send` packet holds the state, the state
27
+ * holds the message list, the list holds the message: four levels; the
28
+ * margin covers state channels that nest a little deeper.
29
+ */
30
+ const MAX_DEPTH = 12;
31
+
32
+ function isPlainObject(value: object): value is Record<string, unknown> {
33
+ const proto: unknown = Object.getPrototypeOf(value);
34
+ return proto === Object.prototype || proto === null;
35
+ }
36
+
37
+ /**
38
+ * A LangGraph `Send` packet (`lg_name: 'Send'`), the shape the serializer
39
+ * recognises (`serialization.ts` `_default`, `JsonPlusSerializer`).
40
+ */
41
+ function isSendPacket(
42
+ value: object,
43
+ ): value is { lg_name: 'Send'; node: unknown; args: unknown } {
44
+ return Reflect.get(value, 'lg_name') === 'Send';
45
+ }
46
+
47
+ function strip(value: unknown, depth: number): unknown {
48
+ if (typeof value !== 'object' || value === null || depth > MAX_DEPTH)
49
+ return value;
50
+ if (isBaseMessage(value)) return offloadInlinePayloads(value) ?? value;
51
+ if (Array.isArray(value)) {
52
+ let out: unknown[] | undefined;
53
+ for (let i = 0; i < value.length; i++) {
54
+ const item: unknown = value[i];
55
+ const stripped = strip(item, depth + 1);
56
+ if (stripped === item) continue;
57
+ out ??= value.slice();
58
+ out[i] = stripped;
59
+ }
60
+ return out ?? value;
61
+ }
62
+ if (isSendPacket(value)) {
63
+ const args = strip(value.args, depth + 1);
64
+ if (args === value.args) return value;
65
+ // Serialized exactly like the `Send` it replaces: both serializers
66
+ // write `{ node, args }` (plus `timeout` when set) for this shape.
67
+ const timeout: unknown = Reflect.get(value, 'timeout');
68
+ return {
69
+ lg_name: 'Send',
70
+ node: value.node,
71
+ args,
72
+ ...(timeout !== undefined ? { timeout } : {}),
73
+ };
74
+ }
75
+ if (!isPlainObject(value)) return value;
76
+ let out: Record<string, unknown> | undefined;
77
+ for (const [key, item] of Object.entries(value)) {
78
+ const stripped = strip(item, depth + 1);
79
+ if (stripped === item) continue;
80
+ out ??= { ...value };
81
+ out[key] = stripped;
82
+ }
83
+ return out ?? value;
84
+ }
85
+
86
+ /**
87
+ * `value` as the checkpointer stores it: inline attachment payloads
88
+ * replaced by their placeholder (see the module comment). The same object
89
+ * when there is nothing to replace.
90
+ */
91
+ export function withoutInlinePayloads(value: unknown): unknown {
92
+ return strip(value, 0);
93
+ }
94
+
95
+ /**
96
+ * The text every serialized inline payload block contains (`JSON.stringify`
97
+ * writes no spaces): what the stored-form checks and the one-time sweep of
98
+ * old rows look for. User text quoting it matches too, harmlessly.
99
+ */
100
+ export const INLINE_PAYLOAD_NEEDLE = '"source_type":"base64"';
101
+
102
+ const NEEDLE_BYTES = new TextEncoder().encode(INLINE_PAYLOAD_NEEDLE);
103
+
104
+ /**
105
+ * Whether serialized bytes still contain an inline payload block: a shape
106
+ * `withoutInlinePayloads` does not reach (a plain `{ role, content }`
107
+ * object, a `Map` or class instance, a value nested deeper than
108
+ * `MAX_DEPTH`) or a non-human message carrying base64 (tool output, never
109
+ * stripped). One linear scan, no decoding.
110
+ */
111
+ export function containsInlinePayload(bytes: Uint8Array): boolean {
112
+ const first = NEEDLE_BYTES[0]!;
113
+ const last = bytes.length - NEEDLE_BYTES.length;
114
+ for (let i = bytes.indexOf(first); i !== -1 && i <= last; ) {
115
+ let j = 1;
116
+ while (j < NEEDLE_BYTES.length && bytes[i + j] === NEEDLE_BYTES[j]) j++;
117
+ if (j === NEEDLE_BYTES.length) return true;
118
+ i = bytes.indexOf(first, i + 1);
119
+ }
120
+ return false;
121
+ }