@ixo/oracle-runtime-workers 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +5 -2
  2. package/package.json +1 -1
  3. package/src/core/context-budget.ts +201 -0
  4. package/src/core/context-window.ts +289 -0
  5. package/src/core/env.ts +38 -0
  6. package/src/core/main-agent-types.ts +46 -0
  7. package/src/core/main-agent.ts +86 -9
  8. package/src/core/middlewares/context-guard.ts +270 -0
  9. package/src/core/middlewares/result-cap.ts +180 -0
  10. package/src/core/middlewares/summarization.ts +87 -6
  11. package/src/core/middlewares/tool-marks.ts +209 -0
  12. package/src/core/openrouter-pricing.ts +42 -1
  13. package/src/core/read-result-tool.ts +77 -0
  14. package/src/core/runtime-context.ts +7 -0
  15. package/src/core/sub-agent-fallback.ts +20 -3
  16. package/src/core/wrap-plugin-tool.ts +24 -3
  17. package/src/do/ambient.ts +17 -0
  18. package/src/do/contracts.ts +249 -0
  19. package/src/do/result-store-test-do.ts +88 -0
  20. package/src/do/result-store.ts +339 -0
  21. package/src/do/run-buffer.ts +204 -0
  22. package/src/do/run-coordinator.ts +572 -0
  23. package/src/do/run-request.ts +52 -0
  24. package/src/do/run-store-test-do.ts +130 -0
  25. package/src/do/run-store.ts +604 -0
  26. package/src/do/session-id-migration.ts +85 -0
  27. package/src/do/speaker-prefix.ts +37 -0
  28. package/src/do/sse-stream.ts +567 -400
  29. package/src/do/transcript.ts +238 -0
  30. package/src/do/turn-body.ts +10 -0
  31. package/src/do/user-oracle-do.ts +1019 -169
  32. package/src/matrix/gateway-do.ts +230 -29
  33. package/src/matrix/group-chat-summarizer.ts +107 -0
  34. package/src/matrix/group-chat.ts +1163 -0
  35. package/src/matrix/inbox-store.ts +23 -3
  36. package/src/matrix/ingest.ts +54 -11
  37. package/src/matrix/reply-chain.ts +34 -25
  38. package/src/owner-store/ixo-vfs-store.ts +12 -5
  39. package/src/owner-store/matrix-media-store.ts +12 -7
  40. package/src/owner-store/measure.ts +39 -0
  41. package/src/owner-store/migrating-store.ts +9 -3
  42. package/src/owner-store/types.ts +14 -4
  43. package/src/plugin-api/tool-helper.ts +9 -1
  44. package/src/plugin-api/types.ts +86 -0
  45. package/src/plugins/index.ts +3 -0
  46. package/src/plugins/matrix-group-chats/README.md +70 -0
  47. package/src/plugins/matrix-group-chats/index.ts +132 -0
  48. package/src/plugins/matrix-group-chats/tools.ts +172 -0
  49. package/src/plugins/tasks/tasks-tools.ts +4 -0
  50. package/src/shell/app.ts +78 -0
  51. package/src/sqlite/cache-config.ts +27 -6
  52. package/src/sqlite/chunk-layout.ts +14 -0
  53. package/src/sqlite/database.ts +188 -19
  54. package/src/sqlite/do-vfs.ts +963 -99
  55. package/src/sqlite/index.ts +13 -0
  56. package/src/sqlite/page-tier.ts +668 -0
  57. package/src/sqlite/sqlite-saver.ts +81 -0
  58. package/src/tasks/scheduler.ts +134 -3
  59. package/src/tasks/store.ts +22 -0
@@ -46,6 +46,20 @@ export function ensureInboxTable(sql: SqlStorage): void {
46
46
  attempts INTEGER NOT NULL DEFAULT 0
47
47
  ) WITHOUT ROWID`,
48
48
  );
49
+ // Columns added after the table first shipped (the group-chat gate reads
50
+ // them on replay): CREATE TABLE IF NOT EXISTS leaves an existing table alone.
51
+ const columns = new Set(
52
+ sql
53
+ .exec<{ name: string }>(`PRAGMA table_info(turn_inbox)`)
54
+ .toArray()
55
+ .map((row) => row.name),
56
+ );
57
+ if (!columns.has('mentions_bot'))
58
+ sql.exec(
59
+ `ALTER TABLE turn_inbox ADD COLUMN mentions_bot INTEGER NOT NULL DEFAULT 0`,
60
+ );
61
+ if (!columns.has('in_reply_to'))
62
+ sql.exec(`ALTER TABLE turn_inbox ADD COLUMN in_reply_to TEXT`);
49
63
  }
50
64
 
51
65
  /** Record a message before its turn starts. A repeat of the same event id is a no-op. */
@@ -56,8 +70,8 @@ export function insertInboxRow(
56
70
  ): void {
57
71
  sql.exec(
58
72
  `INSERT OR IGNORE INTO turn_inbox
