@ixo/oracle-runtime-workers 0.37.2 → 0.39.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 +4 -4
- 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/channels/replies.ts +8 -4
- package/src/core/capability-expiry.ts +62 -0
- package/src/core/capability-router.ts +126 -95
- package/src/core/context-budget.ts +116 -4
- package/src/core/domain-context/document.ts +74 -2
- package/src/core/domain-context/index.ts +1 -0
- package/src/core/domain-context/resolver.ts +9 -6
- package/src/core/domain-context/turn-context.ts +62 -13
- package/src/core/domain-context/types.ts +17 -0
- package/src/core/env.ts +21 -2
- package/src/core/index.ts +4 -0
- package/src/core/llm.ts +29 -0
- package/src/core/main-agent-types.ts +53 -4
- package/src/core/main-agent.ts +228 -125
- package/src/core/manifest.ts +109 -24
- package/src/core/mcp-result.ts +56 -0
- package/src/core/meta-tools.ts +258 -110
- package/src/core/middlewares/attachment-recall.ts +502 -0
- package/src/core/middlewares/capability-gate.ts +77 -28
- package/src/core/middlewares/context-guard.ts +154 -21
- package/src/core/middlewares/index.ts +5 -0
- package/src/core/middlewares/result-cap.ts +1 -1
- package/src/core/middlewares/summarization.ts +9 -0
- package/src/core/middlewares/tool-marks.ts +11 -2
- package/src/core/middlewares/turn-boundary.ts +22 -12
- package/src/core/operating-guides.ts +126 -0
- package/src/core/plugins/skills/skills-tools.ts +6 -31
- package/src/core/plugins/skills/skills.plugin.ts +4 -3
- package/src/core/plugins/weather/weather.plugin.ts +1 -0
- package/src/core/prompt-composer.ts +188 -111
- package/src/core/read-result-tool.ts +48 -14
- package/src/core/registries.ts +13 -5
- package/src/core/state.ts +4 -3
- package/src/core/sub-agent-fallback.ts +1 -0
- package/src/core/subagent-as-tool.ts +4 -1
- package/src/core/tool-access.ts +3 -32
- package/src/core/wrap-plugin-tool.ts +2 -1
- package/src/do/contracts.ts +64 -24
- package/src/do/room-mirror.ts +144 -20
- package/src/do/transcript.ts +41 -12
- package/src/do/turn-attachments.ts +45 -6
- package/src/do/turn-metadata.ts +16 -4
- package/src/do/user-oracle-do.ts +639 -152
- package/src/index.ts +1 -0
- 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/memory/session-history-indexer.ts +60 -14
- package/src/plugin-api/oracle-plugin.ts +4 -5
- package/src/plugin-api/types.ts +32 -3
- package/src/plugins/action-proposals/action-proposals.plugin.ts +7 -2
- package/src/plugins/agui/agui.plugin.ts +1 -0
- package/src/plugins/attachments/attachments.plugin.ts +3 -3
- package/src/plugins/attachments/view-attachment-tool.ts +6 -4
- package/src/plugins/compact-schema.ts +80 -0
- package/src/plugins/composio/composio.plugin.ts +1 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +4 -3
- package/src/plugins/editor/editor.plugin.ts +4 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +2 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +1 -0
- package/src/plugins/flows/flows.plugin.ts +37 -6
- package/src/plugins/flows/prompts.ts +1 -1
- package/src/plugins/flows/tools/authoring.ts +5 -7
- package/src/plugins/flows/tools/discovery.ts +1 -4
- package/src/plugins/flows/tools/forms.ts +2 -8
- package/src/plugins/flows/tools/linkage.ts +1 -4
- package/src/plugins/flows/tools/settings.ts +1 -4
- package/src/plugins/flows/translator.ts +16 -5
- package/src/plugins/flows/types.ts +27 -48
- package/src/plugins/hub-actions/hub-actions.plugin.ts +30 -4
- package/src/plugins/index.ts +2 -2
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +29 -5
- package/src/plugins/ixo-chain/kinds/core.ts +2 -2
- package/src/plugins/ixo-chain/kinds/types.ts +34 -5
- package/src/plugins/ixo-chain/queries.ts +10 -1
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +1 -0
- package/src/plugins/matrix-group-chats/index.ts +1 -0
- package/src/plugins/memory/memory-tools.ts +4 -4
- package/src/plugins/memory/memory.plugin.ts +27 -1
- package/src/plugins/memory/oracle-document-tools.ts +24 -49
- package/src/plugins/pod-creator/pod-creator.plugin.ts +1 -0
- package/src/plugins/portal/portal.plugin.ts +1 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +3 -15
- package/src/plugins/sandbox/sandbox.plugin.ts +19 -2
- package/src/plugins/tasks/manifest.ts +1 -0
- package/src/plugins/tasks/tasks-tools.ts +14 -19
- package/src/plugins/user-preferences/user-preferences-tool.ts +11 -37
- package/src/plugins/user-preferences/user-preferences.plugin.ts +4 -0
- package/src/plugins/vfs/vfs-tools.ts +6 -6
- package/src/plugins/vfs/vfs.plugin.ts +4 -5
- package/src/plugins/yoma/yoma.plugin.ts +1 -0
- package/src/sqlite/inline-payloads.ts +121 -0
- package/src/sqlite/serialization.ts +12 -0
- package/src/sqlite/sqlite-saver.ts +258 -42
- package/src/tasks/schedule-input.ts +5 -22
package/src/core/tool-access.ts
CHANGED
|
@@ -6,13 +6,8 @@ import type {
|
|
|
6
6
|
PluginTool,
|
|
7
7
|
} from '../plugin-api/types';
|
|
8
8
|
import type { CapabilityRequirement } from './manifest';
|
|
9
|
-
import type {
|
|
10
|
-
RegisteredSubAgent,
|
|
11
|
-
SubAgentRegistry,
|
|
12
|
-
ToolRegistry,
|
|
13
|
-
} from './registries';
|
|
9
|
+
import type { RegisteredSubAgent } from './registries';
|
|
14
10
|
import { computeSubAgentToolName } from './subagent-as-tool';
|
|
15
|
-
import { NOOP_LOGGER } from './utils';
|
|
16
11
|
|
|
17
12
|
/** `ctx.ucan.hasCapability` bound to the turn's delegation. */
|
|
18
13
|
export type HasCapability = (resource: string, action: string) => boolean;
|
|
@@ -53,8 +48,8 @@ export interface TurnToolAccess<
|
|
|
53
48
|
/**
|
|
54
49
|
* Plugins that contributed tools or sub-agents of which none is left.
|
|
55
50
|
* Treated like a plugin whose `requires` are unmet (left out of the
|
|
56
|
-
* prompt, refused by `load_capability`,
|
|
57
|
-
* are hidden outright: `list_capabilities` does not list them.
|
|
51
|
+
* prompt, refused by `load_capability`, not offered to the router) except
|
|
52
|
+
* that they are hidden outright: `list_capabilities` does not list them.
|
|
58
53
|
*/
|
|
59
54
|
hiddenPlugins: ReadonlySet<string>;
|
|
60
55
|
}
|
|
@@ -145,30 +140,6 @@ export function resolveTurnToolAccess<
|
|
|
145
140
|
return { tools, subAgents, withheldToolNames, hiddenPlugins };
|
|
146
141
|
}
|
|
147
142
|
|
|
148
|
-
/**
|
|
149
|
-
* `hiddenPlugins` from the boot-time contributions alone, for the
|
|
150
|
-
* capability router, which predicts before the turn's request-time tools are
|
|
151
|
-
* collected. A plugin whose request-time tools would make it usable may be
|
|
152
|
-
* left out here (the router does not offer it; `load_capability` still
|
|
153
|
-
* can); one whose request-time tools are all withheld may be offered, and
|
|
154
|
-
* the turn's build voids that preload (`createMainAgent`).
|
|
155
|
-
*/
|
|
156
|
-
export async function bootHiddenPlugins(input: {
|
|
157
|
-
registries: { tools: ToolRegistry; subAgents: SubAgentRegistry };
|
|
158
|
-
buildCtx: PluginContext;
|
|
159
|
-
has: HasCapability;
|
|
160
|
-
}): Promise<ReadonlySet<string>> {
|
|
161
|
-
const { registries, buildCtx, has } = input;
|
|
162
|
-
return resolveTurnToolAccess({
|
|
163
|
-
tools: await registries.tools.collectBoot(buildCtx),
|
|
164
|
-
subAgents: registries.subAgents.collectBoot(buildCtx),
|
|
165
|
-
buildCtx,
|
|
166
|
-
has,
|
|
167
|
-
// A failing sub-agent is reported by the turn's build, once.
|
|
168
|
-
logger: NOOP_LOGGER,
|
|
169
|
-
}).hiddenPlugins;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
143
|
/**
|
|
173
144
|
* The manifest as this turn may show it: `examples` naming a withheld tool
|
|
174
145
|
* removed, so neither the Tier-1 prompt nor `load_capability` teaches the
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ToolMessage } from '@langchain/core/messages';
|
|
2
2
|
import { Command } from '@langchain/langgraph';
|
|
3
|
+
import { withoutStructuredCopy } from './mcp-result';
|
|
3
4
|
import { capToolResult, type ResultCapConfig } from './middlewares/result-cap';
|
|
4
5
|
import { tool } from '@langchain/core/tools';
|
|
5
6
|
import type { StructuredTool } from 'langchain';
|
|
@@ -125,7 +126,7 @@ export function wrapPluginTool(
|
|
|
125
126
|
);
|
|
126
127
|
if (pluginName !== undefined)
|
|
127
128
|
requireToolPlane(ctx, pluginName, pluginTool);
|
|
128
|
-
const output = await pluginTool.handler(args, ctx);
|
|
129
|
+
const output = withoutStructuredCopy(await pluginTool.handler(args, ctx));
|
|
129
130
|
// A tool's own message (a result that says the tool did not run) is
|
|
130
131
|
// returned whole, never capped into text.
|
|
131
132
|
if (
|
package/src/do/contracts.ts
CHANGED
|
@@ -248,6 +248,13 @@ export interface OracleWorkerEnv {
|
|
|
248
248
|
* `"ai": { "binding": "AI" }` in the wrangler config.
|
|
249
249
|
*/
|
|
250
250
|
AI?: Ai;
|
|
251
|
+
/**
|
|
252
|
+
* Cloudflare Images binding. Optional: images sent natively to a model are
|
|
253
|
+
* downscaled to a 1568 px long edge as JPEG first (`attachments/resize.ts`); the
|
|
254
|
+
* sandbox keeps the original. Absent = images go as they are. Declare it
|
|
255
|
+
* with `"images": { "binding": "IMAGES" }` in the wrangler config.
|
|
256
|
+
*/
|
|
257
|
+
IMAGES?: ImagesBinding;
|
|
251
258
|
|
|
252
259
|
// --- llm ----------------------------------------------------------------
|
|
253
260
|
OPEN_ROUTER_API_KEY: string;
|
|
@@ -264,6 +271,8 @@ export interface OracleWorkerEnv {
|
|
|
264
271
|
LLM_STREAM_IDLE_TIMEOUT_MS?: string;
|
|
265
272
|
LLM_BYO_STREAM_IDLE_TIMEOUT_MS?: string;
|
|
266
273
|
LLM_STREAM_RETRIES?: string;
|
|
274
|
+
/** Most tokens one platform model call may generate, reasoning included (default 16384; 0 = the model's limit). */
|
|
275
|
+
LLM_MAX_OUTPUT_TOKENS?: string;
|
|
267
276
|
/** LangGraph steps one turn may take before `GraphRecursionError` (default 600; Node hard-codes 200). */
|
|
268
277
|
TURN_RECURSION_LIMIT?: string;
|
|
269
278
|
/** Turn budget: tool attempts (default 120) and deadline in ms (600000). */
|
|
@@ -288,7 +297,12 @@ export interface OracleWorkerEnv {
|
|
|
288
297
|
MODEL_CONTEXT_TOKENS?: string;
|
|
289
298
|
MODEL_CONTEXT_OVERRIDES?: string;
|
|
290
299
|
CONTEXT_SUMMARIZE_FRACTION?: string;
|
|
300
|
+
CONTEXT_SUMMARIZE_MAX_TOKENS?: string;
|
|
291
301
|
CONTEXT_PRUNE_FRACTION?: string;
|
|
302
|
+
CONTEXT_PRUNE_MAX_TOKENS?: string;
|
|
303
|
+
CONTEXT_STALE_RESULT_TURNS?: string;
|
|
304
|
+
CONTEXT_CACHE_COLD_MS?: string;
|
|
305
|
+
CONTEXT_PLUGIN_IDLE_TURNS?: string;
|
|
292
306
|
CONTEXT_RESULT_CAP_FRACTION?: string;
|
|
293
307
|
CONTEXT_RESULT_CAP_MAX_CHARS?: string;
|
|
294
308
|
CONTEXT_REQUEST_FRACTION?: string;
|
|
@@ -320,9 +334,14 @@ export interface OracleWorkerEnv {
|
|
|
320
334
|
/**
|
|
321
335
|
* Capability router (needs a `DECISION_PROVIDER`). `off` (default) never
|
|
322
336
|
* evaluates; `shadow` evaluates and logs the verdict without preloading;
|
|
323
|
-
* `on`
|
|
337
|
+
* `on` loads the predicted plugins on the thread before the first model
|
|
338
|
+
* call.
|
|
324
339
|
*/
|
|
325
340
|
CAPABILITY_ROUTER?: CapabilityRouterMode;
|
|
341
|
+
/** The score a preload needs on a warm turn (default 0.8; above 1, never). */
|
|
342
|
+
CAPABILITY_ROUTER_WARM_THRESHOLD?: string;
|
|
343
|
+
/** The score a preload needs on a cold turn (default 0.7). */
|
|
344
|
+
CAPABILITY_ROUTER_COLD_THRESHOLD?: string;
|
|
326
345
|
/** Applies exclusively to authenticated channel admission; Portal/Matrix turns never route. */
|
|
327
346
|
CHANNEL_SEMANTIC_ROUTING?: 'off' | 'shadow' | 'on';
|
|
328
347
|
CHANNEL_SEMANTIC_ROUTING_BINDINGS?: string;
|
|
@@ -982,10 +1001,9 @@ export interface CreateRoomOptions {
|
|
|
982
1001
|
}
|
|
983
1002
|
|
|
984
1003
|
/**
|
|
985
|
-
* Deadline for one gateway media
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
* cancelling the returned stream.
|
|
1004
|
+
* Deadline for one gateway media event lookup (`eventMediaInfo`; the
|
|
1005
|
+
* gateway's default applies when omitted). Only the timeout crosses the RPC:
|
|
1006
|
+
* an `AbortSignal` cannot.
|
|
989
1007
|
*/
|
|
990
1008
|
export interface MediaDownloadDeadline {
|
|
991
1009
|
timeoutMs?: number;
|
|
@@ -1000,22 +1018,20 @@ export interface MediaDownloadDeadline {
|
|
|
1000
1018
|
*/
|
|
1001
1019
|
export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
|
|
1002
1020
|
/**
|
|
1003
|
-
*
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1021
|
+
* Where the media of event `eventId` in `roomId` lives, as small JSON: the
|
|
1022
|
+
* gateway fetches and decrypts the EVENT (only it holds the room keys),
|
|
1023
|
+
* never the media. The caller downloads `mxc` from the homeserver itself
|
|
1024
|
+
* (`openHomeserverMedia`, with the plugin device's token) and decrypts it
|
|
1025
|
+
* with `file` (`createAttachmentDecryptor`), so no media byte crosses the
|
|
1026
|
+
* shared gateway object. Bounded by `opts.timeoutMs` (default the media
|
|
1027
|
+
* download deadline). Null when the event is unknown, redacted or carries
|
|
1028
|
+
* no media.
|
|
1008
1029
|
*/
|
|
1009
|
-
|
|
1030
|
+
eventMediaInfo(
|
|
1010
1031
|
roomId: string,
|
|
1011
1032
|
eventId: string,
|
|
1012
1033
|
opts?: MediaDownloadDeadline,
|
|
1013
|
-
): Promise<
|
|
1014
|
-
/** Authenticated media download of an `mxc://` URI, as a stream. */
|
|
1015
|
-
downloadMxcMediaStream(
|
|
1016
|
-
mxc: string,
|
|
1017
|
-
opts?: MediaDownloadDeadline,
|
|
1018
|
-
): Promise<ReadableStream<Uint8Array>>;
|
|
1034
|
+
): Promise<EventMediaInfo | null>;
|
|
1019
1035
|
/** Forcibly reset the gateway object like a platform host drain (debug routes only). The call itself rejects. */
|
|
1020
1036
|
debugAbortObject(): Promise<void>;
|
|
1021
1037
|
/** Stop the bot (operator/debug; `ensureStarted`/cron brings it back). */
|
|
@@ -1042,11 +1058,16 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
|
|
|
1042
1058
|
listOutbox(): Promise<OutboxRow[]>;
|
|
1043
1059
|
/**
|
|
1044
1060
|
* Credentials of the oracle's second, crypto-less device for plugins that
|
|
1045
|
-
* run their own Matrix client (editor, flows)
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
1061
|
+
* run their own Matrix client (editor, flows) and for the user objects'
|
|
1062
|
+
* own media transfers (channel file uploads, Matrix media downloads).
|
|
1063
|
+
* Logged in once with the account password, kept in the gateway's
|
|
1064
|
+
* storage; concurrent first calls share one login. A caller whose
|
|
1065
|
+
* request the homeserver answered 401 passes the token it used as
|
|
1066
|
+
* `rejected`: when that is still the cached token, the cache is dropped and
|
|
1067
|
+
* the stored token re-validated (`whoami`), else the device is logged in
|
|
1068
|
+
* again with the same device id — once, for every caller waiting.
|
|
1048
1069
|
*/
|
|
1049
|
-
botCredentials(): Promise<BotCredentials>;
|
|
1070
|
+
botCredentials(opts?: { rejected?: string }): Promise<BotCredentials>;
|
|
1050
1071
|
status(): Promise<GatewayStatus>;
|
|
1051
1072
|
/** Send a text message (encrypted when the room is). Returns the event id. */
|
|
1052
1073
|
sendText(
|
|
@@ -1075,12 +1096,18 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
|
|
|
1075
1096
|
* event id. `txnId` pins the transaction id, as for `sendText`: the
|
|
1076
1097
|
* homeserver deduplicates by it per device, so a send retried after a lost
|
|
1077
1098
|
* response returns the original event instead of posting a second one.
|
|
1099
|
+
* `priority: 'background'` (channel text mirrors, channel file events)
|
|
1100
|
+
* goes through the gateway's small background lane, so such sends never
|
|
1101
|
+
* hold more than a few of the send path's places ahead of a reply
|
|
1102
|
+
* (`interactive`, the default). Content carrying an attachment key
|
|
1103
|
+
* (`file.key`) is refused for a room the gateway does not see as
|
|
1104
|
+
* end-to-end encrypted.
|
|
1078
1105
|
*/
|
|
1079
1106
|
sendEvent(
|
|
1080
1107
|
roomId: string,
|
|
1081
1108
|
type: string,
|
|
1082
1109
|
content: JsonString,
|
|
1083
|
-
opts?: { txnId?: string },
|
|
1110
|
+
opts?: { txnId?: string; priority?: 'interactive' | 'background' },
|
|
1084
1111
|
): Promise<string>;
|
|
1085
1112
|
/** Resolve the canonical user↔oracle room for a user DID (alias lookup, cached). */
|
|
1086
1113
|
resolveUserRoom(
|
|
@@ -1105,7 +1132,7 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
|
|
|
1105
1132
|
): Promise<{ eventId: string }>;
|
|
1106
1133
|
/**
|
|
1107
1134
|
* The latest snapshot for `storageKey` as a stream, as stored (see
|
|
1108
|
-
* `
|
|
1135
|
+
* `MediaStream`), or null when none exists.
|
|
1109
1136
|
*/
|
|
1110
1137
|
downloadUserSnapshotStream(
|
|
1111
1138
|
userDid: string,
|
|
@@ -1200,7 +1227,6 @@ export interface OutboxRow {
|
|
|
1200
1227
|
enqueuedAt: number;
|
|
1201
1228
|
}
|
|
1202
1229
|
|
|
1203
|
-
/** `MatrixBotDO.status()` plus the gateway's own turn bookkeeping. */
|
|
1204
1230
|
/**
|
|
1205
1231
|
* Media handed across the Durable Object boundary as a stream, as stored on
|
|
1206
1232
|
* the homeserver: `file` is present when the event carried an encrypted
|
|
@@ -1216,6 +1242,19 @@ export interface MediaStream {
|
|
|
1216
1242
|
filename?: string;
|
|
1217
1243
|
}
|
|
1218
1244
|
|
|
1245
|
+
/**
|
|
1246
|
+
* Where a media event's file lives (`MatrixGatewayObject.eventMediaInfo`):
|
|
1247
|
+
* its `mxc://` URI and, for an encrypted attachment, the `EncryptedFile`
|
|
1248
|
+
* fields to decrypt it with. `size` is the sender's `info.size` when set.
|
|
1249
|
+
*/
|
|
1250
|
+
export interface EventMediaInfo {
|
|
1251
|
+
mxc: string;
|
|
1252
|
+
file?: EncryptedFileInfo;
|
|
1253
|
+
mimetype?: string;
|
|
1254
|
+
filename?: string;
|
|
1255
|
+
size?: number;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1219
1258
|
/** `MediaStream` of a user's snapshot, plus the media event that carries it. */
|
|
1220
1259
|
export interface SnapshotStream extends MediaStream {
|
|
1221
1260
|
eventId: string;
|
|
@@ -1229,6 +1268,7 @@ export interface GroupRoomInfo {
|
|
|
1229
1268
|
groupLane: boolean;
|
|
1230
1269
|
}
|
|
1231
1270
|
|
|
1271
|
+
/** `MatrixBotDO.status()` plus the gateway's own turn bookkeeping. */
|
|
1232
1272
|
export interface GatewayStatus extends BotStatus {
|
|
1233
1273
|
turns: { inFlight: number; waiting: number };
|
|
1234
1274
|
/** Channel memory of group rooms: messages awaiting compaction, summary chunks, pinned facts. */
|
package/src/do/room-mirror.ts
CHANGED
|
@@ -19,9 +19,20 @@ import {
|
|
|
19
19
|
* - sends are serialised per session (parallel across sessions), so a reply
|
|
20
20
|
* being retried can never overtake the message it answers, and a later
|
|
21
21
|
* turn's mirrors queue behind an earlier turn's.
|
|
22
|
+
*
|
|
23
|
+
* The same chain carries the media events of a channel turn's files
|
|
24
|
+
* (`sendMedia`, built by `attachments/room-media.ts`): the owner object
|
|
25
|
+
* queues them in file order right after the person's text, so each is
|
|
26
|
+
* posted after that text and before the reply, whenever its upload ends.
|
|
22
27
|
*/
|
|
28
|
+
import type { EncryptedFileInfo } from '@ixo/matrix-bot-workers-sdk';
|
|
29
|
+
import { abortError, untilAborted } from '../attachments/download';
|
|
23
30
|
import { matrixTxnId } from '../matrix/txn-id';
|
|
24
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
isTransientGatewayError,
|
|
33
|
+
retryGateway,
|
|
34
|
+
type RetryGatewayOptions,
|
|
35
|
+
} from './gateway-retry';
|
|
25
36
|
|
|
26
37
|
export type MirrorAuthor = 'user' | 'oracle';
|
|
27
38
|
|
|
@@ -53,8 +64,43 @@ export interface MirrorSend {
|
|
|
53
64
|
};
|
|
54
65
|
}
|
|
55
66
|
|
|
67
|
+
/** The `org.ixo.qi.origin` input of a channel mirror. */
|
|
68
|
+
export type MirrorOrigin = NonNullable<MirrorSend['origin']>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The media part of an `m.room.message` file event (`m.image` / `m.video` /
|
|
72
|
+
* `m.audio` / `m.file`): what the room shows, without the thread relation
|
|
73
|
+
* and the channel origin, which `mediaEventContent` adds.
|
|
74
|
+
*/
|
|
75
|
+
export interface MediaMessage {
|
|
76
|
+
msgtype: 'm.image' | 'm.video' | 'm.audio' | 'm.file';
|
|
77
|
+
body: string;
|
|
78
|
+
filename: string;
|
|
79
|
+
info: { mimetype: string; size: number };
|
|
80
|
+
/**
|
|
81
|
+
* The encrypted attachment: its `mxc://` plus the `EncryptedFile` fields.
|
|
82
|
+
* Always encrypted: a file is only ever posted into a Megolm room.
|
|
83
|
+
*/
|
|
84
|
+
file: { url: string } & EncryptedFileInfo;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface MediaMirrorSend {
|
|
88
|
+
roomId: string;
|
|
89
|
+
/** The session's marker event — the thread the file is posted under. */
|
|
90
|
+
threadId: string;
|
|
91
|
+
txnId: string;
|
|
92
|
+
origin?: MirrorOrigin;
|
|
93
|
+
media: MediaMessage;
|
|
94
|
+
}
|
|
95
|
+
|
|
56
96
|
export interface RoomMirrorDeps {
|
|
57
97
|
sendText(send: MirrorSend): Promise<string>;
|
|
98
|
+
/**
|
|
99
|
+
* Send one file event (`content` JSON-encoded) with a pinned transaction
|
|
100
|
+
* id, at background priority (`MatrixGatewayObject.sendEvent`); returns
|
|
101
|
+
* its event id.
|
|
102
|
+
*/
|
|
103
|
+
sendEvent(roomId: string, content: string, txnId: string): Promise<string>;
|
|
58
104
|
/** Keeps the (un-awaited) send alive past the request that started it: `ctx.waitUntil`. */
|
|
59
105
|
keepAlive(work: Promise<unknown>): void;
|
|
60
106
|
log(message: string): void;
|
|
@@ -63,6 +109,16 @@ export interface RoomMirrorDeps {
|
|
|
63
109
|
retry?: Pick<RetryGatewayOptions, 'delaysMs' | 'sleep' | 'isTransient'>;
|
|
64
110
|
}
|
|
65
111
|
|
|
112
|
+
/** The relation that posts an event in the thread of `threadId` (with the reply fallback for clients without threads). */
|
|
113
|
+
function threadRelation(threadId: string) {
|
|
114
|
+
return {
|
|
115
|
+
rel_type: 'm.thread',
|
|
116
|
+
event_id: threadId,
|
|
117
|
+
is_falling_back: true,
|
|
118
|
+
'm.in_reply_to': { event_id: threadId },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
66
122
|
export function mirrorEventContent(send: MirrorSend) {
|
|
67
123
|
return {
|
|
68
124
|
msgtype: 'm.text',
|
|
@@ -70,12 +126,7 @@ export function mirrorEventContent(send: MirrorSend) {
|
|
|
70
126
|
...(send.formattedBody
|
|
71
127
|
? { format: 'org.matrix.custom.html', formatted_body: send.formattedBody }
|
|
72
128
|
: {}),
|
|
73
|
-
'm.relates_to':
|
|
74
|
-
rel_type: 'm.thread',
|
|
75
|
-
event_id: send.threadId,
|
|
76
|
-
is_falling_back: true,
|
|
77
|
-
'm.in_reply_to': { event_id: send.threadId },
|
|
78
|
-
},
|
|
129
|
+
'm.relates_to': threadRelation(send.threadId),
|
|
79
130
|
...(send.origin
|
|
80
131
|
? {
|
|
81
132
|
'org.ixo.qi.origin': channelOrigin(send.origin),
|
|
@@ -87,6 +138,19 @@ export function mirrorEventContent(send: MirrorSend) {
|
|
|
87
138
|
};
|
|
88
139
|
}
|
|
89
140
|
|
|
141
|
+
/**
|
|
142
|
+
* A file event in the session thread: the media part, the thread relation
|
|
143
|
+
* of the text mirrors and their `org.ixo.qi.origin` (never the quote, which
|
|
144
|
+
* the text mirror of the same message carries).
|
|
145
|
+
*/
|
|
146
|
+
export function mediaEventContent(send: MediaMirrorSend) {
|
|
147
|
+
return {
|
|
148
|
+
...send.media,
|
|
149
|
+
'm.relates_to': threadRelation(send.threadId),
|
|
150
|
+
...(send.origin ? { 'org.ixo.qi.origin': channelOrigin(send.origin) } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
90
154
|
const errorText = (err: unknown): string =>
|
|
91
155
|
err instanceof Error ? err.message : String(err);
|
|
92
156
|
|
|
@@ -115,38 +179,98 @@ export class RoomMirror {
|
|
|
115
179
|
label: string,
|
|
116
180
|
required = false,
|
|
117
181
|
): Promise<void> {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
.then((send) => (send ? this.send(send, label) : undefined))
|
|
125
|
-
.catch((err: unknown) => {
|
|
182
|
+
return this.chain(sessionId, async () => {
|
|
183
|
+
try {
|
|
184
|
+
const send = await prepare();
|
|
185
|
+
if (send)
|
|
186
|
+
await this.send(() => this.deps.sendText(send), send.threadId, label);
|
|
187
|
+
} catch (err) {
|
|
126
188
|
if (required) throw err;
|
|
127
189
|
this.deps.warn(
|
|
128
190
|
`[user-do] Matrix replay (${label}) failed — session=${sessionId}: ${errorText(err)}`,
|
|
129
191
|
);
|
|
130
|
-
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Queue one file event behind the session's previous mirrors. When its
|
|
198
|
+
* turn in the chain comes, `prepare` builds the event (it waits for the
|
|
199
|
+
* file's upload; `null` = nothing to post) and the send follows with the
|
|
200
|
+
* transient retries; resolves with the event id, or null when nothing was
|
|
201
|
+
* posted. `signal` is honoured throughout: checked before `prepare` and
|
|
202
|
+
* before every send attempt, and it ends the wait on `prepare` — once it
|
|
203
|
+
* aborts no further attempt is made and the call rejects with its reason.
|
|
204
|
+
* A send that fails after the retries rejects.
|
|
205
|
+
*/
|
|
206
|
+
sendMedia(
|
|
207
|
+
sessionId: string,
|
|
208
|
+
prepare: () => Promise<MediaMirrorSend | null>,
|
|
209
|
+
label: string,
|
|
210
|
+
signal?: AbortSignal,
|
|
211
|
+
): Promise<string | null> {
|
|
212
|
+
return this.chain(sessionId, async () => {
|
|
213
|
+
if (signal?.aborted) throw abortError(signal);
|
|
214
|
+
const send = await (signal ? untilAborted(prepare(), signal) : prepare());
|
|
215
|
+
if (!send) return null;
|
|
216
|
+
const content = JSON.stringify(mediaEventContent(send));
|
|
217
|
+
return this.send(
|
|
218
|
+
() => {
|
|
219
|
+
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
220
|
+
return this.deps.sendEvent(send.roomId, content, send.txnId);
|
|
221
|
+
},
|
|
222
|
+
send.threadId,
|
|
223
|
+
label,
|
|
224
|
+
signal,
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Run `work` once the session's previous mirrors have settled either way
|
|
231
|
+
* (a required mirror that failed, and rejected its own caller, must not
|
|
232
|
+
* skip the next one), keep it alive past the request, and drop the chain
|
|
233
|
+
* once it drains.
|
|
234
|
+
*/
|
|
235
|
+
private chain<T>(sessionId: string, work: () => Promise<T>): Promise<T> {
|
|
236
|
+
const previous = this.chains.get(sessionId) ?? Promise.resolve();
|
|
237
|
+
// Settles (as `work` did) once the chain entry is gone, so a caller that
|
|
238
|
+
// awaited it sees the session's chain drained.
|
|
239
|
+
const done = previous
|
|
240
|
+
.catch(() => undefined)
|
|
241
|
+
.then(work)
|
|
131
242
|
.finally(() => {
|
|
132
243
|
if (this.chains.get(sessionId) === tracked)
|
|
133
244
|
this.chains.delete(sessionId);
|
|
134
245
|
});
|
|
246
|
+
const tracked: Promise<void> = done.then(
|
|
247
|
+
() => undefined,
|
|
248
|
+
() => undefined,
|
|
249
|
+
);
|
|
135
250
|
this.chains.set(sessionId, tracked);
|
|
136
251
|
this.deps.keepAlive(tracked);
|
|
137
|
-
return
|
|
252
|
+
return done;
|
|
138
253
|
}
|
|
139
254
|
|
|
140
|
-
private async send(
|
|
141
|
-
|
|
255
|
+
private async send(
|
|
256
|
+
post: () => Promise<string>,
|
|
257
|
+
threadId: string,
|
|
258
|
+
label: string,
|
|
259
|
+
signal?: AbortSignal,
|
|
260
|
+
): Promise<string> {
|
|
261
|
+
const isTransient = this.deps.retry?.isTransient ?? isTransientGatewayError;
|
|
262
|
+
const eventId = await retryGateway(post, {
|
|
142
263
|
...this.deps.retry,
|
|
264
|
+
// An abort is final: the transient check would otherwise retry it.
|
|
265
|
+
isTransient: (err) => !signal?.aborted && isTransient(err),
|
|
143
266
|
onRetry: (err, attempt, delayMs) =>
|
|
144
267
|
this.deps.warn(
|
|
145
268
|
`[user-do] Matrix replay (${label}) send failed (attempt ${attempt}) — retrying in ${delayMs} ms with the same transaction id: ${errorText(err)}`,
|
|
146
269
|
),
|
|
147
270
|
});
|
|
148
271
|
this.deps.log(
|
|
149
|
-
`[user-do] Matrix replay (${label}) → ${eventId} in thread ${
|
|
272
|
+
`[user-do] Matrix replay (${label}) → ${eventId} in thread ${threadId}`,
|
|
150
273
|
);
|
|
274
|
+
return eventId;
|
|
151
275
|
}
|
|
152
276
|
}
|
package/src/do/transcript.ts
CHANGED
|
@@ -111,7 +111,7 @@ export function contentToText(content: BaseMessage['content']): string {
|
|
|
111
111
|
if (typeof part === 'string') return part;
|
|
112
112
|
if (part && typeof part === 'object' && 'text' in part) {
|
|
113
113
|
const text = (part as { text?: unknown }).text;
|
|
114
|
-
// The
|
|
114
|
+
// The attachment placeholder is model-facing only; the transcript
|
|
115
115
|
// keeps listing the attachment from its metadata.
|
|
116
116
|
return typeof text === 'string' && !isAttachmentPlaceholderText(text)
|
|
117
117
|
? text
|
|
@@ -140,6 +140,45 @@ export function visibleText(message: BaseMessage): string {
|
|
|
140
140
|
return contentToText(visibleContent(message));
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Whether a stored message is one the user sees in the transcript. System and
|
|
145
|
+
* tool messages never are, nor a failed reply, nor anything the runtime wrote
|
|
146
|
+
* for the model about an attachment: the extracted text of a file (an AI
|
|
147
|
+
* message with an `attachment`) and `view_attachment` re-attaching a payload,
|
|
148
|
+
* nor a chat gateway's delivery notice.
|
|
149
|
+
*/
|
|
150
|
+
export function visibleToUser(message: BaseMessage): boolean {
|
|
151
|
+
if (message.type === 'system' || message.type === 'tool') return false;
|
|
152
|
+
const kw = (message.additional_kwargs ?? {}) as Record<string, unknown>;
|
|
153
|
+
if (kw.isError) return false;
|
|
154
|
+
if (message.type === 'ai' && Boolean(kw.attachment)) return false;
|
|
155
|
+
// A chat gateway's delivery notice: the model's, never a user message.
|
|
156
|
+
return !isAttachmentViewMessage(message) && !isDeliveryNoticeMessage(message);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The latest turns as the user saw them, oldest first and each as
|
|
161
|
+
* `user: …` or `assistant: …` cut to `maxChars`, from messages listed newest
|
|
162
|
+
* first. Only what `visibleToUser` lets through, without summaries and rows
|
|
163
|
+
* with no visible text.
|
|
164
|
+
*/
|
|
165
|
+
export function recentTurnTexts(
|
|
166
|
+
newestFirst: readonly BaseMessage[],
|
|
167
|
+
limit: number,
|
|
168
|
+
maxChars: number,
|
|
169
|
+
): string[] {
|
|
170
|
+
const turns: string[] = [];
|
|
171
|
+
for (const message of newestFirst) {
|
|
172
|
+
if (turns.length === limit) break;
|
|
173
|
+
if (isSummarizationMessage(message) || !visibleToUser(message)) continue;
|
|
174
|
+
const text = visibleText(message).trim();
|
|
175
|
+
if (!text) continue;
|
|
176
|
+
const speaker = message.type === 'human' ? 'user' : 'assistant';
|
|
177
|
+
turns.push(`${speaker}: ${text.slice(0, maxChars)}`);
|
|
178
|
+
}
|
|
179
|
+
return turns.reverse();
|
|
180
|
+
}
|
|
181
|
+
|
|
143
182
|
/**
|
|
144
183
|
* The summarization middleware's bookkeeping message — never shown to users.
|
|
145
184
|
* LangChain 1.4 writes it as a HUMAN message tagged
|
|
@@ -171,18 +210,8 @@ export async function transformTranscript(
|
|
|
171
210
|
for (const message of messages) {
|
|
172
211
|
const toolMsg = message.type === 'tool' ? (message as ToolMessage) : null;
|
|
173
212
|
const kw = (message.additional_kwargs ?? {}) as Record<string, unknown>;
|
|
174
|
-
const isExtractionContext = message.type === 'ai' && Boolean(kw.attachment);
|
|
175
213
|
|
|
176
|
-
if (
|
|
177
|
-
message.type !== 'system' &&
|
|
178
|
-
message.type !== 'tool' &&
|
|
179
|
-
!kw.isError &&
|
|
180
|
-
!isExtractionContext &&
|
|
181
|
-
// `view_attachment` re-attaching an offloaded payload — model-facing.
|
|
182
|
-
!isAttachmentViewMessage(message) &&
|
|
183
|
-
// A chat gateway's delivery notice — the model's, never a user message.
|
|
184
|
-
!isDeliveryNoticeMessage(message)
|
|
185
|
-
) {
|
|
214
|
+
if (visibleToUser(message)) {
|
|
186
215
|
const reasoning = reasoningToText(kw.reasoning);
|
|
187
216
|
const attachments =
|
|
188
217
|
message.type === 'human'
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type ChannelMediaSource,
|
|
16
16
|
type PreparedAttachments,
|
|
17
17
|
} from '../attachments';
|
|
18
|
+
import type { ChannelThreadFile } from '../attachments/room-media';
|
|
18
19
|
import { parseChannelServiceDids } from '../channels/auth';
|
|
19
20
|
import {
|
|
20
21
|
channelAttachmentInputs,
|
|
@@ -58,26 +59,64 @@ export function channelTurnLinks(
|
|
|
58
59
|
}));
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* A channel turn's files as `room-media.ts` posts them to the session
|
|
64
|
+
* thread: the first occurrence of each file (a file sent twice in one
|
|
65
|
+
* message is posted once), with its position in the message.
|
|
66
|
+
*/
|
|
67
|
+
export function channelThreadFiles(req: TurnFiles): ChannelThreadFile[] {
|
|
68
|
+
if (req.client !== 'channel' || !req.channel?.attachments?.length) return [];
|
|
69
|
+
const bindingId = req.channel.bindingId;
|
|
70
|
+
const seen = new Set<string>();
|
|
71
|
+
const files: ChannelThreadFile[] = [];
|
|
72
|
+
req.channel.attachments.forEach((file, index) => {
|
|
73
|
+
const ref = channelAttachmentRef(file.sha256);
|
|
74
|
+
if (seen.has(ref)) return;
|
|
75
|
+
seen.add(ref);
|
|
76
|
+
files.push({
|
|
77
|
+
ref,
|
|
78
|
+
index,
|
|
79
|
+
link: { url: file.url, sha256: file.sha256, bindingId },
|
|
80
|
+
filename: file.filename,
|
|
81
|
+
mimetype: file.mimetype,
|
|
82
|
+
size: file.size,
|
|
83
|
+
kind: file.kind,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
return files;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The https origins channel files are fetched from: the gateways of `CHANNEL_SERVICE_DID`. */
|
|
90
|
+
export function channelGatewayOrigins(
|
|
91
|
+
channelServiceDid: string | undefined,
|
|
92
|
+
): string[] {
|
|
93
|
+
return channelMediaOrigins(parseChannelServiceDids(channelServiceDid));
|
|
94
|
+
}
|
|
95
|
+
|
|
61
96
|
/**
|
|
62
97
|
* The channel source of a turn in `sessionId`: a reference resolves to the
|
|
63
98
|
* turn's own files first, then to the links kept for the session; only the
|
|
64
|
-
* gateways of `CHANNEL_SERVICE_DID` are fetched from.
|
|
99
|
+
* gateways of `CHANNEL_SERVICE_DID` are fetched from. The store also names
|
|
100
|
+
* the room copy of a file posted to the session thread, which serves it
|
|
101
|
+
* when its link fails or has expired (`loadAttachmentBytes`).
|
|
65
102
|
*/
|
|
66
103
|
export function turnChannelMediaSource(input: {
|
|
67
104
|
sessionId: string;
|
|
68
105
|
turnLinks: ReadonlyArray<ChannelMediaLink & { ref: string }>;
|
|
69
|
-
store: Pick<ChannelAttachmentLinkStore, 'get'> | null;
|
|
106
|
+
store: Pick<ChannelAttachmentLinkStore, 'get' | 'eventIdOf'> | null;
|
|
70
107
|
channelServiceDid: string | undefined;
|
|
71
108
|
}): ChannelMediaSource {
|
|
109
|
+
const store = input.store;
|
|
72
110
|
return createChannelMediaSource({
|
|
73
|
-
origins:
|
|
74
|
-
parseChannelServiceDids(input.channelServiceDid),
|
|
75
|
-
),
|
|
111
|
+
origins: channelGatewayOrigins(input.channelServiceDid),
|
|
76
112
|
resolve: async (ref) => {
|
|
77
113
|
const own = input.turnLinks.find((link) => link.ref === ref);
|
|
78
114
|
if (own) return own;
|
|
79
|
-
return
|
|
115
|
+
return store?.get(input.sessionId, ref);
|
|
80
116
|
},
|
|
117
|
+
...(store
|
|
118
|
+
? { roomEventOf: (ref: string) => store.eventIdOf(input.sessionId, ref) }
|
|
119
|
+
: {}),
|
|
81
120
|
});
|
|
82
121
|
}
|
|
83
122
|
|
package/src/do/turn-metadata.ts
CHANGED
|
@@ -62,6 +62,16 @@ export interface PriorMetadataState {
|
|
|
62
62
|
sessionRunId?: string;
|
|
63
63
|
currentEntityDid?: string;
|
|
64
64
|
loadedPlugins?: string[];
|
|
65
|
+
/** The plugins whose operating guides the prompt carried (`operating-guides.ts`). */
|
|
66
|
+
promptGuides?: string[];
|
|
67
|
+
/** The plugins whose usage the thread's messages carry (`operating-guides.ts`). */
|
|
68
|
+
capabilityUsage?: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function strings(value: unknown): string[] | undefined {
|
|
72
|
+
return Array.isArray(value)
|
|
73
|
+
? value.filter((entry): entry is string => typeof entry === 'string')
|
|
74
|
+
: undefined;
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
function priorString(
|
|
@@ -75,15 +85,17 @@ function priorString(
|
|
|
75
85
|
export function priorMetadataState(
|
|
76
86
|
prior: Record<string, unknown>,
|
|
77
87
|
): PriorMetadataState {
|
|
78
|
-
const loaded = prior['loadedPlugins'];
|
|
88
|
+
const loaded = strings(prior['loadedPlugins']);
|
|
89
|
+
const promptGuides = strings(prior['promptGuides']);
|
|
90
|
+
const capabilityUsage = strings(prior['capabilityUsage']);
|
|
79
91
|
return {
|
|
80
92
|
editorRoomId: priorString(prior, 'editorRoomId'),
|
|
81
93
|
spaceId: priorString(prior, 'spaceId'),
|
|
82
94
|
sessionRunId: priorString(prior, 'sessionRunId'),
|
|
83
95
|
currentEntityDid: priorString(prior, 'currentEntityDid'),
|
|
84
|
-
...(
|
|
85
|
-
|
|
86
|
-
|
|
96
|
+
...(loaded ? { loadedPlugins: loaded } : {}),
|
|
97
|
+
...(promptGuides ? { promptGuides } : {}),
|
|
98
|
+
...(capabilityUsage ? { capabilityUsage } : {}),
|
|
87
99
|
};
|
|
88
100
|
}
|
|
89
101
|
|