@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.
- 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 +38 -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 +249 -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 +1019 -169
- 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/owner-store/ixo-vfs-store.ts +12 -5
- package/src/owner-store/matrix-media-store.ts +12 -7
- package/src/owner-store/measure.ts +39 -0
- package/src/owner-store/migrating-store.ts +9 -3
- package/src/owner-store/types.ts +14 -4
- 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 +78 -0
- package/src/sqlite/cache-config.ts +27 -6
- package/src/sqlite/chunk-layout.ts +14 -0
- package/src/sqlite/database.ts +188 -19
- package/src/sqlite/do-vfs.ts +963 -99
- package/src/sqlite/index.ts +13 -0
- package/src/sqlite/page-tier.ts +668 -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
|
}
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
type OwnerCopy,
|
|
51
51
|
type OwnerStore,
|
|
52
52
|
type SaveResult,
|
|
53
|
+
type SaveHints,
|
|
53
54
|
} from './types';
|
|
54
55
|
|
|
55
56
|
const VFS_RESOURCE = 'ixo:filesystem';
|
|
@@ -233,9 +234,11 @@ export class IxoVfsOwnerStore implements OwnerStore {
|
|
|
233
234
|
* Atomic replace, always delete + re-create, never a new version:
|
|
234
235
|
*
|
|
235
236
|
* 1. drop stale `.uploading-*` temp files a crashed flush left behind;
|
|
236
|
-
* 2.
|
|
237
|
-
*
|
|
238
|
-
*
|
|
237
|
+
* 2. take the gzipped length from `hints` (the object measured it in the
|
|
238
|
+
* same pass as its checksum) or, without a hint, count it in a pass
|
|
239
|
+
* of its own (the VFS needs `Upload-Length` at session creation —
|
|
240
|
+
* `Upload-Defer-Length` is not supported — and gzip output is
|
|
241
|
+
* deterministic for identical input);
|
|
239
242
|
* 3. upload gzip → a TEMP path next to the real one: one `POST /files`
|
|
240
243
|
* when it fits in a single part, else a tus session in 5 MiB parts
|
|
241
244
|
* (`tusUpload`, resumable per part);
|
|
@@ -247,7 +250,10 @@ export class IxoVfsOwnerStore implements OwnerStore {
|
|
|
247
250
|
* file is cleaned up then). Peak memory is one part (5 MiB) plus fixed
|
|
248
251
|
* overhead, whatever the file size.
|
|
249
252
|
*/
|
|
250
|
-
async save(
|
|
253
|
+
async save(
|
|
254
|
+
snapshot: FileSnapshot,
|
|
255
|
+
hints: SaveHints = {},
|
|
256
|
+
): Promise<SaveResult> {
|
|
251
257
|
const listing = await this.listOracleFiles();
|
|
252
258
|
const existing = listing.filter((f) => f.path === this.path);
|
|
253
259
|
const stale = listing.filter((f) =>
|
|
@@ -261,7 +267,8 @@ export class IxoVfsOwnerStore implements OwnerStore {
|
|
|
261
267
|
});
|
|
262
268
|
}
|
|
263
269
|
|
|
264
|
-
const gzLength =
|
|
270
|
+
const gzLength =
|
|
271
|
+
hints.gzippedLength ?? (await countStream(gzipStream(snapshot.open())));
|
|
265
272
|
const tempPath = `${this.path}${TEMP_PATH_INFIX}${Date.now().toString(36)}`;
|
|
266
273
|
const uploaded =
|
|
267
274
|
gzLength <= SINGLE_SHOT_MAX_BYTES
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type OwnerCopy,
|
|
23
23
|
type OwnerStore,
|
|
24
24
|
type SaveResult,
|
|
25
|
+
type SaveHints,
|
|
25
26
|
} from './types';
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -89,19 +90,23 @@ export class MatrixMediaOwnerStore implements OwnerStore {
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
/**
|
|
92
|
-
* Streamed as well: the snapshot is opened
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* uploaded — and never held whole.
|
|
93
|
+
* Streamed as well: the snapshot is opened for a header peek, for a gzip
|
|
94
|
+
* pass that only counts unless the caller measured the length already
|
|
95
|
+
* (`hints`; the homeserver needs the exact ciphertext length up front and
|
|
96
|
+
* gzip output is deterministic for identical input) and for the gzip pass
|
|
97
|
+
* that is uploaded — and never held whole.
|
|
97
98
|
*/
|
|
98
|
-
async save(
|
|
99
|
+
async save(
|
|
100
|
+
snapshot: FileSnapshot,
|
|
101
|
+
hints: SaveHints = {},
|
|
102
|
+
): Promise<SaveResult> {
|
|
99
103
|
const checked = await assertSqliteStream(
|
|
100
104
|
snapshot.open(),
|
|
101
105
|
`MatrixMediaOwnerStore: refusing to save — the working copy (${snapshot.size} bytes)`,
|
|
102
106
|
);
|
|
103
107
|
await checked.cancel().catch(() => undefined);
|
|
104
|
-
const gzLength =
|
|
108
|
+
const gzLength =
|
|
109
|
+
hints.gzippedLength ?? (await countStream(gzipStream(snapshot.open())));
|
|
105
110
|
const { eventId } = await this.gateway.uploadUserSnapshotStream(
|
|
106
111
|
this.userDid,
|
|
107
112
|
this.storageKey,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One streamed pass over a snapshot that yields BOTH what a flush needs
|
|
3
|
+
* before it uploads: the SHA-256 of the raw SQLite bytes (the "did anything
|
|
4
|
+
* change" gate against the last upload) and the exact gzipped length (the
|
|
5
|
+
* VFS needs `Upload-Length` when the tus session is created). Before this
|
|
6
|
+
* helper the object hashed in one pass and the store counted the gzip in a
|
|
7
|
+
* second one; with the R2 page tier every pass over a cold file is one R2
|
|
8
|
+
* GET per segment, so the two are folded together and the upload itself is
|
|
9
|
+
* the only other read.
|
|
10
|
+
*
|
|
11
|
+
* The length is exact for the store's own gzip: `gzipStream` is the same
|
|
12
|
+
* `CompressionStream('gzip')` the stores pipe the upload through, and its
|
|
13
|
+
* output is deterministic for identical input.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { countStream, gzipStream } from './types';
|
|
17
|
+
|
|
18
|
+
export interface SnapshotMeasure {
|
|
19
|
+
/** Hex SHA-256 of the raw (uncompressed) bytes. */
|
|
20
|
+
sha256Hex: string;
|
|
21
|
+
/** Byte length of `gzipStream(<the same bytes>)`. */
|
|
22
|
+
gzippedLength: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function measureForSave(
|
|
26
|
+
stream: ReadableStream<Uint8Array>,
|
|
27
|
+
): Promise<SnapshotMeasure> {
|
|
28
|
+
const hash = createHash('sha256');
|
|
29
|
+
const tapped = stream.pipeThrough(
|
|
30
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
31
|
+
transform(chunk, controller) {
|
|
32
|
+
hash.update(chunk);
|
|
33
|
+
controller.enqueue(chunk);
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
const gzippedLength = await countStream(gzipStream(tapped));
|
|
38
|
+
return { sha256Hex: hash.digest('hex'), gzippedLength };
|
|
39
|
+
}
|
|
@@ -20,7 +20,13 @@
|
|
|
20
20
|
* Matrix copy so no second copy of the user's history lingers.
|
|
21
21
|
*/
|
|
22
22
|
import { VfsNoDelegationError } from './ixo-vfs-store';
|
|
23
|
-
import type {
|
|
23
|
+
import type {
|
|
24
|
+
FileSnapshot,
|
|
25
|
+
OwnerCopy,
|
|
26
|
+
OwnerStore,
|
|
27
|
+
SaveHints,
|
|
28
|
+
SaveResult,
|
|
29
|
+
} from './types';
|
|
24
30
|
|
|
25
31
|
export interface MigratingOwnerStoreOptions {
|
|
26
32
|
/** System of record (IXO VFS). */
|
|
@@ -104,8 +110,8 @@ export class MigratingOwnerStore implements OwnerStore {
|
|
|
104
110
|
* "not on VFS yet" (no delegation) — so the object keeps the copy dirty
|
|
105
111
|
* and retries; Matrix media is never written.
|
|
106
112
|
*/
|
|
107
|
-
async save(snapshot: FileSnapshot): Promise<SaveResult> {
|
|
108
|
-
return this.primary.save(snapshot);
|
|
113
|
+
async save(snapshot: FileSnapshot, hints?: SaveHints): Promise<SaveResult> {
|
|
114
|
+
return this.primary.save(snapshot, hints);
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
/**
|
package/src/owner-store/types.ts
CHANGED
|
@@ -15,9 +15,13 @@
|
|
|
15
15
|
* Both directions STREAM: `load()` hands back the raw (already gunzipped)
|
|
16
16
|
* SQLite bytes as a `ReadableStream` the object writes into its VFS chunk by
|
|
17
17
|
* chunk, and `save()` takes a `FileSnapshot` — a re-openable, consistent
|
|
18
|
-
* view of the working copy — that the store reads as many times as it needs
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* view of the working copy — that the store reads as many times as it needs.
|
|
19
|
+
* The object measures the hash and the gzipped length in ONE pass
|
|
20
|
+
* (`measureForSave`) and hands the length in as a `SaveHints`, so a store
|
|
21
|
+
* only reads the snapshot once more, for the upload itself; a store called
|
|
22
|
+
* without the hint counts the gzip in a pass of its own. That matters with
|
|
23
|
+
* the R2 page tier, where every pass over a cold file is one R2 GET per
|
|
24
|
+
* segment. Nothing on this path ever holds the whole file in memory — the
|
|
21
25
|
* legacy Matrix media path included: the gateway hands the media over as a
|
|
22
26
|
* stream and decryption, gunzip and the SQLite header check run chunk by
|
|
23
27
|
* chunk in the object. Compression is the store's concern. `etag` lets the object skip a reload
|
|
@@ -26,7 +30,7 @@
|
|
|
26
30
|
export interface OwnerStore {
|
|
27
31
|
readonly kind: 'matrix' | 'vfs';
|
|
28
32
|
load(): Promise<OwnerCopy | null>;
|
|
29
|
-
save(snapshot: FileSnapshot): Promise<SaveResult>;
|
|
33
|
+
save(snapshot: FileSnapshot, hints?: SaveHints): Promise<SaveResult>;
|
|
30
34
|
/** Cheap upstream freshness probe — null when unsupported/unknown. */
|
|
31
35
|
head(): Promise<{ etag: string } | null>;
|
|
32
36
|
/** Remove the durable copy (user asked to be forgotten). */
|
|
@@ -60,6 +64,12 @@ export interface OwnerCopy {
|
|
|
60
64
|
fromLegacy?: true;
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
/** What the caller already measured about the snapshot (see `measureForSave`). */
|
|
68
|
+
export interface SaveHints {
|
|
69
|
+
/** Byte length of `gzipStream(snapshot.open())`; spares the store its counting pass. */
|
|
70
|
+
gzippedLength?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
63
73
|
export interface SaveResult {
|
|
64
74
|
etag: string;
|
|
65
75
|
/** Bytes actually sent upstream (compressed size on the VFS path). */
|
|
@@ -17,6 +17,11 @@ export interface ToolHelperOptions {
|
|
|
17
17
|
* `manifest.visibility` setting.
|
|
18
18
|
*/
|
|
19
19
|
visibility?: PluginTool['visibility'];
|
|
20
|
+
/**
|
|
21
|
+
* `'read'` for a tool with no external effect (safe to run again when a
|
|
22
|
+
* turn resumes after a reset); omitted or `'write'` otherwise.
|
|
23
|
+
*/
|
|
24
|
+
effect?: PluginTool['effect'];
|
|
20
25
|
/**
|
|
21
26
|
* Billing gate marker, kept for source compatibility with the Node
|
|
22
27
|
* runtime's plugin contract. The Workers runtime has no commerce lane, so
|
|
@@ -49,7 +54,7 @@ export function tool(
|
|
|
49
54
|
if (!options || typeof options !== 'object') {
|
|
50
55
|
throw new TypeError('tool(handler, options): `options` is required.');
|
|
51
56
|
}
|
|
52
|
-
const { name, description, schema, visibility, billing } = options;
|
|
57
|
+
const { name, description, schema, visibility, billing, effect } = options;
|
|
53
58
|
if (!name || typeof name !== 'string') {
|
|
54
59
|
throw new TypeError(
|
|
55
60
|
'tool(handler, options): `options.name` must be a non-empty string.',
|
|
@@ -78,5 +83,8 @@ export function tool(
|
|
|
78
83
|
if (billing !== undefined) {
|
|
79
84
|
pluginTool.billing = billing;
|
|
80
85
|
}
|
|
86
|
+
if (effect !== undefined) {
|
|
87
|
+
pluginTool.effect = effect;
|
|
88
|
+
}
|
|
81
89
|
return pluginTool;
|
|
82
90
|
}
|
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[];
|