diff --git a/extensions/matrix/runtime-api.ts b/extensions/matrix/runtime-api.ts index 52df80f9843..bc8163c9969 100644 --- a/extensions/matrix/runtime-api.ts +++ b/extensions/matrix/runtime-api.ts @@ -1,14 +1,4 @@ -export * from "openclaw/plugin-sdk/matrix"; +// Keep the external runtime API light so Jiti callers can resolve Matrix config +// helpers without traversing the full plugin-sdk/runtime graph. export * from "./src/auth-precedence.js"; -export { - findMatrixAccountEntry, - hashMatrixAccessToken, - listMatrixEnvAccountIds, - resolveConfiguredMatrixAccountIds, - resolveMatrixChannelConfig, - resolveMatrixCredentialsFilename, - resolveMatrixEnvAccountToken, - resolveMatrixHomeserverKey, - resolveMatrixLegacyFlatStoreRoot, - sanitizeMatrixPathSegment, -} from "./helper-api.js"; +export * from "./helper-api.js"; diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index cfc4ccdddf1..34b6b9610e3 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -17,14 +17,6 @@ import { } from "openclaw/plugin-sdk/channel-runtime"; import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; -import { - buildChannelConfigSchema, - buildProbeChannelStatusSummary, - collectStatusIssuesFromLastError, - DEFAULT_ACCOUNT_ID, - PAIRING_APPROVED_MESSAGE, - type ChannelPlugin, -} from "../runtime-api.js"; import { matrixMessageActions } from "./actions.js"; import { MatrixConfigSchema } from "./config-schema.js"; import { @@ -44,6 +36,14 @@ import { resolveMatrixDirectUserId, resolveMatrixTargetIdentity, } from "./matrix/target-ids.js"; +import { + buildChannelConfigSchema, + buildProbeChannelStatusSummary, + collectStatusIssuesFromLastError, + DEFAULT_ACCOUNT_ID, + PAIRING_APPROVED_MESSAGE, + type ChannelPlugin, +} from "./runtime-api.js"; import { getMatrixRuntime } from "./runtime.js"; import { resolveMatrixOutboundSessionRoute } from "./session-route.js"; import { matrixSetupAdapter } from "./setup-core.js"; diff --git a/extensions/matrix/src/matrix/thread-bindings-shared.ts b/extensions/matrix/src/matrix/thread-bindings-shared.ts new file mode 100644 index 00000000000..f8c9c2b9e3f --- /dev/null +++ b/extensions/matrix/src/matrix/thread-bindings-shared.ts @@ -0,0 +1,225 @@ +import type { + BindingTargetKind, + SessionBindingRecord, +} from "openclaw/plugin-sdk/conversation-runtime"; + +export type MatrixThreadBindingTargetKind = "subagent" | "acp"; + +export type MatrixThreadBindingRecord = { + accountId: string; + conversationId: string; + parentConversationId?: string; + targetKind: MatrixThreadBindingTargetKind; + targetSessionKey: string; + agentId?: string; + label?: string; + boundBy?: string; + boundAt: number; + lastActivityAt: number; + idleTimeoutMs?: number; + maxAgeMs?: number; +}; + +export type MatrixThreadBindingManager = { + accountId: string; + getIdleTimeoutMs: () => number; + getMaxAgeMs: () => number; + getByConversation: (params: { + conversationId: string; + parentConversationId?: string; + }) => MatrixThreadBindingRecord | undefined; + listBySessionKey: (targetSessionKey: string) => MatrixThreadBindingRecord[]; + listBindings: () => MatrixThreadBindingRecord[]; + touchBinding: (bindingId: string, at?: number) => MatrixThreadBindingRecord | null; + setIdleTimeoutBySessionKey: (params: { + targetSessionKey: string; + idleTimeoutMs: number; + }) => MatrixThreadBindingRecord[]; + setMaxAgeBySessionKey: (params: { + targetSessionKey: string; + maxAgeMs: number; + }) => MatrixThreadBindingRecord[]; + stop: () => void; +}; + +export type MatrixThreadBindingManagerCacheEntry = { + filePath: string; + manager: MatrixThreadBindingManager; +}; + +const MANAGERS_BY_ACCOUNT_ID = new Map(); +const BINDINGS_BY_ACCOUNT_CONVERSATION = new Map(); + +export function resolveBindingKey(params: { + accountId: string; + conversationId: string; + parentConversationId?: string; +}): string { + return `${params.accountId}:${params.parentConversationId?.trim() || "-"}:${params.conversationId}`; +} + +function toSessionBindingTargetKind(raw: MatrixThreadBindingTargetKind): BindingTargetKind { + return raw === "subagent" ? "subagent" : "session"; +} + +export function toMatrixBindingTargetKind(raw: BindingTargetKind): MatrixThreadBindingTargetKind { + return raw === "subagent" ? "subagent" : "acp"; +} + +export function resolveEffectiveBindingExpiry(params: { + record: MatrixThreadBindingRecord; + defaultIdleTimeoutMs: number; + defaultMaxAgeMs: number; +}): { + expiresAt?: number; + reason?: "idle-expired" | "max-age-expired"; +} { + const idleTimeoutMs = + typeof params.record.idleTimeoutMs === "number" + ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) + : params.defaultIdleTimeoutMs; + const maxAgeMs = + typeof params.record.maxAgeMs === "number" + ? Math.max(0, Math.floor(params.record.maxAgeMs)) + : params.defaultMaxAgeMs; + const inactivityExpiresAt = + idleTimeoutMs > 0 + ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs + : undefined; + const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; + + if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { + return inactivityExpiresAt <= maxAgeExpiresAt + ? { expiresAt: inactivityExpiresAt, reason: "idle-expired" } + : { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; + } + if (inactivityExpiresAt != null) { + return { expiresAt: inactivityExpiresAt, reason: "idle-expired" }; + } + if (maxAgeExpiresAt != null) { + return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; + } + return {}; +} + +export function toSessionBindingRecord( + record: MatrixThreadBindingRecord, + defaults: { idleTimeoutMs: number; maxAgeMs: number }, +): SessionBindingRecord { + const lifecycle = resolveEffectiveBindingExpiry({ + record, + defaultIdleTimeoutMs: defaults.idleTimeoutMs, + defaultMaxAgeMs: defaults.maxAgeMs, + }); + const idleTimeoutMs = + typeof record.idleTimeoutMs === "number" ? record.idleTimeoutMs : defaults.idleTimeoutMs; + const maxAgeMs = typeof record.maxAgeMs === "number" ? record.maxAgeMs : defaults.maxAgeMs; + return { + bindingId: resolveBindingKey(record), + targetSessionKey: record.targetSessionKey, + targetKind: toSessionBindingTargetKind(record.targetKind), + conversation: { + channel: "matrix", + accountId: record.accountId, + conversationId: record.conversationId, + parentConversationId: record.parentConversationId, + }, + status: "active", + boundAt: record.boundAt, + expiresAt: lifecycle.expiresAt, + metadata: { + agentId: record.agentId, + label: record.label, + boundBy: record.boundBy, + lastActivityAt: record.lastActivityAt, + idleTimeoutMs, + maxAgeMs, + }, + }; +} + +export function setBindingRecord(record: MatrixThreadBindingRecord): void { + BINDINGS_BY_ACCOUNT_CONVERSATION.set(resolveBindingKey(record), record); +} + +export function removeBindingRecord( + record: MatrixThreadBindingRecord, +): MatrixThreadBindingRecord | null { + const key = resolveBindingKey(record); + const removed = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key) ?? null; + if (removed) { + BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); + } + return removed; +} + +export function listBindingsForAccount(accountId: string): MatrixThreadBindingRecord[] { + return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( + (entry) => entry.accountId === accountId, + ); +} + +export function getMatrixThreadBindingManagerEntry( + accountId: string, +): MatrixThreadBindingManagerCacheEntry | null { + return MANAGERS_BY_ACCOUNT_ID.get(accountId) ?? null; +} + +export function setMatrixThreadBindingManagerEntry( + accountId: string, + entry: MatrixThreadBindingManagerCacheEntry, +): void { + MANAGERS_BY_ACCOUNT_ID.set(accountId, entry); +} + +export function deleteMatrixThreadBindingManagerEntry(accountId: string): void { + MANAGERS_BY_ACCOUNT_ID.delete(accountId); +} + +export function getMatrixThreadBindingManager( + accountId: string, +): MatrixThreadBindingManager | null { + return MANAGERS_BY_ACCOUNT_ID.get(accountId)?.manager ?? null; +} + +export function setMatrixThreadBindingIdleTimeoutBySessionKey(params: { + accountId: string; + targetSessionKey: string; + idleTimeoutMs: number; +}): SessionBindingRecord[] { + const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager; + if (!manager) { + return []; + } + return manager.setIdleTimeoutBySessionKey(params).map((record) => + toSessionBindingRecord(record, { + idleTimeoutMs: manager.getIdleTimeoutMs(), + maxAgeMs: manager.getMaxAgeMs(), + }), + ); +} + +export function setMatrixThreadBindingMaxAgeBySessionKey(params: { + accountId: string; + targetSessionKey: string; + maxAgeMs: number; +}): SessionBindingRecord[] { + const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager; + if (!manager) { + return []; + } + return manager.setMaxAgeBySessionKey(params).map((record) => + toSessionBindingRecord(record, { + idleTimeoutMs: manager.getIdleTimeoutMs(), + maxAgeMs: manager.getMaxAgeMs(), + }), + ); +} + +export function resetMatrixThreadBindingsForTests(): void { + for (const { manager } of MANAGERS_BY_ACCOUNT_ID.values()) { + manager.stop(); + } + MANAGERS_BY_ACCOUNT_ID.clear(); + BINDINGS_BY_ACCOUNT_CONVERSATION.clear(); +} diff --git a/extensions/matrix/src/matrix/thread-bindings.ts b/extensions/matrix/src/matrix/thread-bindings.ts index 6cf8029f9e9..edbbde5d000 100644 --- a/extensions/matrix/src/matrix/thread-bindings.ts +++ b/extensions/matrix/src/matrix/thread-bindings.ts @@ -6,70 +6,39 @@ import { resolveThreadBindingFarewellText, unregisterSessionBindingAdapter, writeJsonFileAtomically, - type BindingTargetKind, - type SessionBindingRecord, } from "../runtime-api.js"; import { resolveMatrixStoragePaths } from "./client/storage.js"; import type { MatrixAuth } from "./client/types.js"; import type { MatrixClient } from "./sdk.js"; import { sendMessageMatrix } from "./send.js"; +import { + deleteMatrixThreadBindingManagerEntry, + getMatrixThreadBindingManager, + getMatrixThreadBindingManagerEntry, + listBindingsForAccount, + removeBindingRecord, + resetMatrixThreadBindingsForTests, + resolveBindingKey, + resolveEffectiveBindingExpiry, + setBindingRecord, + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingManagerEntry, + setMatrixThreadBindingMaxAgeBySessionKey, + toMatrixBindingTargetKind, + toSessionBindingRecord, + type MatrixThreadBindingManager, + type MatrixThreadBindingRecord, +} from "./thread-bindings-shared.js"; const STORE_VERSION = 1; const THREAD_BINDINGS_SWEEP_INTERVAL_MS = 60_000; const TOUCH_PERSIST_DELAY_MS = 30_000; -type MatrixThreadBindingTargetKind = "subagent" | "acp"; - -type MatrixThreadBindingRecord = { - accountId: string; - conversationId: string; - parentConversationId?: string; - targetKind: MatrixThreadBindingTargetKind; - targetSessionKey: string; - agentId?: string; - label?: string; - boundBy?: string; - boundAt: number; - lastActivityAt: number; - idleTimeoutMs?: number; - maxAgeMs?: number; -}; - type StoredMatrixThreadBindingState = { version: number; bindings: MatrixThreadBindingRecord[]; }; -export type MatrixThreadBindingManager = { - accountId: string; - getIdleTimeoutMs: () => number; - getMaxAgeMs: () => number; - getByConversation: (params: { - conversationId: string; - parentConversationId?: string; - }) => MatrixThreadBindingRecord | undefined; - listBySessionKey: (targetSessionKey: string) => MatrixThreadBindingRecord[]; - listBindings: () => MatrixThreadBindingRecord[]; - touchBinding: (bindingId: string, at?: number) => MatrixThreadBindingRecord | null; - setIdleTimeoutBySessionKey: (params: { - targetSessionKey: string; - idleTimeoutMs: number; - }) => MatrixThreadBindingRecord[]; - setMaxAgeBySessionKey: (params: { - targetSessionKey: string; - maxAgeMs: number; - }) => MatrixThreadBindingRecord[]; - stop: () => void; -}; - -type MatrixThreadBindingManagerCacheEntry = { - filePath: string; - manager: MatrixThreadBindingManager; -}; - -const MANAGERS_BY_ACCOUNT_ID = new Map(); -const BINDINGS_BY_ACCOUNT_CONVERSATION = new Map(); - function normalizeDurationMs(raw: unknown, fallback: number): number { if (typeof raw !== "number" || !Number.isFinite(raw)) { return fallback; @@ -86,94 +55,6 @@ function normalizeConversationId(raw: unknown): string | undefined { return trimmed || undefined; } -function resolveBindingKey(params: { - accountId: string; - conversationId: string; - parentConversationId?: string; -}): string { - return `${params.accountId}:${params.parentConversationId?.trim() || "-"}:${params.conversationId}`; -} - -function toSessionBindingTargetKind(raw: MatrixThreadBindingTargetKind): BindingTargetKind { - return raw === "subagent" ? "subagent" : "session"; -} - -function toMatrixBindingTargetKind(raw: BindingTargetKind): MatrixThreadBindingTargetKind { - return raw === "subagent" ? "subagent" : "acp"; -} - -function resolveEffectiveBindingExpiry(params: { - record: MatrixThreadBindingRecord; - defaultIdleTimeoutMs: number; - defaultMaxAgeMs: number; -}): { - expiresAt?: number; - reason?: "idle-expired" | "max-age-expired"; -} { - const idleTimeoutMs = - typeof params.record.idleTimeoutMs === "number" - ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) - : params.defaultIdleTimeoutMs; - const maxAgeMs = - typeof params.record.maxAgeMs === "number" - ? Math.max(0, Math.floor(params.record.maxAgeMs)) - : params.defaultMaxAgeMs; - const inactivityExpiresAt = - idleTimeoutMs > 0 - ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs - : undefined; - const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; - - if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { - return inactivityExpiresAt <= maxAgeExpiresAt - ? { expiresAt: inactivityExpiresAt, reason: "idle-expired" } - : { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; - } - if (inactivityExpiresAt != null) { - return { expiresAt: inactivityExpiresAt, reason: "idle-expired" }; - } - if (maxAgeExpiresAt != null) { - return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; - } - return {}; -} - -function toSessionBindingRecord( - record: MatrixThreadBindingRecord, - defaults: { idleTimeoutMs: number; maxAgeMs: number }, -): SessionBindingRecord { - const lifecycle = resolveEffectiveBindingExpiry({ - record, - defaultIdleTimeoutMs: defaults.idleTimeoutMs, - defaultMaxAgeMs: defaults.maxAgeMs, - }); - const idleTimeoutMs = - typeof record.idleTimeoutMs === "number" ? record.idleTimeoutMs : defaults.idleTimeoutMs; - const maxAgeMs = typeof record.maxAgeMs === "number" ? record.maxAgeMs : defaults.maxAgeMs; - return { - bindingId: resolveBindingKey(record), - targetSessionKey: record.targetSessionKey, - targetKind: toSessionBindingTargetKind(record.targetKind), - conversation: { - channel: "matrix", - accountId: record.accountId, - conversationId: record.conversationId, - parentConversationId: record.parentConversationId, - }, - status: "active", - boundAt: record.boundAt, - expiresAt: lifecycle.expiresAt, - metadata: { - agentId: record.agentId, - label: record.label, - boundBy: record.boundBy, - lastActivityAt: record.lastActivityAt, - idleTimeoutMs, - maxAgeMs, - }, - }; -} - function resolveBindingsPath(params: { auth: MatrixAuth; accountId: string; @@ -256,25 +137,6 @@ async function persistBindingsSnapshot( await writeJsonFileAtomically(filePath, toStoredBindingsState(bindings)); } -function setBindingRecord(record: MatrixThreadBindingRecord): void { - BINDINGS_BY_ACCOUNT_CONVERSATION.set(resolveBindingKey(record), record); -} - -function removeBindingRecord(record: MatrixThreadBindingRecord): MatrixThreadBindingRecord | null { - const key = resolveBindingKey(record); - const removed = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key) ?? null; - if (removed) { - BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); - } - return removed; -} - -function listBindingsForAccount(accountId: string): MatrixThreadBindingRecord[] { - return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( - (entry) => entry.accountId === accountId, - ); -} - function buildMatrixBindingIntroText(params: { metadata?: Record; targetSessionKey: string; @@ -365,7 +227,7 @@ export async function createMatrixThreadBindingManager(params: { env: params.env, stateDir: params.stateDir, }); - const existingEntry = MANAGERS_BY_ACCOUNT_ID.get(params.accountId); + const existingEntry = getMatrixThreadBindingManagerEntry(params.accountId); if (existingEntry) { if (existingEntry.filePath === filePath) { return existingEntry.manager; @@ -506,11 +368,11 @@ export async function createMatrixThreadBindingManager(params: { channel: "matrix", accountId: params.accountId, }); - if (MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager === manager) { - MANAGERS_BY_ACCOUNT_ID.delete(params.accountId); + if (getMatrixThreadBindingManagerEntry(params.accountId)?.manager === manager) { + deleteMatrixThreadBindingManagerEntry(params.accountId); } for (const record of listBindingsForAccount(params.accountId)) { - BINDINGS_BY_ACCOUNT_CONVERSATION.delete(resolveBindingKey(record)); + removeBindingRecord(record); } }, }; @@ -705,57 +567,15 @@ export async function createMatrixThreadBindingManager(params: { sweepTimer.unref?.(); } - MANAGERS_BY_ACCOUNT_ID.set(params.accountId, { + setMatrixThreadBindingManagerEntry(params.accountId, { filePath, manager, }); return manager; } - -export function getMatrixThreadBindingManager( - accountId: string, -): MatrixThreadBindingManager | null { - return MANAGERS_BY_ACCOUNT_ID.get(accountId)?.manager ?? null; -} - -export function setMatrixThreadBindingIdleTimeoutBySessionKey(params: { - accountId: string; - targetSessionKey: string; - idleTimeoutMs: number; -}): SessionBindingRecord[] { - const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager; - if (!manager) { - return []; - } - return manager.setIdleTimeoutBySessionKey(params).map((record) => - toSessionBindingRecord(record, { - idleTimeoutMs: manager.getIdleTimeoutMs(), - maxAgeMs: manager.getMaxAgeMs(), - }), - ); -} - -export function setMatrixThreadBindingMaxAgeBySessionKey(params: { - accountId: string; - targetSessionKey: string; - maxAgeMs: number; -}): SessionBindingRecord[] { - const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager; - if (!manager) { - return []; - } - return manager.setMaxAgeBySessionKey(params).map((record) => - toSessionBindingRecord(record, { - idleTimeoutMs: manager.getIdleTimeoutMs(), - maxAgeMs: manager.getMaxAgeMs(), - }), - ); -} - -export function resetMatrixThreadBindingsForTests(): void { - for (const { manager } of MANAGERS_BY_ACCOUNT_ID.values()) { - manager.stop(); - } - MANAGERS_BY_ACCOUNT_ID.clear(); - BINDINGS_BY_ACCOUNT_CONVERSATION.clear(); -} +export { + getMatrixThreadBindingManager, + resetMatrixThreadBindingsForTests, + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +}; diff --git a/extensions/matrix/src/runtime-api.ts b/extensions/matrix/src/runtime-api.ts index ece735819df..3c447f50e2f 100644 --- a/extensions/matrix/src/runtime-api.ts +++ b/extensions/matrix/src/runtime-api.ts @@ -1 +1,2 @@ +export * from "openclaw/plugin-sdk/matrix"; export * from "../runtime-api.js"; diff --git a/extensions/matrix/thread-bindings-runtime.ts b/extensions/matrix/thread-bindings-runtime.ts new file mode 100644 index 00000000000..b0e8ff49628 --- /dev/null +++ b/extensions/matrix/thread-bindings-runtime.ts @@ -0,0 +1,4 @@ +export { + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +} from "./src/matrix/thread-bindings-shared.js"; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index a85e8997389..660fe7183fb 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -85,7 +85,7 @@ export { export { setMatrixThreadBindingIdleTimeoutBySessionKey, setMatrixThreadBindingMaxAgeBySessionKey, -} from "../../extensions/matrix/src/matrix/thread-bindings.js"; +} from "../../extensions/matrix/thread-bindings-runtime.js"; export { createTypingCallbacks } from "../channels/typing.js"; export { createChannelReplyPipeline } from "./channel-reply-pipeline.js"; export type { OpenClawConfig } from "../config/config.js"; diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 0a7eab63727..1a44e0e45f1 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -195,8 +195,8 @@ export type PluginRuntimeChannel = { }; matrix: { threadBindings: { - setIdleTimeoutBySessionKey: typeof import("../../../extensions/matrix/runtime-api.js").setMatrixThreadBindingIdleTimeoutBySessionKey; - setMaxAgeBySessionKey: typeof import("../../../extensions/matrix/runtime-api.js").setMatrixThreadBindingMaxAgeBySessionKey; + setIdleTimeoutBySessionKey: typeof import("../../plugin-sdk/matrix.js").setMatrixThreadBindingIdleTimeoutBySessionKey; + setMaxAgeBySessionKey: typeof import("../../plugin-sdk/matrix.js").setMatrixThreadBindingMaxAgeBySessionKey; }; }; signal: {