@ixo/oracle-runtime-workers 0.10.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 (48) 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 +29 -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 +227 -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 +875 -146
  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/plugin-api/tool-helper.ts +9 -1
  39. package/src/plugin-api/types.ts +86 -0
  40. package/src/plugins/index.ts +3 -0
  41. package/src/plugins/matrix-group-chats/README.md +70 -0
  42. package/src/plugins/matrix-group-chats/index.ts +132 -0
  43. package/src/plugins/matrix-group-chats/tools.ts +172 -0
  44. package/src/plugins/tasks/tasks-tools.ts +4 -0
  45. package/src/shell/app.ts +66 -0
  46. package/src/sqlite/sqlite-saver.ts +81 -0
  47. package/src/tasks/scheduler.ts +134 -3
  48. package/src/tasks/store.ts +22 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Matrix Group Chats — the Workers port of
3
+ * `packages/oracle-runtime/src/plugins/matrix-group-chats`.
4
+ *
5
+ * STATUS: under development, off by default. The port is complete and
6
+ * tested but not reviewed for production, and it differs from Node in
7
+ * several ways — every difference and the open items are listed in
8
+ * `README.md` next to this file. A deployment turns the lane on with
9
+ * `MATRIX_GROUP_ROOMS=gate` on the gateway; until then the bot never speaks
10
+ * in a room with more than two members and this plugin offers no tools.
11
+ *
12
+ * On Node the plugin owned both halves: the per-turn gate (an agent
13
+ * middleware: answer only when mentioned, replied to or already in an active
14
+ * thread; capture every message into channel memory) and the per-room
15
+ * memory with its four tools. On Workers the gate and the memory run in the
16
+ * Matrix gateway, the one object every room message passes through
17
+ * (`src/matrix/group-chat.ts`); this plugin is the agent-side half: the four
18
+ * channel-memory tools, offered — exactly as on Node — only to a session in
19
+ * a Matrix room with more than two members. The `[DisplayName]: ` speaker
20
+ * prefix is applied by the user object's turn preparer (`speaker-prefix.ts`).
21
+ *
22
+ * The lane is opt-in per deployment: `MATRIX_GROUP_ROOMS=gate` on the
23
+ * gateway. By default (`silent`) the bot never speaks in a group room and
24
+ * this plugin offers nothing. The gate's knobs are read by the gateway from
25
+ * its env (`GROUP_CHAT_ACTIVE_THREAD_TTL_MS`, `GROUP_CHAT_REQUIRE_POWER_LEVEL`,
26
+ * `GROUP_CHAT_ROOM_INFO_TTL_MS`); they are declared here too so an oracle's
27
+ * config validates them the way Node's did.
28
+ */
29
+ import { z } from 'zod';
30
+ import { OraclePlugin } from '../../plugin-api/oracle-plugin';
31
+ import type {
32
+ PluginManifest,
33
+ PluginTool,
34
+ RuntimeContext,
35
+ } from '../../plugin-api/types';
36
+ import { buildChannelMemoryTools } from './tools';
37
+
38
+ const configSchema = z.object({
39
+ GROUP_CHAT_ACTIVE_THREAD_TTL_MS: z.coerce
40
+ .number()
41
+ .int()
42
+ .min(60_000)
43
+ .default(30 * 60 * 1000)
44
+ .describe('How long a thread stays "active with the bot" after a reply.'),
45
+ GROUP_CHAT_REQUIRE_POWER_LEVEL: z.coerce
46
+ .number()
47
+ .int()
48
+ .min(0)
49
+ .default(0)
50
+ .describe(
51
+ 'Extra minimum power level the bot must have before posting (0 = use the room default).',
52
+ ),
53
+ GROUP_CHAT_ROOM_INFO_TTL_MS: z.coerce
54
+ .number()
55
+ .int()
56
+ .min(60_000)
57
+ .default(30 * 60 * 1000)
58
+ .describe('How long roomInfo (membership, DM flag) stays cached.'),
59
+ });
60
+
61
+ const manifest: PluginManifest = {
62
+ title: 'Matrix Group Chats',
63
+ summary:
64
+ 'Lets the oracle participate cleanly in Matrix group rooms: it only replies when mentioned, replied to, or already in an active thread, and keeps an FTS5-searchable compacted memory of every room it sits in.',
65
+ whenToUse: [
66
+ 'A user asks what was said or decided earlier in this Matrix group room.',
67
+ 'A user asks who is in the room or what their role is.',
68
+ 'A durable fact should survive across threads (deadline, decision, project context) — pin it.',
69
+ 'You need to recall the gist of prior conversation before answering a multi-step group request.',
70
+ ],
71
+ whenNotToUse: [
72
+ 'Single-user DMs — the plugin only acts in rooms with more than 2 members.',
73
+ 'Long-term personal memory about a specific user — use the Memory plugin.',
74
+ 'Verbatim text of a specific Matrix event — use Matrix history directly.',
75
+ ],
76
+ examples: [
77
+ {
78
+ user: 'What did we agree about the launch date?',
79
+ thought:
80
+ 'Search channel memory for "launch date" before answering — the decision may be in an older chunk.',
81
+ tool: 'search_channel_memory',
82
+ args: { query: 'launch date' },
83
+ },
84
+ {
85
+ user: 'Remember that Alice owns the redesign.',
86
+ thought: 'Persist that as a pinned fact for the group room.',
87
+ tool: 'pin_room_fact',
88
+ args: { fact: 'Alice owns the redesign.' },
89
+ },
90
+ ],
91
+ tags: ['matrix', 'group-chat', 'memory'],
92
+ category: 'communication',
93
+ visibility: 'on-demand',
94
+ stability: 'beta',
95
+ };
96
+
97
+ export class MatrixGroupChatsPlugin extends OraclePlugin {
98
+ static readonly NAME = 'matrix-group-chats';
99
+
100
+ readonly name = MatrixGroupChatsPlugin.NAME;
101
+
102
+ readonly version = '1.0.0';
103
+
104
+ readonly manifest = manifest;
105
+
106
+ override readonly configSchema = configSchema;
107
+
108
+ /**
109
+ * Per request: the four channel-memory tools only when the deployment
110
+ * runs the group-chat lane and the session is in a Matrix group room
111
+ * (more than two members). DM, portal and task sessions get an empty
112
+ * list, so the agent never sees them there.
113
+ */
114
+ override async getRequestTools(rtCtx: RuntimeContext): Promise<PluginTool[]> {
115
+ const roomId = rtCtx.session.roomId;
116
+ if (rtCtx.session.client !== 'matrix' || !roomId) return [];
117
+ const roomInfo = rtCtx.matrix.roomInfo;
118
+ if (!roomInfo || !rtCtx.matrix.channelMemory) return [];
119
+ try {
120
+ const info = await roomInfo(roomId);
121
+ if (!info.groupLane || info.isDirect || info.memberCount <= 2) return [];
122
+ } catch (err) {
123
+ rtCtx.logger.warn(
124
+ `[matrix-group-chats] roomInfo failed for ${roomId}: ${err instanceof Error ? err.message : String(err)} — skipping tools`,
125
+ );
126
+ return [];
127
+ }
128
+ return buildChannelMemoryTools();
129
+ }
130
+ }
131
+
132
+ export { buildChannelMemoryTools } from './tools';
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The four channel-memory tools of a Matrix group room — the Node
3
+ * `plugins/matrix-group-chats/tools.ts` verbatim in contract; the store is
4
+ * the gateway's (`RuntimeContext.matrix.channelMemory`).
5
+ */
6
+ import { z } from 'zod';
7
+ import type {
8
+ ChannelMemoryChunk,
9
+ PluginTool,
10
+ RuntimeContext,
11
+ } from '../../plugin-api/types';
12
+
13
+ const formatChunk = (chunk: ChannelMemoryChunk) => ({
14
+ id: chunk.id,
15
+ tier: chunk.tier,
16
+ fromTimestamp: new Date(chunk.fromTimestamp).toISOString(),
17
+ toTimestamp: new Date(chunk.toTimestamp).toISOString(),
18
+ messageCount: chunk.messageCount,
19
+ participants: chunk.participants,
20
+ threadIds: chunk.threadIds,
21
+ summary: chunk.summary,
22
+ });
23
+
24
+ function requireMemory(rtCtx: RuntimeContext) {
25
+ const memory = rtCtx.matrix.channelMemory;
26
+ if (!memory)
27
+ throw new Error(
28
+ 'Channel memory is not available on this host (no Matrix gateway).',
29
+ );
30
+ return memory;
31
+ }
32
+
33
+ function requireRoomId(rtCtx: RuntimeContext): string {
34
+ const roomId = rtCtx.session.roomId;
35
+ if (!roomId)
36
+ throw new Error(
37
+ 'No active Matrix room — channel-memory tools require a roomId in the current session.',
38
+ );
39
+ return roomId;
40
+ }
41
+
42
+ /**
43
+ * Visibility `always` on each tool so they bypass the capability gate —
44
+ * the gating is `getRequestTools`, which returns them only for a session in
45
+ * a Matrix group room.
46
+ */
47
+ export function buildChannelMemoryTools(): PluginTool[] {
48
+ return [
49
+ {
50
+ name: 'recall_channel_memory',
51
+ description:
52
+ 'Read recent channel memory: most recent compacted summary chunks, pinned facts, and the member roster for the current group room. Use this to understand what has been happening in this channel before responding.',
53
+ visibility: 'always',
54
+ effect: 'read',
55
+ schema: z.object({
56
+ limit: z
57
+ .number()
58
+ .int()
59
+ .min(1)
60
+ .max(30)
61
+ .optional()
62
+ .describe(
63
+ 'Number of recent summary chunks to return (default 10, max 30).',
64
+ ),
65
+ }),
66
+ async handler(args, rtCtx) {
67
+ const roomId = requireRoomId(rtCtx);
68
+ const memory = requireMemory(rtCtx);
69
+ const parsed = z
70
+ .object({ limit: z.number().int().min(1).max(30).optional() })
71
+ .parse(args);
72
+ const recalled = await memory.recall(roomId, parsed.limit ?? 10);
73
+ return {
74
+ chunks: recalled.chunks.map(formatChunk),
75
+ pinnedFacts: recalled.pinnedFacts,
76
+ members: recalled.members,
77
+ };
78
+ },
79
+ },
80
+ {
81
+ name: 'search_channel_memory',
82
+ description:
83
+ 'Keyword-search the compacted summary chunks for the current group room. Use to find earlier discussions of a topic, decisions, names, dates. Returns matching chunks with their summaries.',
84
+ visibility: 'always',
85
+ effect: 'read',
86
+ schema: z.object({
87
+ query: z
88
+ .string()
89
+ .min(1)
90
+ .describe('Keywords or phrase to search for in channel memory.'),
91
+ limit: z
92
+ .number()
93
+ .int()
94
+ .min(1)
95
+ .max(30)
96
+ .optional()
97
+ .describe('Maximum matching chunks to return (default 10).'),
98
+ }),
99
+ async handler(args, rtCtx) {
100
+ const roomId = requireRoomId(rtCtx);
101
+ const memory = requireMemory(rtCtx);
102
+ const parsed = z
103
+ .object({
104
+ query: z.string().min(1),
105
+ limit: z.number().int().min(1).max(30).optional(),
106
+ })
107
+ .parse(args);
108
+ const chunks = await memory.search(
109
+ roomId,
110
+ parsed.query,
111
+ parsed.limit ?? 10,
112
+ );
113
+ if (chunks.length === 0)
114
+ return { chunks: [], note: 'No matching chunks.' };
115
+ return { chunks: chunks.map(formatChunk) };
116
+ },
117
+ },
118
+ {
119
+ name: 'pin_room_fact',
120
+ description:
121
+ "Save a durable fact to the current group room's memory — survives across threads and sessions. Use for decisions, deadlines, member roles, project context. Surface in your reply so users know it has been saved.",
122
+ visibility: 'always',
123
+ schema: z.object({
124
+ fact: z
125
+ .string()
126
+ .min(3)
127
+ .max(500)
128
+ .describe('Concise factual statement to remember (max 500 chars).'),
129
+ sourceEventId: z
130
+ .string()
131
+ .optional()
132
+ .describe('Optional Matrix event id this fact was derived from.'),
133
+ }),
134
+ async handler(args, rtCtx) {
135
+ const roomId = requireRoomId(rtCtx);
136
+ const memory = requireMemory(rtCtx);
137
+ const parsed = z
138
+ .object({
139
+ fact: z.string().min(3).max(500),
140
+ sourceEventId: z.string().optional(),
141
+ })
142
+ .parse(args);
143
+ const trimmed = parsed.fact.trim();
144
+ if (!trimmed) throw new Error('Pinned fact must not be empty.');
145
+ const pinned = await memory.pin({
146
+ roomId,
147
+ fact: trimmed.slice(0, 500),
148
+ pinnedByDid: rtCtx.user.did,
149
+ ...(parsed.sourceEventId
150
+ ? { sourceEventId: parsed.sourceEventId }
151
+ : {}),
152
+ });
153
+ return { factId: pinned.id, fact: pinned.fact };
154
+ },
155
+ },
156
+ {
157
+ name: 'unpin_room_fact',
158
+ description:
159
+ "Remove a previously pinned fact from this group room's memory by its factId.",
160
+ visibility: 'always',
161
+ schema: z.object({
162
+ factId: z.string().min(1).describe('The id of the fact to remove.'),
163
+ }),
164
+ async handler(args, rtCtx) {
165
+ const roomId = requireRoomId(rtCtx);
166
+ const memory = requireMemory(rtCtx);
167
+ const parsed = z.object({ factId: z.string().min(1) }).parse(args);
168
+ return { ok: await memory.unpin(roomId, parsed.factId) };
169
+ },
170
+ },
171
+ ];
172
+ }
@@ -145,6 +145,7 @@ function previewTask(): PluginTool {
145
145
  },
