@ixo/oracle-runtime-workers 0.10.0 → 0.11.1
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 +229 -0
- package/src/do/flush-schedule.ts +40 -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 +948 -162
- 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
|
@@ -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
|
}));
|
package/src/matrix/ingest.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
42
|
-
threadId
|
|
43
|
-
/**
|
|
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
|
-
|
|
123
|
-
|
|
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}|${
|
|
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
|
|
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
|
|
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
|
-
|
|
236
|
+
threadId,
|
|
237
|
+
sessionId: threadId,
|
|
198
238
|
message,
|
|
199
239
|
eventIds: entry.messages.map((m) => m.eventId),
|
|
200
|
-
|
|
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
|
-
*
|
|
3
|
-
* `getThreadRoot
|
|
4
|
-
* message
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
-
*
|
|
70
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
93
|
+
let cursor: string = firstParent;
|
|
90
94
|
let hops = 0;
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
}
|
package/src/plugin-api/types.ts
CHANGED
|
@@ -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
|
package/src/plugins/index.ts
CHANGED
|
@@ -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[];
|
|
@@ -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).
|