59
- (event_id, room_id, sender, ts, body, thread_root_id, attachment, received_at, attempts)
60
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`,
73
+ (event_id, room_id, sender, ts, body, thread_root_id, attachment, received_at, attempts, mentions_bot, in_reply_to)
74
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
61
75
  msg.eventId,
62
76
  msg.roomId,
63
77
  msg.sender,
@@ -66,6 +80,8 @@ export function insertInboxRow(
66
80
  msg.threadRootId ?? null,
67
81
  msg.attachment ? JSON.stringify(msg.attachment) : null,
68
82
  receivedAt,
83
+ msg.mentionsBot ? 1 : 0,
84
+ msg.inReplyTo ?? null,
69
85
  );
70
86
  }
71
87
 
@@ -112,13 +128,15 @@ type InboxSqlRow = {
112
128
  attachment: string | null;
113
129
  received_at: number;
114
130
  attempts: number;
131
+ mentions_bot: number;
132
+ in_reply_to: string | null;
115
133
  } & Record<string, SqlStorageValue>;
116
134
 
117
135
  /** Every pending row, oldest first (the order the messages arrived in). */
118
136
  export function listInboxRows(sql: SqlStorage): InboxRow[] {
119
137
  return sql
120
138
  .exec<InboxSqlRow>(
121
- `SELECT event_id, room_id, sender, ts, body, thread_root_id, attachment, received_at, attempts
139
+ `SELECT event_id, room_id, sender, ts, body, thread_root_id, attachment, received_at, attempts, mentions_bot, in_reply_to
122
140
  FROM turn_inbox ORDER BY ts ASC, event_id ASC`,
123
141
  )
124
142
  .toArray()
@@ -132,6 +150,8 @@ export function listInboxRows(sql: SqlStorage): InboxRow[] {
132
150
  ...(row.attachment
133
151
  ? { attachment: parseAttachment(row.attachment) }
134
152
  : {}),
153
+ ...(Number(row.mentions_bot) ? { mentionsBot: true } : {}),
154
+ ...(row.in_reply_to ? { inReplyTo: row.in_reply_to } : {}),
135
155
  receivedAt: Number(row.received_at),
136
156
  attempts: Number(row.attempts),
137
157
  }));
@@ -7,6 +7,12 @@
7
7
  * so the sender must resolve to a user DID before anything else happens.
8
8
  * Own-message, stale and duplicate gating happens upstream, in the bot SDK's
9
9
  * pipeline; `gateway-do.ts` adapts its `BotMessage`s into `InboundMessage`s.
10
+ *
11
+ * Threads are sessions, exactly as on Node: the thread root of a message is
12
+ * its session id (`reply-chain.ts` resolves it; a bare message is its own
13
+ * root), the reply is posted into that thread, and a Portal session — whose
14
+ * id is its marker event — is continued by replying inside its thread. The
15
+ * main timeline of a room never carries a reply and is never a session.
10
16
  */
11
17
 
12
18
  import { didToAliasPart } from '../do/contracts';
@@ -28,23 +34,40 @@ export interface InboundMessage {
28
34
  ts: number;
29
35
  /** Text body; empty for a media-only message. */
30
36
  body: string;
31
- /** Thread root when the message was sent inside a thread. */
37
+ /**
38
+ * The message's thread root: the `m.thread` root, the root of a quote-reply
39
+ * chain (`gateway-do.ts` resolves it before the offer), or — for a bare
40
+ * message — nothing: the message roots its own thread (`threadRootIdOf`).
41
+ */
32
42
  threadRootId?: string;
33
43
  /** Present for media messages — fetched and decrypted at turn time. */
34
44
  attachment?: InboundAttachment;
45
+ /** The bot is in the message's `m.mentions` (the group-chat gate answers a mention). */
46
+ mentionsBot?: boolean;
47
+ /** The event a quote-reply answers (`m.in_reply_to`); a reply to the bot's own message is answered too. */
48
+ inReplyTo?: string;
35
49
  }
36
50
 