146
146
  {
147
147
  name: 'preview_task',
148
+ effect: 'read',
148
149
  description:
149
150
  'Validate a candidate task spec and return its next run times WITHOUT creating anything. ALWAYS call this before create_task and show the user the result. IMPORTANT: scheduled runs are fresh background sessions with NO memory of this conversation — put every ID, URL, and name the run needs into the intent text.',
150
151
  schema: previewInput,
@@ -214,6 +215,7 @@ function listMyTasks(): PluginTool {
214
215
  },
215
216
  {
216
217
  name: 'list_my_tasks',
218
+ effect: 'read',
217
219
  description:
218
220
  "List the user's scheduled tasks — id, title, status, schedule, next run. A task with `awaitingApprovalSince` has an approval request waiting for the user in their room — tell them and point them there. Optionally filter by status.",
219
221
  schema: listInput,
@@ -246,6 +248,7 @@ function getTask(): PluginTool {
246
248
  },
247
249
  {
248
250
  name: 'get_task',
251
+ effect: 'read',
249
252
  description:
250
253
  'Fetch one task: full intent body, status, schedule, last run result, and the consecutive-failure count if it has been failing.',
251
254
  schema: taskIdSchema,
@@ -406,6 +409,7 @@ function suggestSpecFix(): PluginTool {
406
409
  },
407
410
  {
408
411
  name: 'suggest_spec_fix',
412
+ effect: 'read',
409
413
  description:
410
414
  'For a failing task: returns the current intent plus the last error so you can propose a revised intent to the user. Apply the fix with update_task ONLY after the user agrees — never auto-apply.',
411
415
  schema: taskIdSchema,
package/src/shell/app.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * work unchanged. Every authenticated route resolves the caller's
7
7
  * `UserOracleDO` and forwards to it; streaming turns are proxied byte-for-byte.
8
8
  */
9
+ import { parseTranscriptPageQuery } from '../do/transcript';
9
10
  import { Hono } from 'hono';
10
11
  import { cors } from 'hono/cors';
11
12
  import type { OracleWorkerEnv, TurnIdentity } from '../do/contracts';
@@ -283,6 +284,54 @@ export function createShell(
283
284
  headers: { 'content-type': 'application/json' },
284
285
  });
285
286
  });
287
+ // One turn-aligned page of the transcript (docs/plans/transcript-paging.md):
288
+ // the newest `limit` turns, the turns `before=` a cursor, or what came
289
+ // `after=` one. The legacy listing above stays the whole transcript.
290
+ app.get('/sessions/:sessionId/messages', async (c) => {
291
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
292
+ const parsed = parseTranscriptPageQuery(c.req.query());
293
+ if (!parsed.ok)
294
+ return c.json({ statusCode: 400, message: parsed.message }, 400);
295
+ const page = await userStub(c.env, identity.userDid).listMessagesPage(
296
+ identity,
297
+ c.req.param('sessionId'),
298
+ parsed.options,
299
+ );
300
+ if (!page.ok)
301
+ return c.json({ statusCode: page.status, message: page.message }, 400);
302
+ return new Response(page.json, {
303
+ headers: { 'content-type': 'application/json' },
304
+ });
305
+ });
306
+
307
+ // --- durable runs (docs/plans/durable-runs.md) -------------------------------
308
+ // The session's active run, if any: a client that reloads asks this first,
309
+ // then re-joins the run's stream.
310
+ app.get('/sessions/:sessionId/run', async (c) => {
311
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
312
+ const run = await userStub(c.env, identity.userDid).sessionRun(
313
+ identity,
314
+ c.req.param('sessionId'),
315
+ );
316
+ return c.json({ run });
317
+ });
318
+ // Re-join a run's SSE stream after a cursor (`?after=<seq>`, the `id:` of
319
+ // the last frame the client saw). Frames after it are replayed, then the
320
+ // live tail until `done`. A run that ended replays and closes at once.
321
+ app.get('/runs/:runId', async (c) => {
322
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
323
+ const after = c.req.query('after') ?? '0';
324
+ const res = await userStub(c.env, identity.userDid).fetch(
325
+ `https://user-oracle/runs/${encodeURIComponent(c.req.param('runId'))}?after=${encodeURIComponent(after)}`,
326
+ { headers: { 'x-identity': JSON.stringify(identity) } },
327
+ );
328
+ const headers = new Headers(res.headers);
329
+ headers.set(
330
+ 'access-control-expose-headers',
331
+ 'x-request-id, x-run-id, x-run-status',
332
+ );
333
+ return new Response(res.body, { status: res.status, headers });
334
+ });
286
335
  app.post('/messages/:sessionId', async (c) => {
287
336
  const identity = identityOf(c.get('auth'), c.req.raw.headers);
288
337
  const sessionId = c.req.param('sessionId');
@@ -522,6 +571,23 @@ export function createShell(
522
571
  app.get('/debug/realtime', async (c) =>
523
572
  c.json(await userStub(c.env, c.get('auth').userDid).realtimeStatus()),
524
573
  );
574
+ app.get('/debug/runs', async (c) =>
575
+ c.json(await userStub(c.env, c.get('auth').userDid).runsStatus()),
576
+ );
577
+ // The context budget a model gets on this user's object (window origin,
578
+ // thresholds, saved-result store stats). `?model=` defaults to the
579
+ // deployment's main model; `?byoProvider=` resolves a BYO-native id.
580
+ app.get('/debug/context', async (c) => {
581
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
582
+ return c.json(
583
+ await userStub(c.env, identity.userDid).contextStatus(
584
+ identity,
585
+ c.req.query('model') || undefined,
586
+ c.req.query('byoProvider') || undefined,
587
+ c.req.query('session') || undefined,
588
+ ),
589
+ );
590
+ });
525
591
  app.post('/debug/reauth-prompt/reset', async (c) => {
526
592
  await userStub(c.env, c.get('auth').userDid).debugResetReauthThrottle();
527
593
  return c.json({ reset: true });
@@ -94,6 +94,19 @@ type MessageRow = {
94
94
  message: Uint8Array | string;
95
95
  };
96
96
 
97
+ /** A transcript row's position in the listing order. */
98
+ export interface ThreadMessageAnchor {
99
+ createdAt: string;
100
+ rowid: number;
101
+ }
102
+
103
+ /** A transcript row with what a pager needs to continue from it. */
104
+ export interface ThreadMessageRow {
105
+ message: BaseMessage;
106
+ messageId: string;
107
+ anchor: ThreadMessageAnchor;
108
+ }
109
+
97
110
  interface PendingWriteColumn {
98
111
  task_id: string;
99
112
  channel: string;
@@ -360,6 +373,74 @@ export class SqliteSaver extends BaseCheckpointSaver {
360
373
  return Promise.all(rows.map((row) => this.loadMessage(row.message)));
361
374
  }
362
375
 
376
+ /**
377
+ * Where a transcript row sits in the listing order (`created_at`, then
378
+ * `rowid`), or null when the thread has no row with that message id. A
379
+ * paging cursor names a message id and is resolved through this at query
380
+ * time: rowids move when a checkpoint rewrites the thread's rows, message
381
+ * ids never do.
382
+ */
383
+ async findThreadMessageAnchor(
384
+ threadId: string,
385
+ messageId: string,
386
+ ): Promise<ThreadMessageAnchor | null> {
387
+ await this.setup();
388
+ const row = await this.db.get<{ created_at: string; rowid: number }>(
389
+ 'SELECT created_at, rowid FROM messages WHERE thread_id = ? AND message_id = ?',
390
+ [threadId, messageId],
391
+ );
392
+ return row ? { createdAt: row.created_at, rowid: row.rowid } : null;
393
+ }
394
+
395
+ /**
396
+ * A slice of the transcript next to an anchor: `older` walks back from it
397
+ * (newest first, so a caller can stop once it holds enough turns), `newer`
398
+ * walks forward (oldest first). Without an anchor `older` starts at the
399
+ * newest row and `newer` at the oldest. The anchor row itself is left out
400
+ * unless `inclusive`.
401
+ */
402
+ async listThreadMessageRows(
403
+ threadId: string,
404
+ opts: {
405
+ direction: 'older' | 'newer';
406
+ anchor?: ThreadMessageAnchor | null;
407
+ /** Include the anchor row itself. */
408
+ inclusive?: boolean;
409
+ limit: number;
410
+ },
411
+ ): Promise<ThreadMessageRow[]> {
412
+ await this.setup();
413
+ const older = opts.direction === 'older';
414
+ const cmp = older ? '<' : '>';
415
+ const rowCmp = opts.inclusive ? `${cmp}=` : cmp;
416
+ const order = older ? 'DESC' : 'ASC';
417
+ const anchored = opts.anchor
418
+ ? ` AND (created_at ${cmp} ? OR (created_at = ? AND rowid ${rowCmp} ?))`
419
+ : '';
420
+ const rows = await this.db.exec<{
421
+ message: Uint8Array | string;
422
+ message_id: string;
423
+ created_at: string;
424
+ rowid: number;
425
+ }>(
426
+ `SELECT message, message_id, created_at, rowid FROM messages WHERE thread_id = ?${anchored} ORDER BY created_at ${order}, rowid ${order} LIMIT ?`,
427
+ [
428
+ threadId,
429
+ ...(opts.anchor
430
+ ? [opts.anchor.createdAt, opts.anchor.createdAt, opts.anchor.rowid]
431
+ : []),
432
+ opts.limit,
433
+ ],
434
+ );
435
+ return Promise.all(
436
+ rows.map(async (row) => ({
437
+ message: await this.loadMessage(row.message),
438
+ messageId: row.message_id,
439
+ anchor: { createdAt: row.created_at, rowid: row.rowid },
440
+ })),
441
+ );
442
+ }
443
+
363
444
  /**
364
445
  * The thread's messages whose stored plain content (`message_content`)
365
446
  * contains `contentContains`, oldest first — a SQL `LIKE` pre-filter, so