@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
@@ -0,0 +1,70 @@
1
+ # matrix-group-chats (Workers)
2
+
3
+ **Status: under development — off by default.** The port of the Node
4
+ runtime's `matrix-group-chats` plugin is complete and tested (unit, local
5
+ harness, devnet), but it has not been reviewed for production use and it
6
+ differs from Node in the ways listed below. Until that review, every
7
+ deployment runs with the gateway's `MATRIX_GROUP_ROOMS=silent` (the default):
8
+ the bot never speaks in a room with more than two members and captures
9
+ nothing there. It only talks in direct rooms — the user↔oracle room and the
10
+ task rooms it creates. Set `MATRIX_GROUP_ROOMS=gate` on the gateway to turn
11
+ the lane on for a deployment; `answer` replies to everything (Node without
12
+ the plugin).
13
+
14
+ How it works when enabled: [operations — Rooms: group chats](../../../docs/operations.md#rooms-group-chats).
15
+ Env knobs: [configuration](../../../docs/configuration.md#matrix).
16
+ Drills: `pnpm test:e2e:group` (gate) and `GROUP_ROOMS=silent pnpm test:e2e:group`
17
+ (the default) in `apps/qiforge-workers-example`; unit tests in
18
+ `src/matrix/group-chat.test.ts` and this directory.
19
+
20
+ ## What is the Node contract, unchanged
21
+
22
+ - The gate and its order: a direct room always answers; in a group room the
23
+ bot answers a message that mentions it (`m.mentions.user_ids`), a
24
+ quote-reply to one of its own messages, or a message in a thread it
25
+ answered within `GROUP_CHAT_ACTIVE_THREAD_TTL_MS` (30 min); otherwise it
26
+ stays silent. An answer is skipped when the bot's power level is below the
27
+ room's `m.room.message` threshold or `GROUP_CHAT_REQUIRE_POWER_LEVEL`.
28
+ - Every group message, answered or not, is captured into the room's channel
29
+ memory; batches are compacted with the Node summarizer prompt, verbatim,
30
+ into summary chunks (FTS5 search with the porter tokenizer, LIKE fallback,
31
+ `OR` of the words, ranked), next to pinned facts and the member roster.
32
+ - The four tools with Node's names, descriptions and schemas:
33
+ `recall_channel_memory`, `search_channel_memory`, `pin_room_fact`,
34
+ `unpin_room_fact` — offered only to a session in a group room.
35
+ - A group message reaches the model as `[DisplayName]: …`; the bot's own
36
+ user id in a message body becomes `(USER MENTIONED YOU @AI_AGENT)`; the
37
+ message's `additional_kwargs` carry `senderDid`, `senderMatrixUserId`,
38
+ `senderDisplayName`, `threadId`, `eventId`.
39
+ - Compaction at 20 buffered messages, and just in time before an answer when
40
+ 5 or more are buffered, capped at 3 s so the reply is not held up.
41
+ - The `delegation_required` prompt for a member without a delegation is
42
+ posted into the room the message came from, as on Node.
43
+
44
+ ## Where it differs from Node
45
+
46
+ | Area | Node | Workers | Why |
47
+ | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
48
+ | Where the gate runs | An agent middleware inside the speaker's turn: every group message was dispatched as a turn (typing indicator, `work_status` card, the message appended to the speaker's thread history) and then ended silently. | The gateway decides before a turn exists: an ignored message shows no typing, posts no card, wakes no user object and is not appended to the speaker's thread history. | Cheaper, and a member who cannot boot a user object never fails a turn for a message nobody would answer. Cross-person context came from channel memory on both runtimes. |
49
+ | Direct-room rule | `is_direct` on the create event, else ≤ 2 joined members. | The same two rules, plus: a room whose canonical alias is a user↔oracle alias of this oracle is direct whatever its member count. | A real user↔oracle room also holds the rooms appservice bot and the memory-engine bot (four members on devnet); Node's rule alone would gate the oracle's own conversation with its user. |
50
+ | Active threads after a restart | Scanned the room's last 100 messages for the bot's own replies. | A durable `group_bot_threads` table written whenever the bot decides to answer; nothing is scanned. | The gateway restarts far more often than a Node process; a table read is local and exact. Threads answered before this build are not in it — one mention re-activates them. |
51
+ | Where channel memory lives | One SQLite file per room on the oracle's disk, gzipped and uploaded into the room as an encrypted media event (`qiforge.channel_memory.v1`) after every change, restored from there on a cold start. | The gateway's Durable Object SQLite (`group_*` tables), which survives deploys on its own; nothing is uploaded to the room. | No disk and no need for the room copy on Workers. A Node-era snapshot in a room is **not imported**; the tables use Node's column layout, so an importer is a small addition. |
52
+ | Who runs the summarizer | The oracle process, `session-title` model role. | The speaker's user object (`summarizeGroupMessages` RPC), same model role and prompt. | The gateway script holds no model keys. |
53
+ | Compaction buffer | In memory; lost on restart. | Durable (`group_message_buffer`); a replayed message is not buffered twice. | Gateway resets are routine. |
54
+ | Idle compaction | After 5 quiet minutes. | Not ported; the next engagement compacts the buffer (threshold and just-in-time triggers are unchanged). | The gateway's alarm belongs to the bot SDK. A quiet room's summary exists by the first moment anyone could read it. |
55
+ | Tier rollups (weekly / monthly) | Code present, never scheduled. | Not ported; `tier` stays 1. | Dead on Node. |
56
+ | Prompt injection of roster, pinned facts, recent messages (`buildSessionContext`) and `getCurrentSpeaker` | Code present, never called. | Not ported. | Dead on Node. |
57
+ | Speaker prefix on file messages | Strings only. | Also the first text block of block content (a caption with an attachment). | Workers' attachment pipeline produces block content. |
58
+ | Members without a delegation | Answered (a turn needed no delegation; memory calls failed softly). | On a VFS-backed deployment their user object cannot boot, so a mention from them ends in the "try again" notice. | The Workers owner-copy design: the user's history file lives in their VFS and needs their delegation. The Portal deposits one for whoever invites the oracle into a room, not for the other members. |
59
+ | Enabling | On by default (`features: { 'matrix-group-chats': false }` to turn off). | `MATRIX_GROUP_ROOMS` on the gateway: `silent` (default), `gate`, `answer`. Disabling the plugin only removes the tools; the gateway policy decides whether the bot speaks. | The gate is runtime infrastructure here, not a plugin middleware. |
60
+
61
+ ## Open items before this can be the default
62
+
63
+ - Review the divergences above with the product owner.
64
+ - Decide whether members without a delegation should be prompted in group
65
+ rooms (the Portal's `delegation_required` listener is deliberately not
66
+ mounted) or answered without a user file.
67
+ - Decide whether Node-era channel-memory snapshots need importing.
68
+ - Decide whether task rooms should be recognised by the gateway's own record
69
+ of the rooms it created rather than by member count (a task room with a
70
+ third member becomes a group room today).
@@ -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');
@@ -471,6 +520,18 @@ export function createShell(
471
520
  app.post('/debug/storage/flush', async (c) =>
472
521
  c.json(await userStub(c.env, c.get('auth').userDid).flushToOwnerStore()),
473
522
  );
523
+ app.post('/debug/storage/tier-flush', async (c) => {
524
+ const body: unknown = await c.req.json().catch(() => ({}));
525
+ const rec = typeof body === 'object' && body !== null ? body : {};
526
+ const force = Reflect.get(rec, 'force') === true;
527
+ const max = Number(Reflect.get(rec, 'maxSegments'));
528
+ return c.json(
529
+ await userStub(c.env, c.get('auth').userDid).tierFlush({
530
+ force,
531
+ ...(Number.isInteger(max) && max > 0 && { maxSegments: max }),
532
+ }),
533
+ );
534
+ });
474
535
  app.post('/debug/storage/reset', async (c) =>
475
536
  c.json(await userStub(c.env, c.get('auth').userDid).resetWorkingCopy()),
476
537
  );
@@ -510,6 +571,23 @@ export function createShell(
510
571
  app.get('/debug/realtime', async (c) =>
511
572
  c.json(await userStub(c.env, c.get('auth').userDid).realtimeStatus()),
512
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
+ });
513
591
  app.post('/debug/reauth-prompt/reset', async (c) => {
514
592
  await userStub(c.env, c.get('auth').userDid).debugResetReauthThrottle();
515
593
  return c.json({ reset: true });
@@ -19,22 +19,43 @@ export function parseChunkCacheBytes(
19
19
  raw: string | undefined,
20
20
  warn: (message: string) => void = () => undefined,
21
21
  ): number {
22
- if (raw === undefined || raw.trim() === '') return DEFAULT_CHUNK_CACHE_BYTES;
22
+ return parseByteSize(raw, {
23
+ name: 'CHUNK_CACHE_BYTES',
24
+ fallback: DEFAULT_CHUNK_CACHE_BYTES,
25
+ min: MIN_CHUNK_CACHE_BYTES,
26
+ max: MAX_CHUNK_CACHE_BYTES,
27
+ warn,
28
+ });
29
+ }
30
+
31
+ /** Same grammar for any byte-sized env knob (`TIER_HOT_BUDGET_BYTES`, ...). */
32
+ export function parseByteSize(
33
+ raw: string | undefined,
34
+ options: {
35
+ name: string;
36
+ fallback: number;
37
+ min: number;
38
+ max: number;
39
+ warn?: (message: string) => void;
40
+ },
41
+ ): number {
42
+ const warn = options.warn ?? (() => undefined);
43
+ if (raw === undefined || raw.trim() === '') return options.fallback;
23
44
  const match = /^\s*(\d+)\s*([kKmM]?)\s*$/.exec(raw);
24
45
  if (!match) {
25
46
  warn(
26
- `CHUNK_CACHE_BYTES=${JSON.stringify(raw)} is not a byte count — using the default ${DEFAULT_CHUNK_CACHE_BYTES}`,
47
+ `${options.name}=${JSON.stringify(raw)} is not a byte count — using the default ${options.fallback}`,
27
48
  );
28
- return DEFAULT_CHUNK_CACHE_BYTES;
49
+ return options.fallback;
29
50
  }
30
51
  const unit = match[2]?.toLowerCase();
31
52
  const multiplier = unit === 'k' ? 1024 : unit === 'm' ? 1024 * 1024 : 1;
32
53
  const bytes = Number(match[1]) * multiplier;
33
- if (bytes < MIN_CHUNK_CACHE_BYTES || bytes > MAX_CHUNK_CACHE_BYTES) {
54
+ if (bytes < options.min || bytes > options.max) {
34
55
  warn(
35
- `CHUNK_CACHE_BYTES=${raw} is outside ${MIN_CHUNK_CACHE_BYTES}..${MAX_CHUNK_CACHE_BYTES} — using the default ${DEFAULT_CHUNK_CACHE_BYTES}`,
56
+ `${options.name}=${raw} is outside ${options.min}..${options.max} — using the default ${options.fallback}`,
36
57
  );
37
- return DEFAULT_CHUNK_CACHE_BYTES;
58
+ return options.fallback;
38
59
  }
39
60
  return bytes;
40
61
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The storage grain shared by the DO VFS and the R2 page tier. Kept in its
3
+ * own module so `page-tier.ts` can import it without a cycle through
4
+ * `do-vfs.ts`.
5
+ */
6
+
7
+ /** Bytes per SQLite page. Matches `PRAGMA page_size=4096`. */
8
+ export const VFS_PAGE_SIZE = 4096;
9
+
10
+ /** SQLite pages packed into one stored row (billing grain). */
11
+ export const PAGES_PER_CHUNK = 16;
12
+
13
+ /** Bytes per stored chunk row (64 KiB — well under the 2 MB DO value cap). */
14
+ export const CHUNK_SIZE = VFS_PAGE_SIZE * PAGES_PER_CHUNK;