37
51
  export interface IngestTurn {
38
52
  userDid: string;
39
53
  matrixUserId: string;
40
54
  roomId: string;
41
- /** Present when the user wrote inside a thread; replies go there too. */
42
- threadId?: string;
43
- /** `matrix:<roomId>` (main timeline) or `thread:<threadRootId>`. */
55
+ /** The thread the reply goes to: the message's root, or the message itself. */
56
+ threadId: string;
57
+ /** The thread root event id — the session is the thread (Node parity). */
44
58
  sessionId: string;
45
59
  /** Debounced messages joined with newlines, oldest first. */
46
60
  message: string;
47
61
  eventIds: string[];
62
+ /**
63
+ * The message the turn's metadata comes from — the latest text message of
64
+ * the burst, else its first (Node's `sourceEvent`): its mentions and reply
65
+ * target drive the group-chat gate, its timestamp dates the turn.
66
+ */
67
+ sourceEventId: string;
68
+ ts: number;
69
+ mentionsBot: boolean;
70
+ inReplyTo?: string;
48
71
  /** Media shared in the same debounce window, oldest first. */
49
72
  attachments?: InboundAttachment[];
50
73
  }
@@ -52,6 +75,12 @@ export interface IngestTurn {
52
75
  export interface IngestDeps {
53
76
  /** The DID that forms the oracle half of the user↔oracle room alias. */
54
77
  oracleDid: string;
78
+ /**
79
+ * The bot's Matrix user id. Its first occurrence in a message is rewritten
80
+ * to `(USER MENTIONED YOU @AI_AGENT)` before the agent sees it, as the Node
81
+ * bridge does; omit to leave messages as sent.
82
+ */
83
+ botUserId?: string;
55
84
  debounceMs?: number;
56
85
  /** `m.room.canonical_alias` of the room, when known. */
57
86
  canonicalAlias(roomId: string): string | null;
@@ -66,6 +95,8 @@ interface Pending {
66
95
  }
67
96
 
68
97
  export const DEFAULT_DEBOUNCE_MS = 500;
98
+ /** What the bot's own user id becomes in a message body (the Node bridge's wording). */
99
+ export const MENTIONED_BOT_MARKER = '(USER MENTIONED YOU @AI_AGENT)';
69
100
 
70
101
  /** `did-ixo-ixo1abc` → `did:ixo:ixo1abc`. Only the first two dashes are separators. */
71
102
  export function aliasPartToDid(part: string): string | null {
@@ -119,8 +150,11 @@ export function resolveUserDid(opts: {
119
150
  return fromAlias ?? fromSender;
120
151
  }
121
152
 
122
- export function sessionIdFor(roomId: string, threadRootId?: string): string {
123
- return threadRootId ? `thread:${threadRootId}` : `matrix:${roomId}`;
153
+ /** The thread a message belongs to: its resolved root, else the message itself. */
154
+ export function threadRootIdOf(
155
+ msg: Pick<InboundMessage, 'eventId' | 'threadRootId'>,
156
+ ): string {
157
+ return msg.threadRootId ?? msg.eventId;
124
158
  }
125
159
 
126
160
  export class IngestPipeline {
@@ -144,7 +178,7 @@ export class IngestPipeline {
144
178
  });
145
179
  if (!userDid) return 'unmapped';
146
180
 
147
- const key = `${msg.roomId}|${sessionIdFor(msg.roomId, msg.threadRootId)}`;
181
+ const key = `${msg.roomId}|${threadRootIdOf(msg)}`;
148
182
  const existing = this.pending.get(key);
149
183
  if (existing) {
150
184
  clearTimeout(existing.timer);
@@ -177,27 +211,36 @@ export class IngestPipeline {
177
211
  this.pending.delete(key);
178
212
  const first = entry.messages[0];
179
213
  if (!first) return;
180
- const threadId = first.threadRootId;
214
+ const threadId = threadRootIdOf(first);
181
215
  const texts = entry.messages.map((m) => m.body).filter((b) => b.trim());
216
+ const source =
217
+ [...entry.messages].reverse().find((m) => m.body.trim()) ?? first;
182
218
  const attachments = entry.messages.flatMap((m) =>
183
219
  m.attachment ? [m.attachment] : [],
184
220
  );
185
221
  // A file-only send still needs a user message the agent can act on —
186
222
  // the Node bridge's wording.
187
- const message =
223
+ const raw =
188
224
  texts.length > 0
189
225
  ? texts.join('\n')
190
226
  : attachments.length === 1
191
227
  ? `User shared a file: ${attachments[0]?.filename ?? 'file'}`
192
228
  : `User shared ${attachments.length} file(s): ${attachments.map((a) => a.filename).join(', ')}`;
229
+ const message = this.deps.botUserId
230
+ ? raw.replace(this.deps.botUserId, MENTIONED_BOT_MARKER)
231
+ : raw;
193
232
  const turn: IngestTurn = {
194
233
  userDid: entry.userDid,
195
234
  matrixUserId: first.sender,
196
235
  roomId: first.roomId,
197
- sessionId: sessionIdFor(first.roomId, threadId),
236
+ threadId,
237
+ sessionId: threadId,
198
238
  message,
199
239
  eventIds: entry.messages.map((m) => m.eventId),
200
- ...(threadId ? { threadId } : {}),
240
+ sourceEventId: source.eventId,
241
+ ts: source.ts,
242
+ mentionsBot: source.mentionsBot === true,
243
+ ...(source.inReplyTo ? { inReplyTo: source.inReplyTo } : {}),
201
244
  ...(attachments.length > 0 ? { attachments } : {}),
202
245
  };
203
246
  try {
@@ -1,15 +1,18 @@
1
1
  /**
2
- * Quote-reply → thread resolution — the port of the Node listener bridge's
3
- * `getThreadRoot`. A client that quote-replies (`m.in_reply_to`) to a
4
- * message that lives in a thread, without adding the `m.thread` relation
5
- * itself, still means "continue that thread": walk the reply chain upward
6
- * until an event that carries `m.thread` (→ its root) or a bare event.
2
+ * Thread root of an inbound message — the port of the Node listener bridge's
3
+ * `getThreadRoot`, and with it Node's rule for room conversations: EVERY
4
+ * message has a thread root, and that root is the session.
7
5
  *
8
- * Unlike Node — where every bare room message opens its own thread-session —
9
- * this runtime keeps the main timeline as ONE session (`matrix:<roomId>`), so
10
- * a chain that ends on a bare event resolves to "no thread" (main timeline)
11
- * rather than to that event. Results are memoised per event id with a
12
- * bounded cache; cycles and missing events terminate the walk.
6
+ * - a message inside a thread (`m.thread`) → the thread's root;
7
+ * - a bare message (no relation) → the message itself: the reply opens a
8
+ * thread on it, and later replies in that thread continue the session;
9
+ * - a quote-reply (`m.in_reply_to` without `m.thread`, from a client that
10
+ * does not thread natively) → walk the reply chain upward until an event
11
+ * that carries `m.thread` (→ its root) or a bare event (→ that event).
12
+ *
13
+ * The walk ends where Node's does: on a cycle, on an event that cannot be
14
+ * fetched, or past the hop bound, the last event reached is the root.
15
+ * Results are memoised per event id with a bounded cache.
13
16
  */
14
17
 
15
18
  export interface MatrixRelatesTo {
@@ -35,7 +38,7 @@ export function inReplyToOf(rel: MatrixRelatesTo | undefined): string | null {
35
38
  return typeof id === 'string' && id.length > 0 ? id : null;
36
39
  }
37
40
 
38
- /** Bounded event-id → thread-root ('' = main timeline) memo. */
41
+ /** Bounded event-id → thread-root memo. */
39
42
  export class ThreadRootCache {
40
43
  private readonly map = new Map<string, string>();
41
44
 
@@ -56,7 +59,7 @@ export class ThreadRootCache {
56
59
 
57
60
  export interface ResolveReplyChainInput {
58
61
  eventId: string;
59
- /** The event's own relation (from the clear or wire content). */
62
+ /** The event's own relation (from the clear or wire content); undefined for a bare message. */
60
63
  relatesTo: MatrixRelatesTo | undefined;
61
64
  /** Fetches a room event's `m.relates_to` (null when the event is unavailable). */
62
65
  fetchRelatesTo: (eventId: string) => Promise<MatrixRelatesTo | null>;
@@ -66,44 +69,50 @@ export interface ResolveReplyChainInput {
66
69
  }
67
70
 
68
71
  /**
69
- * Thread root for the event, or `null` for the main timeline. Never throws:
70
- * a fetch failure ends the walk at the last known event (→ main timeline).
72
+ * The thread root of the event (see the module comment). Never throws: a
73
+ * fetch failure ends the walk at the event that could not be fetched.
71
74
  */
72
75
  export async function resolveReplyChainRoot(
73
76
  input: ResolveReplyChainInput,
74
- ): Promise<string | null> {
77
+ ): Promise<string> {
75
78
  const { eventId, relatesTo, fetchRelatesTo, cache } = input;
76
79
  const maxHops = input.maxHops ?? 25;
77
- const remember = (ids: string[], root: string | null): string | null => {
78
- for (const id of ids) cache.set(id, root ?? '');
80
+ const remember = (ids: string[], root: string): string => {
81
+ for (const id of ids) cache.set(id, root);
79
82
  return root;
80
83
  };
81
84
 
82
85
  const own = threadRootOf(relatesTo);
83
86
  if (own) return remember([eventId], own);
84
87
  const firstParent = inReplyToOf(relatesTo);
85
- if (!firstParent) return remember([eventId], null);
88
+ // A bare message roots its own thread.
89
+ if (!firstParent) return remember([eventId], eventId);
86
90
 
87
91
  const path = [eventId];
88
92
  const visited = new Set<string>([eventId]);
89
- let cursor: string | null = firstParent;
93
+ let cursor: string = firstParent;
90
94
  let hops = 0;
91
- while (cursor && !visited.has(cursor) && hops < maxHops) {
92
- hops += 1;
95
+ for (;;) {
93
96
  visited.add(cursor);
94
97
  path.push(cursor);
95
98
  const known = cache.get(cursor);
96
- if (known !== undefined) return remember(path, known === '' ? null : known);
99
+ if (known !== undefined) return remember(path, known);
97
100
  let parentRel: MatrixRelatesTo | null;
98
101
  try {
99
102
  parentRel = await fetchRelatesTo(cursor);
100
103
  } catch {
101
104
  parentRel = null;
102
105
  }
103
- if (parentRel === null) return remember(path, null);
106
+ // Unavailable: the quoted event is the best root there is (Node's
107
+ // fallback is the last cursor as well).
108
+ if (parentRel === null) return remember(path, cursor);
104
109
  const parentRoot = threadRootOf(parentRel);
105
110
  if (parentRoot) return remember(path, parentRoot);
106
- cursor = inReplyToOf(parentRel);
111
+ const next = inReplyToOf(parentRel);
112
+ // A bare ancestor is the root of the chain.
113
+ if (!next) return remember(path, cursor);
114
+ hops += 1;
115
+ if (visited.has(next) || hops >= maxHops) return remember(path, cursor);
116
+ cursor = next;
107
117
  }
108
- return remember(path, null);
109
118
  }
@@ -50,6 +50,7 @@ import {
50
50
  type OwnerCopy,
51
51
  type OwnerStore,
52
52
  type SaveResult,
53
+ type SaveHints,
53
54
  } from './types';
54
55
 
55
56
  const VFS_RESOURCE = 'ixo:filesystem';
@@ -233,9 +234,11 @@ export class IxoVfsOwnerStore implements OwnerStore {
233
234
  * Atomic replace, always delete + re-create, never a new version:
234
235
  *
235
236
  * 1. drop stale `.uploading-*` temp files a crashed flush left behind;
236
- * 2. measure the gzipped length in a counting pass (the VFS needs
237
- * `Upload-Length` at session creation — `Upload-Defer-Length` is not
238
- * supported — and gzip output is deterministic for identical input);
237
+ * 2. take the gzipped length from `hints` (the object measured it in the
238
+ * same pass as its checksum) or, without a hint, count it in a pass
239
+ * of its own (the VFS needs `Upload-Length` at session creation —
240
+ * `Upload-Defer-Length` is not supported — and gzip output is
241
+ * deterministic for identical input);
239
242
  * 3. upload gzip → a TEMP path next to the real one: one `POST /files`
240
243
  * when it fits in a single part, else a tus session in 5 MiB parts
241
244
  * (`tusUpload`, resumable per part);
@@ -247,7 +250,10 @@ export class IxoVfsOwnerStore implements OwnerStore {
247
250
  * file is cleaned up then). Peak memory is one part (5 MiB) plus fixed
248
251
  * overhead, whatever the file size.
249
252
  */
250
- async save(snapshot: FileSnapshot): Promise<SaveResult> {
253
+ async save(
254
+ snapshot: FileSnapshot,
255
+ hints: SaveHints = {},
256
+ ): Promise<SaveResult> {
251
257
  const listing = await this.listOracleFiles();
252
258
  const existing = listing.filter((f) => f.path === this.path);
253
259
  const stale = listing.filter((f) =>
@@ -261,7 +267,8 @@ export class IxoVfsOwnerStore implements OwnerStore {
261
267
  });
262
268
  }
263
269
 
264
- const gzLength = await countStream(gzipStream(snapshot.open()));
270
+ const gzLength =
271
+ hints.gzippedLength ?? (await countStream(gzipStream(snapshot.open())));
265
272
  const tempPath = `${this.path}${TEMP_PATH_INFIX}${Date.now().toString(36)}`;
266
273
  const uploaded =
267
274
  gzLength <= SINGLE_SHOT_MAX_BYTES
@@ -22,6 +22,7 @@ import {
22
22
  type OwnerCopy,
23
23
  type OwnerStore,
24
24
  type SaveResult,
25
+ type SaveHints,
25
26
  } from './types';
26
27
 
27
28
  /**
@@ -89,19 +90,23 @@ export class MatrixMediaOwnerStore implements OwnerStore {
89
90
  }
90
91
 
91
92
  /**
92
- * Streamed as well: the snapshot is opened three times — a header peek, a
93
- * gzip pass that only counts (the homeserver needs the exact ciphertext
94
- * length up front; gzip output is deterministic for identical input, the
95
- * VFS store relies on the same property) and the gzip pass that is
96
- * uploaded — and never held whole.
93
+ * Streamed as well: the snapshot is opened for a header peek, for a gzip
94
+ * pass that only counts unless the caller measured the length already
95
+ * (`hints`; the homeserver needs the exact ciphertext length up front and
96
+ * gzip output is deterministic for identical input) and for the gzip pass
97
+ * that is uploaded — and never held whole.
97
98
  */
98
- async save(snapshot: FileSnapshot): Promise<SaveResult> {
99
+ async save(
100
+ snapshot: FileSnapshot,
101
+ hints: SaveHints = {},
102
+ ): Promise<SaveResult> {
99
103
  const checked = await assertSqliteStream(
100
104
  snapshot.open(),
101
105
  `MatrixMediaOwnerStore: refusing to save — the working copy (${snapshot.size} bytes)`,
102
106
  );
103
107
  await checked.cancel().catch(() => undefined);
104
- const gzLength = await countStream(gzipStream(snapshot.open()));
108
+ const gzLength =
109
+ hints.gzippedLength ?? (await countStream(gzipStream(snapshot.open())));
105
110
  const { eventId } = await this.gateway.uploadUserSnapshotStream(
106
111
  this.userDid,
107
112
  this.storageKey,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * One streamed pass over a snapshot that yields BOTH what a flush needs
3
+ * before it uploads: the SHA-256 of the raw SQLite bytes (the "did anything
4
+ * change" gate against the last upload) and the exact gzipped length (the
5
+ * VFS needs `Upload-Length` when the tus session is created). Before this
6
+ * helper the object hashed in one pass and the store counted the gzip in a
7
+ * second one; with the R2 page tier every pass over a cold file is one R2
8
+ * GET per segment, so the two are folded together and the upload itself is
9
+ * the only other read.
10
+ *
11
+ * The length is exact for the store's own gzip: `gzipStream` is the same
12
+ * `CompressionStream('gzip')` the stores pipe the upload through, and its
13
+ * output is deterministic for identical input.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { countStream, gzipStream } from './types';
17
+
18
+ export interface SnapshotMeasure {
19
+ /** Hex SHA-256 of the raw (uncompressed) bytes. */
20
+ sha256Hex: string;
21
+ /** Byte length of `gzipStream(<the same bytes>)`. */
22
+ gzippedLength: number;
23
+ }
24
+
25
+ export async function measureForSave(
26
+ stream: ReadableStream<Uint8Array>,
27
+ ): Promise<SnapshotMeasure> {
28
+ const hash = createHash('sha256');
29
+ const tapped = stream.pipeThrough(
30
+ new TransformStream<Uint8Array, Uint8Array>({
31
+ transform(chunk, controller) {
32
+ hash.update(chunk);
33
+ controller.enqueue(chunk);
34
+ },
35
+ }),
36
+ );
37
+ const gzippedLength = await countStream(gzipStream(tapped));
38
+ return { sha256Hex: hash.digest('hex'), gzippedLength };
39
+ }
@@ -20,7 +20,13 @@
20
20
  * Matrix copy so no second copy of the user's history lingers.
21
21
  */
22
22
  import { VfsNoDelegationError } from './ixo-vfs-store';
23
- import type { FileSnapshot, OwnerCopy, OwnerStore, SaveResult } from './types';
23
+ import type {
24
+ FileSnapshot,
25
+ OwnerCopy,
26
+ OwnerStore,
27
+ SaveHints,
28
+ SaveResult,
29
+ } from './types';
24
30
 
25
31
  export interface MigratingOwnerStoreOptions {
26
32
  /** System of record (IXO VFS). */
@@ -104,8 +110,8 @@ export class MigratingOwnerStore implements OwnerStore {
104
110
  * "not on VFS yet" (no delegation) — so the object keeps the copy dirty
105
111
  * and retries; Matrix media is never written.
106
112
  */
107
- async save(snapshot: FileSnapshot): Promise<SaveResult> {
108
- return this.primary.save(snapshot);
113
+ async save(snapshot: FileSnapshot, hints?: SaveHints): Promise<SaveResult> {
114
+ return this.primary.save(snapshot, hints);
109
115
  }
110
116
 
111
117
  /**
@@ -15,9 +15,13 @@
15
15
  * Both directions STREAM: `load()` hands back the raw (already gunzipped)
16
16
  * SQLite bytes as a `ReadableStream` the object writes into its VFS chunk by
17
17
  * chunk, and `save()` takes a `FileSnapshot` — a re-openable, consistent
18
- * view of the working copy — that the store reads as many times as it needs
19
- * (the VFS store measures the gzipped length in one pass and uploads in a
20
- * second). Nothing on this path ever holds the whole file in memory — the
18
+ * view of the working copy — that the store reads as many times as it needs.
19
+ * The object measures the hash and the gzipped length in ONE pass
20
+ * (`measureForSave`) and hands the length in as a `SaveHints`, so a store
21
+ * only reads the snapshot once more, for the upload itself; a store called
22
+ * without the hint counts the gzip in a pass of its own. That matters with
23
+ * the R2 page tier, where every pass over a cold file is one R2 GET per
24
+ * segment. Nothing on this path ever holds the whole file in memory — the
21
25
  * legacy Matrix media path included: the gateway hands the media over as a
22
26
  * stream and decryption, gunzip and the SQLite header check run chunk by
23
27
  * chunk in the object. Compression is the store's concern. `etag` lets the object skip a reload
@@ -26,7 +30,7 @@
26
30
  export interface OwnerStore {
27
31
  readonly kind: 'matrix' | 'vfs';
28
32
  load(): Promise<OwnerCopy | null>;
29
- save(snapshot: FileSnapshot): Promise<SaveResult>;
33
+ save(snapshot: FileSnapshot, hints?: SaveHints): Promise<SaveResult>;
30
34
  /** Cheap upstream freshness probe — null when unsupported/unknown. */
31
35
  head(): Promise<{ etag: string } | null>;
32
36
  /** Remove the durable copy (user asked to be forgotten). */
@@ -60,6 +64,12 @@ export interface OwnerCopy {
60
64
  fromLegacy?: true;
61
65
  }
62
66
 
67
+ /** What the caller already measured about the snapshot (see `measureForSave`). */
68
+ export interface SaveHints {
69
+ /** Byte length of `gzipStream(snapshot.open())`; spares the store its counting pass. */
70
+ gzippedLength?: number;
71
+ }
72
+
63
73
  export interface SaveResult {
64
74
  etag: string;
65
75
  /** Bytes actually sent upstream (compressed size on the VFS path). */
@@ -17,6 +17,11 @@ export interface ToolHelperOptions {
17
17
  * `manifest.visibility` setting.
18
18
  */
19
19
  visibility?: PluginTool['visibility'];
20
+ /**
21
+ * `'read'` for a tool with no external effect (safe to run again when a
22
+ * turn resumes after a reset); omitted or `'write'` otherwise.
23
+ */
24
+ effect?: PluginTool['effect'];
20
25
  /**
21
26
  * Billing gate marker, kept for source compatibility with the Node
22
27
  * runtime's plugin contract. The Workers runtime has no commerce lane, so
@@ -49,7 +54,7 @@ export function tool(
49
54
  if (!options || typeof options !== 'object') {
50
55
  throw new TypeError('tool(handler, options): `options` is required.');
51
56
  }
52
- const { name, description, schema, visibility, billing } = options;
57
+ const { name, description, schema, visibility, billing, effect } = options;
53
58
  if (!name || typeof name !== 'string') {
54
59
  throw new TypeError(
55
60
  'tool(handler, options): `options.name` must be a non-empty string.',
@@ -78,5 +83,8 @@ export function tool(
78
83
  if (billing !== undefined) {
79
84
  pluginTool.billing = billing;
80
85
  }
86
+ if (effect !== undefined) {
87
+ pluginTool.effect = effect;
88
+ }
81
89
  return pluginTool;
82
90
  }
@@ -78,6 +78,56 @@ export interface MatrixEvent {
78
78
  readonly originServerTs?: number;
79
79
  }
80
80
 
81
+ export interface MatrixRoomInfo {
82
+ isDirect: boolean;
83
+ memberCount: number;
84
+ joinedMemberIds: string[];
85
+ /**
86
+ * Whether the deployment runs the group-chat lane in group rooms
87
+ * (`MATRIX_GROUP_ROOMS=gate` on the gateway). False by default: the bot
88
+ * then never speaks in a group room and the channel-memory tools are not
89
+ * offered.
90
+ */
91
+ groupLane: boolean;
92
+ }
93
+
94
+ /** One compacted batch of a group room's messages (the Node `ChannelMemoryChunk`). */
95
+ export interface ChannelMemoryChunk {
96
+ id: string;
97
+ roomId: string;
98
+ summary: string;
99
+ fromEventId: string;
100
+ toEventId: string;
101
+ fromTimestamp: number;
102
+ toTimestamp: number;
103
+ messageCount: number;
104
+ participants: string[];
105
+ threadIds: string[];
106
+ tier: number;
107
+ createdAt: number;
108
+ }
109
+
110
+ export interface PinnedRoomFact {
111
+ id: string;
112
+ roomId: string;
113
+ fact: string;
114
+ pinnedByDid: string;
115
+ sourceEventId?: string;
116
+ createdAt: number;
117
+ }
118
+
119
+ export interface ChannelRoomMember {
120
+ matrixUserId: string;
121
+ displayName: string;
122
+ did?: string;
123
+ }
124
+
125
+ export interface ChannelMemoryRecall {
126
+ chunks: ChannelMemoryChunk[];
127
+ pinnedFacts: PinnedRoomFact[];
128
+ members: ChannelRoomMember[];
129
+ }
130
+
81
131
  /** Memory-enriched user context (mirrors `state.userContext`). */
82
132
  export type UserContextData = Record<string, unknown>;
83
133
 
@@ -521,6 +571,34 @@ export interface RuntimeContext<TConfig = MergedConfig> {
521
571
  ) => Promise<string>;
522
572
  getRoomState: (roomId: string) => Promise<RoomStateSnapshot>;
523
573
  getEventById: (roomId: string, eventId: string) => Promise<MatrixEvent>;
574
+ /**
575
+ * Whether a room is a direct room or a group room — the Node
576
+ * `MatrixManager.getRoomInfo` (`is_direct` on the create event, else
577
+ * ≤ 2 joined members), cached by the gateway. Absent on hosts without a
578
+ * Matrix gateway.
579
+ */
580
+ roomInfo?: (roomId: string) => Promise<MatrixRoomInfo>;
581
+ /**
582
+ * A group room's channel memory (the `matrix-group-chats` plugin's
583
+ * tools read and write it): compacted summary chunks, pinned facts and
584
+ * the member roster, kept by the gateway per room. Absent on hosts
585
+ * without a Matrix gateway.
586
+ */
587
+ channelMemory?: {
588
+ recall: (roomId: string, limit?: number) => Promise<ChannelMemoryRecall>;
589
+ search: (
590
+ roomId: string,
591
+ query: string,
592
+ limit?: number,
593
+ ) => Promise<ChannelMemoryChunk[]>;
594
+ pin: (args: {
595
+ roomId: string;
596
+ fact: string;
597
+ pinnedByDid: string;
598
+ sourceEventId?: string;
599
+ }) => Promise<PinnedRoomFact>;
600
+ unpin: (roomId: string, factId: string) => Promise<boolean>;
601
+ };
524
602
  /**
525
603
  * Credentials of the oracle's own crypto-less device for plugins that run
526
604
  * their own Matrix client (editor, flows). Minted and kept by the gateway.
@@ -790,6 +868,14 @@ export interface PluginTool {
790
868
  handler: (args: unknown, ctx: RuntimeContext) => Promise<unknown>;
791
869
  /** Override visibility — by default inherits from the plugin's `manifest.visibility`. */
792
870
  visibility?: 'always' | 'on-demand' | 'silent';
871
+ /**
872
+ * Whether calling the tool changes anything outside the conversation.
873
+ * `'read'` tools may be executed again when a turn is resumed after a
874
+ * runtime reset; a `'write'` (the default for undeclared tools) call that
875
+ * was cut off mid-execution is never repeated — the model is told its
876
+ * outcome is unknown. See `core/middlewares/tool-marks.ts`.
877
+ */
878
+ effect?: 'read' | 'write';
793
879
  /**
794
880
  * Billing gate. `'contracted'` marks a tool that performs paid contracted
795
881
  * work: the runtime binds it only while the turn runs inside an active work
@@ -21,6 +21,7 @@ import { UserPreferencesPlugin } from './user-preferences';
21
21
  import { PortalPlugin } from './portal';
22
22
  import { AGUIPlugin } from './agui';
23
23
  import { AttachmentsPlugin } from './attachments';
24
+ import { MatrixGroupChatsPlugin } from './matrix-group-chats';
24
25
 
25
26
  export * from './composio';
26
27
  export * from './editor';
@@ -46,6 +47,7 @@ export const userPreferencesPlugin = new UserPreferencesPlugin();
46
47
  export const portalPlugin = new PortalPlugin();
47
48
  export const aguiPlugin = new AGUIPlugin();
48
49
  export const attachmentsPlugin = new AttachmentsPlugin();
50
+ export const matrixGroupChatsPlugin = new MatrixGroupChatsPlugin();
49
51
  /** TasksPlugin is already an object (defineOraclePlugin), not a class. */
50
52
  export const tasksPlugin = TasksPlugin;
51
53
 
@@ -67,4 +69,5 @@ export const BUNDLED_WORKERS_PLUGINS = [
67
69
  portalPlugin,
68
70
  aguiPlugin,
69
71
  attachmentsPlugin,
72
+ matrixGroupChatsPlugin,
70
73
  ] as const satisfies readonly OraclePlugin[];