@ixo/oracle-runtime-workers 0.12.0 → 0.15.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 +32 -10
- package/package.json +14 -9
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +267 -0
- package/src/channels/contract.ts +137 -0
- package/src/channels/turns.ts +270 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +198 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +1883 -264
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +523 -111
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +529 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- package/src/tasks/topic-deliverables.ts +70 -0
package/src/matrix/group-chat.ts
CHANGED
|
@@ -91,6 +91,11 @@ export const DEFAULT_REQUIRE_POWER_LEVEL = 0;
|
|
|
91
91
|
export const COMPACT_BUFFER_THRESHOLD = 20;
|
|
92
92
|
export const COMPACT_JIT_MIN = 5;
|
|
93
93
|
export const COMPACT_JIT_TIMEOUT_MS = 3000;
|
|
94
|
+
/** The oldest messages one summary covers at most; a longer buffer is compacted over several runs. */
|
|
95
|
+
export const COMPACT_BATCH_MAX = 50;
|
|
96
|
+
/** After a failed summary a room's automatic compaction waits this long, doubling per failure up to the max. */
|
|
97
|
+
export const COMPACT_RETRY_MIN_MS = 60_000;
|
|
98
|
+
export const COMPACT_RETRY_MAX_MS = 30 * 60_000;
|
|
94
99
|
export const RECALL_DEFAULT_CHUNKS = 10;
|
|
95
100
|
export const RECALL_MAX_CHUNKS = 30;
|
|
96
101
|
export const PINNED_FACT_MAX_CHARS = 500;
|
|
@@ -508,13 +513,13 @@ export class GroupChatStore {
|
|
|
508
513
|
}
|
|
509
514
|
|
|
510
515
|
deletePinnedFact(roomId: string, factId: string): boolean {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
+
return (
|
|
517
|
+
this.sql.exec(
|
|
518
|
+
`DELETE FROM group_pinned_facts WHERE room_id = ? AND id = ?`,
|
|
519
|
+
roomId,
|
|
520
|
+
factId,
|
|
521
|
+
).rowsWritten > 0
|
|
516
522
|
);
|
|
517
|
-
return this.countFacts(roomId) < before;
|
|
518
523
|
}
|
|
519
524
|
|
|
520
525
|
countFacts(roomId?: string): number {
|
|
@@ -568,21 +573,23 @@ export class GroupChatStore {
|
|
|
568
573
|
|
|
569
574
|
/** Buffer a message for compaction; false when the event was already buffered (a replay). */
|
|
570
575
|
bufferAppend(roomId: string, msg: ObservedMessage): boolean {
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
576
|
+
// An ignored duplicate writes nothing; an insert writes its row (and
|
|
577
|
+
// its index entries).
|
|
578
|
+
return (
|
|
579
|
+
this.sql.exec(
|
|
580
|
+
`INSERT OR IGNORE INTO group_message_buffer
|
|
574
581
|
(event_id, room_id, thread_id, sender_did, sender_user_id, display_name, body, ts)
|
|
575
582
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
583
|
+
msg.eventId,
|
|
584
|
+
roomId,
|
|
585
|
+
msg.threadId,
|
|
586
|
+
msg.senderDid,
|
|
587
|
+
msg.senderMatrixUserId,
|
|
588
|
+
msg.senderDisplayName,
|
|
589
|
+
msg.body,
|
|
590
|
+
msg.timestamp,
|
|
591
|
+
).rowsWritten > 0
|
|
584
592
|
);
|
|
585
|
-
return this.bufferCount(roomId) > before;
|
|
586
593
|
}
|
|
587
594
|
|
|
588
595
|
bufferCount(roomId?: string): number {
|
|
@@ -601,12 +608,13 @@ export class GroupChatStore {
|
|
|
601
608
|
return Number(rows[0]?.n ?? 0);
|
|
602
609
|
}
|
|
603
610
|
|
|
604
|
-
/** The room's buffered messages, oldest first. */
|
|
605
|
-
bufferRows(roomId: string): ObservedMessage[] {
|
|
611
|
+
/** The room's buffered messages, oldest first (at most `limit`). */
|
|
612
|
+
bufferRows(roomId: string, limit = COMPACT_BATCH_MAX): ObservedMessage[] {
|
|
606
613
|
return this.sql
|
|
607
614
|
.exec<BufferRow>(
|
|
608
|
-
`SELECT * FROM group_message_buffer WHERE room_id = ? ORDER BY ts ASC, rowid ASC
|
|
615
|
+
`SELECT * FROM group_message_buffer WHERE room_id = ? ORDER BY ts ASC, rowid ASC LIMIT ?`,
|
|
609
616
|
roomId,
|
|
617
|
+
limit,
|
|
610
618
|
)
|
|
611
619
|
.toArray()
|
|
612
620
|
.map(rowToObserved);
|
|
@@ -760,6 +768,11 @@ export class GroupChatService {
|
|
|
760
768
|
>();
|
|
761
769
|
private readonly activeThreads = new Map<string, number>();
|
|
762
770
|
private readonly compacting = new Map<string, Promise<void>>();
|
|
771
|
+
/** Rooms whose last summary failed: no automatic compaction before `until`. */
|
|
772
|
+
private readonly compactBackoff = new Map<
|
|
773
|
+
string,
|
|
774
|
+
{ until: number; delayMs: number }
|
|
775
|
+
>();
|
|
763
776
|
private readonly now: () => number;
|
|
764
777
|
|
|
765
778
|
constructor(
|
|
@@ -1013,7 +1026,10 @@ export class GroupChatService {
|
|
|
1013
1026
|
|
|
1014
1027
|
observe(roomId: string, message: ObservedMessage): void {
|
|
1015
1028
|
if (!this.store.bufferAppend(roomId, message)) return;
|
|
1016
|
-
if (
|
|
1029
|
+
if (
|
|
1030
|
+
this.compactDue(roomId) &&
|
|
1031
|
+
this.store.bufferCount(roomId) >= COMPACT_BUFFER_THRESHOLD
|
|
1032
|
+
)
|
|
1017
1033
|
this.deps.keepAlive(
|
|
1018
1034
|
this.compact(roomId).catch((err: unknown) =>
|
|
1019
1035
|
this.deps.log(
|
|
@@ -1027,6 +1043,7 @@ export class GroupChatService {
|
|
|
1027
1043
|
|
|
1028
1044
|
/** Compact a buffer of ≥ 5 before the bot answers, bounded so the reply is not held up. */
|
|
1029
1045
|
async compactJustInTime(roomId: string): Promise<void> {
|
|
1046
|
+
if (!this.compactDue(roomId)) return;
|
|
1030
1047
|
if (this.store.bufferCount(roomId) < COMPACT_JIT_MIN) return;
|
|
1031
1048
|
const work = this.compact(roomId).catch((err: unknown) =>
|
|
1032
1049
|
this.deps.log(
|
|
@@ -1053,19 +1070,45 @@ export class GroupChatService {
|
|
|
1053
1070
|
return work;
|
|
1054
1071
|
}
|
|
1055
1072
|
|
|
1073
|
+
/**
|
|
1074
|
+
* Whether automatic compaction (threshold, just in time) may run for the
|
|
1075
|
+
* room: not while it backs off after a failed summary, so a model outage
|
|
1076
|
+
* costs one summary call per back-off window, not one per message.
|
|
1077
|
+
*/
|
|
1078
|
+
private compactDue(roomId: string): boolean {
|
|
1079
|
+
const backoff = this.compactBackoff.get(roomId);
|
|
1080
|
+
return !backoff || backoff.until <= this.now();
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
private noteCompactFailure(roomId: string): void {
|
|
1084
|
+
const previous = this.compactBackoff.get(roomId)?.delayMs;
|
|
1085
|
+
const delayMs = previous
|
|
1086
|
+
? Math.min(previous * 2, COMPACT_RETRY_MAX_MS)
|
|
1087
|
+
: COMPACT_RETRY_MIN_MS;
|
|
1088
|
+
this.compactBackoff.set(roomId, { until: this.now() + delayMs, delayMs });
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1056
1091
|
private async compactInner(roomId: string): Promise<void> {
|
|
1057
1092
|
const drained = this.store.bufferRows(roomId);
|
|
1058
1093
|
const first = drained[0];
|
|
1059
1094
|
const last = drained[drained.length - 1];
|
|
1060
1095
|
if (!first || !last) return;
|
|
1061
|
-
|
|
1096
|
+
let summary: string | null;
|
|
1097
|
+
try {
|
|
1098
|
+
summary = await this.deps.summarize(drained);
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
this.noteCompactFailure(roomId);
|
|
1101
|
+
throw err;
|
|
1102
|
+
}
|
|
1062
1103
|
if (!summary) {
|
|
1104
|
+
this.noteCompactFailure(roomId);
|
|
1063
1105
|
this.deps.log(
|
|
1064
1106
|
'warn',
|
|
1065
1107
|
`[group-chat] summary unavailable; ${drained.length} messages stay buffered for ${roomId}`,
|
|
1066
1108
|
);
|
|
1067
1109
|
return;
|
|
1068
1110
|
}
|
|
1111
|
+
this.compactBackoff.delete(roomId);
|
|
1069
1112
|
const chunk: ChannelMemoryChunk = {
|
|
1070
1113
|
id: crypto.randomUUID(),
|
|
1071
1114
|
roomId,
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Cost shape (SQLite-backed Durable Object storage bills rows written and
|
|
17
17
|
* read): one row written on receipt, one delete when the turn ends.
|
|
18
18
|
*/
|
|
19
|
-
import type { InboundAttachment, InboundMessage } from './ingest';
|
|
19
|
+
import type { InboundAttachment, InboundMessage, OfferOutcome } from './ingest';
|
|
20
20
|
import { matrixTxnId } from './txn-id';
|
|
21
21
|
|
|
22
22
|
export interface InboxRow extends InboundMessage {
|
|
@@ -103,6 +103,21 @@ export function deleteInboxRows(sql: SqlStorage, eventIds: string[]): void {
|
|
|
103
103
|
sql.exec(`DELETE FROM turn_inbox WHERE event_id = ?`, id);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The inbox row of a message after its offer: a queued message keeps it until
|
|
108
|
+
* its turn ends, an unverified one (the sender's homeserver could not be
|
|
109
|
+
* looked up) keeps it for the next replay, and a dropped one leaves at once.
|
|
110
|
+
*/
|
|
111
|
+
export function settleOfferedRow(
|
|
112
|
+
sql: SqlStorage,
|
|
113
|
+
eventId: string,
|
|
114
|
+
outcome: OfferOutcome,
|
|
115
|
+
): 'kept' | 'deleted' {
|
|
116
|
+
if (outcome === 'queued' || outcome === 'unverified') return 'kept';
|
|
117
|
+
deleteInboxRows(sql, [eventId]);
|
|
118
|
+
return 'deleted';
|
|
119
|
+
}
|
|
120
|
+
|
|
106
121
|
export function bumpInboxAttempts(sql: SqlStorage, eventIds: string[]): void {
|
|
107
122
|
for (const id of eventIds)
|
|
108
123
|
sql.exec(
|
package/src/matrix/ingest.ts
CHANGED
|
@@ -75,6 +75,15 @@ export interface IngestTurn {
|
|
|
75
75
|
export interface IngestDeps {
|
|
76
76
|
/** The DID that forms the oracle half of the user↔oracle room alias. */
|
|
77
77
|
oracleDid: string;
|
|
78
|
+
/**
|
|
79
|
+
* The Matrix server name the user's DID document registers, lowercased;
|
|
80
|
+
* null when it is not known. Synchronous like `canonicalAlias`: the gateway
|
|
81
|
+
* resolves it before the offer. A message whose attributed identity was
|
|
82
|
+
* minted on any other server is dropped (`'foreign'`); with no known server
|
|
83
|
+
* there is no verdict (`'unverified'`) and the gateway keeps the message
|
|
84
|
+
* for a later attempt.
|
|
85
|
+
*/
|
|
86
|
+
userServerName(userDid: string): string | null;
|
|
78
87
|
/**
|
|
79
88
|
* The bot's Matrix user id. Its first occurrence in a message is rewritten
|
|
80
89
|
* to `(USER MENTIONED YOU @AI_AGENT)` before the agent sees it, as the Node
|
|
@@ -129,25 +138,65 @@ export function userDidFromRoomAlias(
|
|
|
129
138
|
return aliasPartToDid(local.slice(0, -suffix.length));
|
|
130
139
|
}
|
|
131
140
|
|
|
141
|
+
/** The server name of a Matrix user id or room alias (`@a:server` → `server`), lowercased; null without one. */
|
|
142
|
+
export function serverNameOf(matrixId: string): string | null {
|
|
143
|
+
const colon = matrixId.indexOf(':');
|
|
144
|
+
if (colon === -1) return null;
|
|
145
|
+
const server = matrixId.slice(colon + 1).toLowerCase();
|
|
146
|
+
return server || null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whose turn a message is, and the server that identity was minted on. */
|
|
150
|
+
export interface UserAttribution {
|
|
151
|
+
userDid: string;
|
|
152
|
+
/**
|
|
153
|
+
* The server the identity came from: the sender's for a DID-shaped sender,
|
|
154
|
+
* the alias's for a non-DID sender attributed to the room's owner. It must
|
|
155
|
+
* be the user's registered homeserver — anyone can register
|
|
156
|
+
* `@did-ixo-<someone>` on a server of their own, and a Matrix localpart
|
|
157
|
+
* says nothing about who controls the DID.
|
|
158
|
+
*/
|
|
159
|
+
server: string | null;
|
|
160
|
+
}
|
|
161
|
+
|
|
132
162
|
/**
|
|
133
163
|
* Who is the user for this message? The canonical alias names the room's
|
|
134
164
|
* owner; the sender's localpart names the speaker. They agree in a 1:1
|
|
135
165
|
* oracle room. When the speaker is DID-shaped but differs from the room
|
|
136
166
|
* owner (a third member invited into the room), the SPEAKER wins — a turn
|
|
137
167
|
* must never run inside another user's object. A non-DID sender in an
|
|
138
|
-
* owner's room is attributed to the owner (legacy / non-DID accounts)
|
|
168
|
+
* owner's room is attributed to the owner (legacy / non-DID accounts), but
|
|
169
|
+
* only when the sender's account lives on the alias's own server: an
|
|
170
|
+
* account on any other server is someone else, whatever the room says.
|
|
171
|
+
*
|
|
172
|
+
* `alias` must be one the caller verified (the alias resolves to this very
|
|
173
|
+
* room on its homeserver — `room-alias.ts`): a room's canonical-alias state
|
|
174
|
+
* is written by its members and proves nothing on its own.
|
|
139
175
|
*/
|
|
176
|
+
export function attributeUser(opts: {
|
|
177
|
+
alias: string | null;
|
|
178
|
+
sender: string;
|
|
179
|
+
oracleDid: string;
|
|
180
|
+
}): UserAttribution | null {
|
|
181
|
+
const fromSender = matrixUserIdToDid(opts.sender);
|
|
182
|
+
if (fromSender) {
|
|
183
|
+
return { userDid: fromSender, server: serverNameOf(opts.sender) };
|
|
184
|
+
}
|
|
185
|
+
if (!opts.alias) return null;
|
|
186
|
+
const fromAlias = userDidFromRoomAlias(opts.alias, opts.oracleDid);
|
|
187
|
+
const aliasServer = serverNameOf(opts.alias);
|
|
188
|
+
if (!fromAlias || !aliasServer) return null;
|
|
189
|
+
if (serverNameOf(opts.sender) !== aliasServer) return null;
|
|
190
|
+
return { userDid: fromAlias, server: aliasServer };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The DID `attributeUser` names, without the server it came from. */
|
|
140
194
|
export function resolveUserDid(opts: {
|
|
141
195
|
alias: string | null;
|
|
142
196
|
sender: string;
|
|
143
197
|
oracleDid: string;
|
|
144
198
|
}): string | null {
|
|
145
|
-
|
|
146
|
-
const fromAlias = opts.alias
|
|
147
|
-
? userDidFromRoomAlias(opts.alias, opts.oracleDid)
|
|
148
|
-
: null;
|
|
149
|
-
if (fromSender && fromAlias && fromSender !== fromAlias) return fromSender;
|
|
150
|
-
return fromAlias ?? fromSender;
|
|
199
|
+
return attributeUser(opts)?.userDid ?? null;
|
|
151
200
|
}
|
|
152
201
|
|
|
153
202
|
/** The thread a message belongs to: its resolved root, else the message itself. */
|
|
@@ -157,6 +206,13 @@ export function threadRootIdOf(
|
|
|
157
206
|
return msg.threadRootId ?? msg.eventId;
|
|
158
207
|
}
|
|
159
208
|
|
|
209
|
+
export type OfferOutcome =
|
|
210
|
+
| 'queued'
|
|
211
|
+
| 'unverified'
|
|
212
|
+
| 'empty'
|
|
213
|
+
| 'unmapped'
|
|
214
|
+
| 'foreign';
|
|
215
|
+
|
|
160
216
|
export class IngestPipeline {
|
|
161
217
|
private readonly pending = new Map<string, Pending>();
|
|
162
218
|
private readonly debounceMs: number;
|
|
@@ -166,19 +222,28 @@ export class IngestPipeline {
|
|
|
166
222
|
}
|
|
167
223
|
|
|
168
224
|
/**
|
|
169
|
-
* Offer a decrypted message. Returns the reason it was dropped,
|
|
170
|
-
*
|
|
225
|
+
* Offer a decrypted message. Returns the reason it was dropped, `'queued'`
|
|
226
|
+
* when it entered the debounce buffer, or `'unverified'` when the sender's
|
|
227
|
+
* registered homeserver is not known — neither queued nor dropped.
|
|
228
|
+
* `alias`: the room's verified alias as the caller just checked it (null
|
|
229
|
+
* for none); omitted, `canonicalAlias` is asked.
|
|
171
230
|
*/
|
|
172
|
-
offer(msg: InboundMessage
|
|
231
|
+
offer(msg: InboundMessage, alias?: string | null): OfferOutcome {
|
|
173
232
|
if (!msg.body.trim() && !msg.attachment) return 'empty';
|
|
174
|
-
const
|
|
175
|
-
alias: this.deps.canonicalAlias(msg.roomId),
|
|
233
|
+
const attribution = attributeUser({
|
|
234
|
+
alias: alias === undefined ? this.deps.canonicalAlias(msg.roomId) : alias,
|
|
176
235
|
sender: msg.sender,
|
|
177
236
|
oracleDid: this.deps.oracleDid,
|
|
178
237
|
});
|
|
179
|
-
if (!
|
|
238
|
+
if (!attribution) return 'unmapped';
|
|
239
|
+
const { userDid } = attribution;
|
|
240
|
+
const registered = this.deps.userServerName(userDid);
|
|
241
|
+
if (!registered) return 'unverified';
|
|
242
|
+
if (attribution.server !== registered) return 'foreign';
|
|
180
243
|
|
|
181
|
-
|
|
244
|
+
// Per speaker as well as per thread: two members typing in one thread of
|
|
245
|
+
// a group room are two turns, each in its own user's object.
|
|
246
|
+
const key = `${msg.roomId}|${threadRootIdOf(msg)}|${userDid}`;
|
|
182
247
|
const existing = this.pending.get(key);
|
|
183
248
|
if (existing) {
|
|
184
249
|
clearTimeout(existing.timer);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deadlines for the gateway's media downloads. A homeserver that stops
|
|
3
|
+
* sending mid-body would otherwise keep the reading side — a user object
|
|
4
|
+
* assembling an attachment for a turn — waiting for as long as the
|
|
5
|
+
* connection stays open.
|
|
6
|
+
*
|
|
7
|
+
* The bound covers the whole transfer: opening the download and every byte
|
|
8
|
+
* of the body. When it passes (or the caller's own signal aborts), the
|
|
9
|
+
* source is cancelled and the stream handed out errors with the reason.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface BoundedSignal {
|
|
13
|
+
signal: AbortSignal;
|
|
14
|
+
/** Stop the timer (the transfer finished or failed on its own). */
|
|
15
|
+
clear(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Error a download fails with when its deadline passes. */
|
|
19
|
+
export class MediaDownloadTimeoutError extends Error {
|
|
20
|
+
constructor(timeoutMs: number) {
|
|
21
|
+
super(`media download timed out after ${timeoutMs} ms`);
|
|
22
|
+
this.name = 'MediaDownloadTimeoutError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A signal that aborts after `timeoutMs`, or as soon as `signal` does. The
|
|
28
|
+
* timer is a plain timeout so it can be cleared the moment the transfer ends
|
|
29
|
+
* (a pending timer keeps a Durable Object resident).
|
|
30
|
+
*/
|
|
31
|
+
export function boundedSignal(
|
|
32
|
+
timeoutMs: number,
|
|
33
|
+
signal?: AbortSignal,
|
|
34
|
+
): BoundedSignal {
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const onCallerAbort = (): void => controller.abort(signal?.reason);
|
|
37
|
+
const timer = setTimeout(
|
|
38
|
+
() => controller.abort(new MediaDownloadTimeoutError(timeoutMs)),
|
|
39
|
+
timeoutMs,
|
|
40
|
+
);
|
|
41
|
+
const clear = (): void => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
signal?.removeEventListener('abort', onCallerAbort);
|
|
44
|
+
};
|
|
45
|
+
if (signal?.aborted) {
|
|
46
|
+
clear();
|
|
47
|
+
controller.abort(signal.reason);
|
|
48
|
+
} else signal?.addEventListener('abort', onCallerAbort, { once: true });
|
|
49
|
+
controller.signal.addEventListener('abort', clear, { once: true });
|
|
50
|
+
return { signal: controller.signal, clear };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The signal's reason as an Error (a `DOMException` reason already is one). */
|
|
54
|
+
function abortReason(signal: AbortSignal): Error {
|
|
55
|
+
const reason: unknown = signal.reason;
|
|
56
|
+
return reason instanceof Error ? reason : new Error(String(reason));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Settle with `work`, or reject with the signal's reason when it aborts
|
|
61
|
+
* first. A result that arrives after the abort is handed to `discard` so a
|
|
62
|
+
* stream opened too late is still released.
|
|
63
|
+
*/
|
|
64
|
+
export function raceSignal<T>(
|
|
65
|
+
work: Promise<T>,
|
|
66
|
+
signal: AbortSignal,
|
|
67
|
+
discard: (late: T) => void,
|
|
68
|
+
): Promise<T> {
|
|
69
|
+
if (signal.aborted) {
|
|
70
|
+
void work.then(discard, () => undefined);
|
|
71
|
+
return Promise.reject(abortReason(signal));
|
|
72
|
+
}
|
|
73
|
+
return new Promise<T>((resolve, reject) => {
|
|
74
|
+
const onAbort = (): void => {
|
|
75
|
+
void work.then(discard, () => undefined);
|
|
76
|
+
reject(abortReason(signal));
|
|
77
|
+
};
|
|
78
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
79
|
+
work.then(
|
|
80
|
+
(value) => {
|
|
81
|
+
signal.removeEventListener('abort', onAbort);
|
|
82
|
+
if (signal.aborted) discard(value);
|
|
83
|
+
else resolve(value);
|
|
84
|
+
},
|
|
85
|
+
(err: unknown) => {
|
|
86
|
+
signal.removeEventListener('abort', onAbort);
|
|
87
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `source` behind the deadline: the returned stream carries the same bytes,
|
|
95
|
+
* and errors with the signal's reason (the source cancelled) when the bound
|
|
96
|
+
* passes before the last byte. The timer is cleared once the transfer ends
|
|
97
|
+
* either way, including when the reader cancels.
|
|
98
|
+
*/
|
|
99
|
+
export function streamWithDeadline(
|
|
100
|
+
source: ReadableStream<Uint8Array>,
|
|
101
|
+
bound: BoundedSignal,
|
|
102
|
+
): ReadableStream<Uint8Array> {
|
|
103
|
+
const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>();
|
|
104
|
+
const writer = writable.getWriter();
|
|
105
|
+
const reader = source.getReader();
|
|
106
|
+
const stop = (reason: unknown): void => {
|
|
107
|
+
void writer.abort(reason).catch(() => undefined);
|
|
108
|
+
void reader.cancel(reason).catch(() => undefined);
|
|
109
|
+
};
|
|
110
|
+
const onAbort = (): void => stop(bound.signal.reason);
|
|
111
|
+
if (bound.signal.aborted) onAbort();
|
|
112
|
+
else bound.signal.addEventListener('abort', onAbort, { once: true });
|
|
113
|
+
void (async () => {
|
|
114
|
+
try {
|
|
115
|
+
for (;;) {
|
|
116
|
+
const { done, value } = await reader.read();
|
|
117
|
+
if (done || bound.signal.aborted) break;
|
|
118
|
+
await writer.write(value);
|
|
119
|
+
}
|
|
120
|
+
if (!bound.signal.aborted) await writer.close();
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// The reader went away (cancelled) or the source failed: end both.
|
|
123
|
+
stop(err);
|
|
124
|
+
} finally {
|
|
125
|
+
bound.signal.removeEventListener('abort', onAbort);
|
|
126
|
+
bound.clear();
|
|
127
|
+
}
|
|
128
|
+
})();
|
|
129
|
+
return readable;
|
|
130
|
+
}
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* Room replay formatting — a port of `@ixo/matrix`'s `formatMsg`. The Node
|
|
3
3
|
* runtime replays every HTTP/SSE turn into the user's oracle room as a thread
|
|
4
4
|
* under the session's root event: the user message prefixed `**You:**`, the
|
|
5
|
-
* reply prefixed with the oracle's name, both rendered to HTML with marked
|
|
5
|
+
* reply prefixed with the oracle's name, both rendered to HTML with marked —
|
|
6
|
+
* with raw HTML escaped, as for every room reply (`reply-parts.ts`): the
|
|
7
|
+
* text may come from a model or a user and must not become room markup.
|
|
6
8
|
*/
|
|
7
|
-
import {
|
|
9
|
+
import { renderRoomMarkdown } from './reply-parts';
|
|
8
10
|
|
|
9
11
|
export interface FormatReplayParams {
|
|
10
12
|
message: string;
|
|
@@ -29,6 +31,6 @@ export function formatReplay({
|
|
|
29
31
|
const body = disablePrefix
|
|
30
32
|
? message
|
|
31
33
|
: `**${isOracle ? oracleName : 'You'}:**\n${message}`;
|
|
32
|
-
const formattedBody =
|
|
34
|
+
const formattedBody = renderRoomMarkdown(body);
|
|
33
35
|
return { body, formattedBody };
|
|
34
36
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the gateway posts in a Matrix room for one finished turn. Without a
|
|
3
|
+
* Reply Plan (chat style off, the default) it is the reply text as one
|
|
4
|
+
* plain `m.text`, exactly as before chat delivery existed. With a plan it is
|
|
5
|
+
* one `m.text` per part, in the thread, rendered to HTML with raw HTML from
|
|
6
|
+
* the model escaped. Each part has its own transaction id, so a replayed
|
|
7
|
+
* turn is deduplicated part by part.
|
|
8
|
+
*/
|
|
9
|
+
import { Marked } from 'marked';
|
|
10
|
+
import type { TurnResult } from '../do/contracts';
|
|
11
|
+
import { parseReplyPlan } from '../delivery/schema';
|
|
12
|
+
import type { ReplyPart } from '../delivery/types';
|
|
13
|
+
import { matrixTxnId } from './txn-id';
|
|
14
|
+
|
|
15
|
+
function escapeHtml(value: string): string {
|
|
16
|
+
return value
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** URL schemes a link or image in a room message may use. */
|
|
24
|
+
const ALLOWED_URL = /^(?:https?|mailto|mxc):/i;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Markdown → `formatted_body` for anything the oracle posts to a room. Raw
|
|
28
|
+
* HTML in the text (block or inline) is shown as text, never passed through
|
|
29
|
+
* as markup, and a link or image whose URL is not http(s), mailto or mxc
|
|
30
|
+
* (`javascript:`, `data:`, a relative path) is shown as its text. Returning
|
|
31
|
+
* `false` hands an allowed one to marked's own renderer.
|
|
32
|
+
*/
|
|
33
|
+
const safeMarkdown = new Marked({
|
|
34
|
+
gfm: true,
|
|
35
|
+
async: false,
|
|
36
|
+
renderer: {
|
|
37
|
+
html: ({ text }) => escapeHtml(text),
|
|
38
|
+
link({ href, tokens }) {
|
|
39
|
+
return ALLOWED_URL.test(href.trim())
|
|
40
|
+
? false
|
|
41
|
+
: this.parser.parseInline(tokens);
|
|
42
|
+
},
|
|
43
|
+
image({ href, text }) {
|
|
44
|
+
return ALLOWED_URL.test(href.trim()) ? false : escapeHtml(text);
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** Markdown rendered to room HTML with raw HTML escaped. */
|
|
50
|
+
export function renderRoomMarkdown(text: string): string {
|
|
51
|
+
return safeMarkdown.parse(text, { async: false });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function replyPartContent(part: ReplyPart): {
|
|
55
|
+
body: string;
|
|
56
|
+
formattedBody: string;
|
|
57
|
+
} {
|
|
58
|
+
if (part.kind === 'text')
|
|
59
|
+
return {
|
|
60
|
+
body: part.text,
|
|
61
|
+
formattedBody: renderRoomMarkdown(part.text),
|
|
62
|
+
};
|
|
63
|
+
const { title, url } = part.artifact;
|
|
64
|
+
return {
|
|
65
|
+
body: `${title}\n${url}`,
|
|
66
|
+
formattedBody: `<p><strong>${escapeHtml(title)}</strong><br><a href="${escapeHtml(url)}">Open document</a></p>`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function replyPartTxnId(eventId: string, partId: string): string {
|
|
71
|
+
return matrixTxnId('reply', eventId, partId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One room message of a turn's reply; `partId` is set for a plan part. */
|
|
75
|
+
export interface RoomReplyMessage {
|
|
76
|
+
body: string;
|
|
77
|
+
formattedBody?: string;
|
|
78
|
+
partId?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The messages a finished room turn posts, in order; none for an empty reply. */
|
|
82
|
+
export function roomReplyMessages(
|
|
83
|
+
result: Pick<TurnResult, 'text' | 'plan'>,
|
|
84
|
+
): RoomReplyMessage[] {
|
|
85
|
+
const plan = parseReplyPlan(result.plan);
|
|
86
|
+
if (plan && plan.parts.length > 0)
|
|
87
|
+
return plan.parts.map((part) => ({
|
|
88
|
+
...replyPartContent(part),
|
|
89
|
+
partId: part.partId,
|
|
90
|
+
}));
|
|
91
|
+
return result.text.trim() ? [{ body: result.text }] : [];
|
|
92
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user↔oracle alias a room really carries.
|
|
3
|
+
*
|
|
4
|
+
* A room's `m.room.canonical_alias` is ordinary room state: any member with
|
|
5
|
+
* the power to set it can write any alias there, and a homeserver does not
|
|
6
|
+
* check aliases that live on another server. A room created on a server of
|
|
7
|
+
* an attacker's own can therefore claim `#<victim>_<oracle>:<victim's
|
|
8
|
+
* server>`. What cannot be forged is the alias directory of the alias's own
|
|
9
|
+
* server, so an alias counts only when that server resolves it to the very
|
|
10
|
+
* same room. Anything else — no alias, an alias that resolves elsewhere or
|
|
11
|
+
* nowhere — is "no alias".
|
|
12
|
+
*
|
|
13
|
+
* Verdicts are memoised per room for a TTL and dropped when the room's alias
|
|
14
|
+
* state changes. A lookup that fails (homeserver error, rate limit) is not a
|
|
15
|
+
* verdict: it throws, nothing is memoised, and the caller retries later.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface VerifiedRoomAliasDeps {
|
|
19
|
+
/**
|
|
20
|
+
* The room's canonical alias when it is one that matters (a user↔oracle
|
|
21
|
+
* alias of this oracle), null otherwise. Throws when the state cannot be read.
|
|
22
|
+
*/
|
|
23
|
+
readCanonicalAlias(roomId: string): Promise<string | null>;
|
|
24
|
+
/** The room an alias points at in its server's directory; null when it points nowhere. Throws on any other failure. */
|
|
25
|
+
resolveAlias(alias: string): Promise<string | null>;
|
|
26
|
+
/** How long a verdict is served without asking again. */
|
|
27
|
+
ttlMs: number;
|
|
28
|
+
/** Upper bound on the rooms remembered at once (oldest dropped first). */
|
|
29
|
+
maxRooms?: number;
|
|
30
|
+
now?: () => number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Verdict {
|
|
34
|
+
alias: string | null;
|
|
35
|
+
at: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_MAX_ROOMS = 5_000;
|
|
39
|
+
|
|
40
|
+
export class VerifiedRoomAliases {
|
|
41
|
+
private readonly verdicts = new Map<string, Verdict>();
|
|
42
|
+
/** Bumped by `invalidate`, so a lookup that started before a change is not memoised after it. */
|
|
43
|
+
private readonly generations = new Map<string, number>();
|
|
44
|
+
private readonly now: () => number;
|
|
45
|
+
|
|
46
|
+
constructor(private readonly deps: VerifiedRoomAliasDeps) {
|
|
47
|
+
this.now = deps.now ?? Date.now;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The memoised verified alias of a room; null when there is none or no fresh verdict. */
|
|
51
|
+
known(roomId: string): string | null {
|
|
52
|
+
return this.fresh(roomId)?.alias ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The room's verified alias (null = none). Throws when the homeserver could not be asked. */
|
|
56
|
+
async verify(roomId: string): Promise<string | null> {
|
|
57
|
+
const memo = this.fresh(roomId);
|
|
58
|
+
if (memo) return memo.alias;
|
|
59
|
+
const generation = this.generations.get(roomId) ?? 0;
|
|
60
|
+
const claimed = await this.deps.readCanonicalAlias(roomId);
|
|
61
|
+
const alias =
|
|
62
|
+
claimed !== null && (await this.deps.resolveAlias(claimed)) === roomId
|
|
63
|
+
? claimed
|
|
64
|
+
: null;
|
|
65
|
+
if ((this.generations.get(roomId) ?? 0) === generation)
|
|
66
|
+
this.remember(roomId, alias);
|
|
67
|
+
return alias;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Forget the room's verdict (its alias state changed). */
|
|
71
|
+
invalidate(roomId: string): void {
|
|
72
|
+
this.verdicts.delete(roomId);
|
|
73
|
+
this.generations.set(roomId, (this.generations.get(roomId) ?? 0) + 1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private fresh(roomId: string): Verdict | undefined {
|
|
77
|
+
const verdict = this.verdicts.get(roomId);
|
|
78
|
+
if (!verdict) return undefined;
|
|
79
|
+
if (this.now() - verdict.at >= this.deps.ttlMs) {
|
|
80
|
+
this.verdicts.delete(roomId);
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
return verdict;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private remember(roomId: string, alias: string | null): void {
|
|
87
|
+
this.verdicts.delete(roomId);
|
|
88
|
+
const max = this.deps.maxRooms ?? DEFAULT_MAX_ROOMS;
|
|
89
|
+
while (this.verdicts.size >= max) {
|
|
90
|
+
const oldest = this.verdicts.keys().next().value;
|
|
91
|
+
if (oldest === undefined) break;
|
|
92
|
+
this.verdicts.delete(oldest);
|
|
93
|
+
}
|
|
94
|
+
this.verdicts.set(roomId, { alias, at: this.now() });
|
|
95
|
+
}
|
|
96
|
+
}
|