@ixo/oracle-runtime-workers 0.37.1 → 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.
- package/package.json +3 -3
- package/src/attachments/channel-links.ts +74 -7
- package/src/attachments/channel.ts +15 -4
- package/src/attachments/content-blocks.ts +19 -16
- package/src/attachments/download.ts +173 -50
- package/src/attachments/index.ts +6 -5
- package/src/attachments/pipeline.ts +117 -49
- package/src/attachments/resize.ts +142 -0
- package/src/attachments/retention.ts +26 -70
- package/src/attachments/room-media.ts +643 -0
- package/src/attachments/route.ts +47 -4
- package/src/attachments/sandbox-archive.ts +15 -4
- package/src/attachments/sanitize.ts +24 -13
- package/src/attachments/view.ts +82 -38
- package/src/core/main-agent-types.ts +8 -0
- package/src/core/main-agent.ts +18 -3
- package/src/core/middlewares/attachment-recall.ts +502 -0
- package/src/core/middlewares/index.ts +5 -0
- package/src/do/contracts.ts +53 -23
- package/src/do/flush-schedule.ts +8 -2
- package/src/do/room-mirror.ts +144 -20
- package/src/do/transcript.ts +1 -1
- package/src/do/turn-attachments.ts +45 -6
- package/src/do/user-oracle-do.ts +588 -105
- package/src/llm/provider-error.ts +5 -2
- package/src/llm/stream-liveness.ts +3 -2
- package/src/matrix/gateway-do.ts +164 -102
- package/src/matrix/ingest.ts +12 -3
- package/src/matrix/media-deadline.ts +5 -47
- package/src/plugins/attachments/attachments.plugin.ts +3 -3
- package/src/plugins/attachments/view-attachment-tool.ts +6 -4
- package/src/sqlite/inline-payloads.ts +121 -0
- package/src/sqlite/sqlite-saver.ts +241 -42
|
@@ -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
|
-
/**
|
|
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 <
|
|
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 <
|
|
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;
|
package/src/matrix/gateway-do.ts
CHANGED
|
@@ -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
|
|
126
|
-
* asks for less. A stalled homeserver
|
|
127
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
1652
|
-
*
|
|
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
|
|
1748
|
+
async eventMediaInfo(
|
|
1655
1749
|
roomId: string,
|
|
1656
1750
|
eventId: string,
|
|
1657
1751
|
opts: MediaDownloadOptions = {},
|
|
1658
|
-
): Promise<
|
|
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
|
-
|
|
1666
|
-
|
|
1667
|
-
media = await raceSignal(
|
|
1668
|
-
this.mediaStream(roomId, eventId),
|
|
1758
|
+
const raw = await raceSignal(
|
|
1759
|
+
this.getEvent(roomId, eventId),
|
|
1669
1760
|
bound.signal,
|
|
1670
|
-
(
|
|
1761
|
+
() => undefined,
|
|
1671
1762
|
);
|
|
1672
|
-
|
|
1673
|
-
|
|
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)
|
|
1805
|
-
* one-time keys and store are the
|
|
1806
|
-
* re-login
|
|
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(
|
|
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
|
// -------------------------------------------------------------------------
|
package/src/matrix/ingest.ts
CHANGED
|
@@ -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
|
|
89
|
-
*
|
|
90
|
-
* bridge does; omit to
|
|
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
|
|
3
|
-
*
|
|
4
|
-
* assembling an attachment for a turn — waiting for as long as
|
|
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
|
|
3
|
-
*
|
|
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
|
|
3
|
-
*
|
|
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,
|
|
15
|
-
*
|
|
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
|
+
}
|