@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.
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/core/context-budget.ts +201 -0
- package/src/core/context-window.ts +289 -0
- package/src/core/env.ts +29 -0
- package/src/core/main-agent-types.ts +46 -0
- package/src/core/main-agent.ts +86 -9
- package/src/core/middlewares/context-guard.ts +270 -0
- package/src/core/middlewares/result-cap.ts +180 -0
- package/src/core/middlewares/summarization.ts +87 -6
- package/src/core/middlewares/tool-marks.ts +209 -0
- package/src/core/openrouter-pricing.ts +42 -1
- package/src/core/read-result-tool.ts +77 -0
- package/src/core/runtime-context.ts +7 -0
- package/src/core/sub-agent-fallback.ts +20 -3
- package/src/core/wrap-plugin-tool.ts +24 -3
- package/src/do/ambient.ts +17 -0
- package/src/do/contracts.ts +227 -0
- package/src/do/result-store-test-do.ts +88 -0
- package/src/do/result-store.ts +339 -0
- package/src/do/run-buffer.ts +204 -0
- package/src/do/run-coordinator.ts +572 -0
- package/src/do/run-request.ts +52 -0
- package/src/do/run-store-test-do.ts +130 -0
- package/src/do/run-store.ts +604 -0
- package/src/do/session-id-migration.ts +85 -0
- package/src/do/speaker-prefix.ts +37 -0
- package/src/do/sse-stream.ts +567 -400
- package/src/do/transcript.ts +238 -0
- package/src/do/turn-body.ts +10 -0
- package/src/do/user-oracle-do.ts +875 -146
- package/src/matrix/gateway-do.ts +230 -29
- package/src/matrix/group-chat-summarizer.ts +107 -0
- package/src/matrix/group-chat.ts +1163 -0
- package/src/matrix/inbox-store.ts +23 -3
- package/src/matrix/ingest.ts +54 -11
- package/src/matrix/reply-chain.ts +34 -25
- package/src/plugin-api/tool-helper.ts +9 -1
- package/src/plugin-api/types.ts +86 -0
- package/src/plugins/index.ts +3 -0
- package/src/plugins/matrix-group-chats/README.md +70 -0
- package/src/plugins/matrix-group-chats/index.ts +132 -0
- package/src/plugins/matrix-group-chats/tools.ts +172 -0
- package/src/plugins/tasks/tasks-tools.ts +4 -0
- package/src/shell/app.ts +66 -0
- package/src/sqlite/sqlite-saver.ts +81 -0
- package/src/tasks/scheduler.ts +134 -3
- 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
|