r2-relay-channel 0.3.5

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +309 -0
  3. package/dist/channel.d.ts +16 -0
  4. package/dist/channel.js +271 -0
  5. package/dist/checkpoint-store.d.ts +4 -0
  6. package/dist/checkpoint-store.js +16 -0
  7. package/dist/commands.d.ts +2 -0
  8. package/dist/commands.js +54 -0
  9. package/dist/config.d.ts +76 -0
  10. package/dist/config.js +123 -0
  11. package/dist/conversation-targets.d.ts +32 -0
  12. package/dist/conversation-targets.js +120 -0
  13. package/dist/inbound.d.ts +38 -0
  14. package/dist/inbound.js +440 -0
  15. package/dist/index.d.ts +4 -0
  16. package/dist/index.js +26 -0
  17. package/dist/lifecycle.d.ts +64 -0
  18. package/dist/lifecycle.js +296 -0
  19. package/dist/media.d.ts +30 -0
  20. package/dist/media.js +361 -0
  21. package/dist/message-adapter.d.ts +27 -0
  22. package/dist/message-adapter.js +53 -0
  23. package/dist/openclaw.plugin.json +41 -0
  24. package/dist/outbound.d.ts +28 -0
  25. package/dist/outbound.js +141 -0
  26. package/dist/protocol.d.ts +2 -0
  27. package/dist/protocol.js +1 -0
  28. package/dist/r2-relay.d.ts +1 -0
  29. package/dist/r2-relay.js +1 -0
  30. package/dist/r2client.d.ts +33 -0
  31. package/dist/r2client.js +137 -0
  32. package/dist/relay-core/checkpoint.d.ts +31 -0
  33. package/dist/relay-core/checkpoint.js +95 -0
  34. package/dist/relay-core/errors.d.ts +9 -0
  35. package/dist/relay-core/errors.js +18 -0
  36. package/dist/relay-core/index.d.ts +6 -0
  37. package/dist/relay-core/index.js +6 -0
  38. package/dist/relay-core/keyspace.d.ts +24 -0
  39. package/dist/relay-core/keyspace.js +60 -0
  40. package/dist/relay-core/object-store.d.ts +20 -0
  41. package/dist/relay-core/object-store.js +1 -0
  42. package/dist/relay-core/transport.d.ts +48 -0
  43. package/dist/relay-core/transport.js +326 -0
  44. package/dist/relay-core/types.d.ts +220 -0
  45. package/dist/relay-core/types.js +1 -0
  46. package/dist/runtime.d.ts +9 -0
  47. package/dist/runtime.js +54 -0
  48. package/dist/service.d.ts +51 -0
  49. package/dist/service.js +186 -0
  50. package/dist/service.test.d.ts +1 -0
  51. package/dist/service.test.js +27 -0
  52. package/dist/session-publication.d.ts +7 -0
  53. package/dist/session-publication.js +365 -0
  54. package/dist/setup-entry.d.ts +6 -0
  55. package/dist/setup-entry.js +49 -0
  56. package/dist/setup.d.ts +3 -0
  57. package/dist/setup.js +291 -0
  58. package/dist/target.d.ts +6 -0
  59. package/dist/target.js +53 -0
  60. package/dist/webhook.d.ts +18 -0
  61. package/dist/webhook.js +225 -0
  62. package/openclaw.plugin.json +41 -0
  63. package/package.json +62 -0
@@ -0,0 +1,54 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import os from "node:os";
5
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
6
+ import { PLUGIN_ID } from "./config.js";
7
+ const { setRuntime: setRelayRuntime, getRuntime: getRelayRuntime } = createPluginRuntimeStore("R2 Relay runtime not initialized");
8
+ let relayConfig = null;
9
+ let relayStateDir = null;
10
+ let persistedServerId = null;
11
+ function setRelayConfig(cfg) {
12
+ relayConfig = cfg;
13
+ }
14
+ function getRelayConfig() {
15
+ if (!relayConfig) {
16
+ throw new Error("R2 Relay config not initialized");
17
+ }
18
+ return relayConfig;
19
+ }
20
+ function setRelayStateDir(stateDir) {
21
+ relayStateDir = stateDir;
22
+ }
23
+ function resolveFallbackStateDir() {
24
+ const fromEnv = process.env.OPENCLAW_STATE_DIR?.trim();
25
+ if (fromEnv) {
26
+ return fromEnv;
27
+ }
28
+ return path.join(os.homedir(), ".openclaw");
29
+ }
30
+ function resolvePluginStateDir() {
31
+ const baseStateDir = relayStateDir ?? resolveFallbackStateDir();
32
+ return path.join(baseStateDir, "plugins", PLUGIN_ID);
33
+ }
34
+ function getPersistedRelayServerId() {
35
+ if (persistedServerId) {
36
+ return persistedServerId;
37
+ }
38
+ const dir = resolvePluginStateDir();
39
+ const file = path.join(dir, "server-id.txt");
40
+ try {
41
+ const existing = fs.readFileSync(file, "utf8").trim();
42
+ if (existing) {
43
+ persistedServerId = existing;
44
+ return existing;
45
+ }
46
+ }
47
+ catch { }
48
+ fs.mkdirSync(dir, { recursive: true });
49
+ const generated = `srv-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
50
+ fs.writeFileSync(file, `${generated}\n`, "utf8");
51
+ persistedServerId = generated;
52
+ return generated;
53
+ }
54
+ export { getPersistedRelayServerId, getRelayConfig, getRelayRuntime, resolvePluginStateDir, setRelayConfig, setRelayRuntime, setRelayStateDir };
@@ -0,0 +1,51 @@
1
+ import { type HeadDoc, type IdentityDoc, type InboxBatch, type RelayMessage, type RelayRetentionConfig, type SendMessageOptions, type SendMessageResult, type SweepRuleSummary } from "./relay-core/index.js";
2
+ import { R2Relay } from "./protocol.js";
3
+ import { R2Client } from "./r2client.js";
4
+ export declare const IDENTITY_REFRESH_INTERVAL_MS: number;
5
+ export declare const RELAY_PLUGIN_VERSION: string;
6
+ export interface ServiceConfig {
7
+ endpoint: string;
8
+ bucket: string;
9
+ region?: string;
10
+ accessKeyId?: string;
11
+ secretAccessKey?: string;
12
+ forcePathStyle?: boolean;
13
+ peerId: string;
14
+ }
15
+ export interface InboxMessage {
16
+ key: string;
17
+ message: RelayMessage;
18
+ }
19
+ export declare class Service {
20
+ readonly client: R2Client;
21
+ readonly relay: R2Relay;
22
+ readonly cfg: ServiceConfig;
23
+ private readonly transport;
24
+ constructor(cfg: ServiceConfig);
25
+ getPresignedUrl(key: string, expiresIn: number): Promise<string>;
26
+ getAttachmentObject(key: string): Promise<import("@aws-sdk/client-s3").GetObjectCommandOutput | null>;
27
+ publishIdentity(identity?: Partial<IdentityDoc>): Promise<IdentityDoc>;
28
+ publishIdentify(identity?: Partial<IdentityDoc>): Promise<IdentityDoc>;
29
+ getIdentity(peer: string): Promise<IdentityDoc | null>;
30
+ sendMessage(to: string, options: SendMessageOptions): Promise<SendMessageResult>;
31
+ sendStreamingSnapshots(to: string, snapshots: string[], options: Omit<SendMessageOptions, "delivery">): Promise<{
32
+ streamId: string;
33
+ results: SendMessageResult[];
34
+ }>;
35
+ getHead(peer: string): Promise<HeadDoc | null>;
36
+ getHeadState(peer: string): Promise<{
37
+ doc: HeadDoc;
38
+ etag: string | null;
39
+ } | null>;
40
+ readMessage(key: string): Promise<RelayMessage | null>;
41
+ markMessageProcessed(key: string, patch: {
42
+ processedAt?: number;
43
+ processedBy?: string | null;
44
+ state?: "pending" | "processing" | "done" | "error";
45
+ error?: string | null;
46
+ }): Promise<RelayMessage | null>;
47
+ collectInboxMessages(selfId: string, lastSeenKey: string | null): Promise<InboxBatch>;
48
+ sweepByKeyTimestamp(prefix: string, ttlDays: number, abortSignal?: AbortSignal): Promise<SweepRuleSummary>;
49
+ sweepRetention(ttl: RelayRetentionConfig, abortSignal?: AbortSignal): Promise<SweepRuleSummary[]>;
50
+ pollInbox(selfId: string, handler: (msg: RelayMessage, key: string) => Promise<void>, pollIntervalMs?: number, backoffMax?: number, deleteAfterProcessing?: boolean, abortSignal?: AbortSignal): Promise<void>;
51
+ }
@@ -0,0 +1,186 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { PreconditionFailedError, R2RelayTransport, } from "./relay-core/index.js";
5
+ import { R2Relay } from "./protocol.js";
6
+ import { R2Client } from "./r2client.js";
7
+ export const IDENTITY_REFRESH_INTERVAL_MS = 12 * 60 * 60 * 1000;
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+ function resolvePluginVersion() {
11
+ try {
12
+ const pkgPath = path.join(__dirname, "..", "package.json");
13
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
14
+ const version = pkg.version?.trim();
15
+ return version || "0.0.0";
16
+ }
17
+ catch {
18
+ return "0.0.0";
19
+ }
20
+ }
21
+ export const RELAY_PLUGIN_VERSION = resolvePluginVersion();
22
+ class R2ObjectStoreAdapter {
23
+ client;
24
+ constructor(client) {
25
+ this.client = client;
26
+ }
27
+ async putObject(key, body, contentType, tagging, ifMatch, ifNoneMatch) {
28
+ try {
29
+ return await this.client.putObject(key, body, contentType, tagging, ifMatch, ifNoneMatch);
30
+ }
31
+ catch (err) {
32
+ const value = err;
33
+ if (value?.code === "PreconditionFailed") {
34
+ throw new PreconditionFailedError();
35
+ }
36
+ throw err;
37
+ }
38
+ }
39
+ async getObject(key) {
40
+ return await this.client.getObject(key);
41
+ }
42
+ async getJsonWithEtag(key) {
43
+ return await this.client.getJsonWithEtag(key);
44
+ }
45
+ async deleteObject(key) {
46
+ return await this.client.deleteObject(key);
47
+ }
48
+ async deleteObjects(keys) {
49
+ return await this.client.deleteObjects(keys);
50
+ }
51
+ async listPrefixPage(prefix, continuationToken, maxKeys) {
52
+ return await this.client.listPrefixPage(prefix, continuationToken, maxKeys);
53
+ }
54
+ }
55
+ async function streamToUtf8(stream) {
56
+ if (typeof stream === "string") {
57
+ return stream;
58
+ }
59
+ if (!stream) {
60
+ return "";
61
+ }
62
+ const body = stream;
63
+ if (typeof body.transformToString === "function") {
64
+ return await body.transformToString();
65
+ }
66
+ if (typeof body[Symbol.asyncIterator] === "function") {
67
+ const chunks = [];
68
+ for await (const chunk of body) {
69
+ chunks.push(Buffer.from(chunk));
70
+ }
71
+ return Buffer.concat(chunks).toString("utf8");
72
+ }
73
+ return String(stream);
74
+ }
75
+ async function sleep(ms, abortSignal) {
76
+ await new Promise((resolve) => {
77
+ const timer = setTimeout(resolve, ms);
78
+ if (!abortSignal) {
79
+ return;
80
+ }
81
+ const onAbort = () => {
82
+ clearTimeout(timer);
83
+ resolve();
84
+ };
85
+ if (abortSignal.aborted) {
86
+ onAbort();
87
+ return;
88
+ }
89
+ abortSignal.addEventListener("abort", onAbort, { once: true });
90
+ });
91
+ }
92
+ export class Service {
93
+ client;
94
+ relay;
95
+ cfg;
96
+ transport;
97
+ constructor(cfg) {
98
+ this.cfg = cfg;
99
+ this.client = new R2Client(cfg);
100
+ this.relay = new R2Relay({ bucket: cfg.bucket });
101
+ this.transport = new R2RelayTransport({
102
+ store: new R2ObjectStoreAdapter(this.client),
103
+ keyspace: this.relay,
104
+ peerId: cfg.peerId,
105
+ sleep: (ms) => sleep(ms),
106
+ });
107
+ }
108
+ async getPresignedUrl(key, expiresIn) {
109
+ return this.client.getPresignedUrl(key, expiresIn);
110
+ }
111
+ async getAttachmentObject(key) {
112
+ return this.client.getObject(key);
113
+ }
114
+ async publishIdentity(identity) {
115
+ return await this.transport.publishIdentity({
116
+ role: "server",
117
+ software: { id: "openclaw", name: "OpenClaw", version: RELAY_PLUGIN_VERSION },
118
+ protocol: { name: "r2-relay", version: 3 },
119
+ capabilities: {
120
+ messaging: { text: true, streaming: true, reactions: true, system_events: true },
121
+ conversations: { list: true, create: false, reset: false, archive: false, threading: false },
122
+ agents: { list: true, multiple: true, switch: true, per_agent_models: false },
123
+ attachments: null,
124
+ approvals: {
125
+ exec: true,
126
+ tool: false,
127
+ custom: false,
128
+ },
129
+ extensions: null,
130
+ },
131
+ agents: [],
132
+ conversations: [],
133
+ limits: null,
134
+ ...(identity ?? {}),
135
+ peer: identity?.peer ?? this.cfg.peerId,
136
+ last_seen: identity?.last_seen ?? Date.now(),
137
+ });
138
+ }
139
+ async publishIdentify(identity) {
140
+ return await this.publishIdentity(identity);
141
+ }
142
+ async getIdentity(peer) {
143
+ const key = this.relay.makeIdentityKey(peer);
144
+ try {
145
+ const res = await this.client.getObject(key);
146
+ if (!res || typeof res !== "object" || !("Body" in res) || !res.Body) {
147
+ return null;
148
+ }
149
+ const body = await streamToUtf8(res.Body);
150
+ return JSON.parse(body);
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ async sendMessage(to, options) {
157
+ return await this.transport.sendMessage(to, options);
158
+ }
159
+ async sendStreamingSnapshots(to, snapshots, options) {
160
+ return await this.transport.sendStreamingSnapshots(to, snapshots, options);
161
+ }
162
+ async getHead(peer) {
163
+ return await this.transport.getHead(peer);
164
+ }
165
+ async getHeadState(peer) {
166
+ return await this.transport.getHeadState(peer);
167
+ }
168
+ async readMessage(key) {
169
+ return await this.transport.readMessage(key);
170
+ }
171
+ async markMessageProcessed(key, patch) {
172
+ return await this.transport.markMessageProcessed(key, patch);
173
+ }
174
+ async collectInboxMessages(selfId, lastSeenKey) {
175
+ return await this.transport.collectInboxMessages(selfId, lastSeenKey);
176
+ }
177
+ async sweepByKeyTimestamp(prefix, ttlDays, abortSignal) {
178
+ return await this.transport.sweepByKeyTimestamp(prefix, ttlDays, abortSignal);
179
+ }
180
+ async sweepRetention(ttl, abortSignal) {
181
+ return await this.transport.sweepRetention(ttl, abortSignal);
182
+ }
183
+ async pollInbox(selfId, handler, pollIntervalMs = 5000, backoffMax = 40000, deleteAfterProcessing = true, abortSignal) {
184
+ return await this.transport.pollInbox(selfId, handler, pollIntervalMs, backoffMax, deleteAfterProcessing, abortSignal);
185
+ }
186
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { Service } from "./service.js";
4
+ test("service sweepRetention delegates retention ownership to shared transport core", async () => {
5
+ const service = new Service({
6
+ endpoint: "https://example.r2.cloudflarestorage.com",
7
+ bucket: "relay-bucket",
8
+ peerId: "relay.openclaw.dev",
9
+ });
10
+ const expected = [
11
+ { prefix: "msg/", scanned: 2, deleted: 1 },
12
+ { prefix: "identity/", scanned: 1, deleted: 1 },
13
+ ];
14
+ const calls = [];
15
+ Object.defineProperty(service, "transport", {
16
+ value: {
17
+ sweepRetention: async (ttl, abortSignal) => {
18
+ calls.push({ ttl, aborted: abortSignal?.aborted ?? false });
19
+ return expected;
20
+ },
21
+ },
22
+ });
23
+ const ttl = { msg: 7, identity: 1 };
24
+ const summaries = await service.sweepRetention(ttl);
25
+ assert.deepEqual(summaries, expected);
26
+ assert.deepEqual(calls, [{ ttl, aborted: false }]);
27
+ });
@@ -0,0 +1,7 @@
1
+ import { Service } from "./service.js";
2
+ import type { ResolvedR2RelayAccount } from "./config.js";
3
+ import type { ConversationDescriptor } from "./protocol.js";
4
+ export declare function syncPublishedIdentity(service: Service, account: ResolvedR2RelayAccount, force?: boolean): Promise<void>;
5
+ export declare function schedulePublishedIdentityRefresh(service: Service, account: ResolvedR2RelayAccount, delayMs?: number): void;
6
+ export declare function agentIdFromSessionKey(sessionKey: string): string;
7
+ export declare function readPublishedConversationEntry(sessionKey: string): Promise<ConversationDescriptor | null>;
@@ -0,0 +1,365 @@
1
+ import fs from "node:fs/promises";
2
+ import { maxBytesForKind } from "openclaw/plugin-sdk/media-runtime";
3
+ import { getRelayConfig, getRelayRuntime } from "./runtime.js";
4
+ import { IDENTITY_REFRESH_INTERVAL_MS, RELAY_PLUGIN_VERSION } from "./service.js";
5
+ const MAX_IMAGE_BYTES = maxBytesForKind("image");
6
+ const MAX_VIDEO_BYTES = maxBytesForKind("video");
7
+ const MAX_AUDIO_BYTES = maxBytesForKind("audio");
8
+ const MAX_DOCUMENT_BYTES = maxBytesForKind("document");
9
+ const publishedSessionSignatures = new Map();
10
+ const publishedIdentityAt = new Map();
11
+ const scheduledIdentityRefreshes = new Map();
12
+ export async function syncPublishedIdentity(service, account, force = false) {
13
+ const { agents, conversations } = await collectPublishedConversations(account);
14
+ const modelCapabilities = await collectPublishedModelCapabilities(account);
15
+ const signature = JSON.stringify({ agents, conversations, modelCapabilities });
16
+ const previousSignature = publishedSessionSignatures.get(account.accountId);
17
+ const now = Date.now();
18
+ const lastPublishedAt = publishedIdentityAt.get(account.accountId) ?? 0;
19
+ const heartbeatDue = now - lastPublishedAt >= IDENTITY_REFRESH_INTERVAL_MS;
20
+ if (previousSignature === signature && !heartbeatDue) {
21
+ return;
22
+ }
23
+ await service.publishIdentity({
24
+ peer: account.serverId,
25
+ display_name: deriveGatewayDisplayName(account.serverId),
26
+ role: "server",
27
+ last_seen: now,
28
+ protocol: { name: "r2-relay", version: 3 },
29
+ software: { id: "openclaw", name: "OpenClaw", version: RELAY_PLUGIN_VERSION },
30
+ capabilities: {
31
+ messaging: { text: true, streaming: true, reactions: true, system_events: true },
32
+ conversations: { list: true, create: false, reset: false, archive: false, threading: false },
33
+ agents: { list: true, multiple: agents.length > 1, switch: true, per_agent_models: Boolean(modelCapabilities) },
34
+ attachments: {
35
+ supported: true,
36
+ kinds: ["image", "video", "audio", "file", "unknown"],
37
+ max_bytes_by_kind: resolvePublishedAttachmentCapabilityLimits(),
38
+ oversize_behavior: "reject",
39
+ },
40
+ approvals: {
41
+ exec: true,
42
+ tool: false,
43
+ custom: false,
44
+ },
45
+ extensions: null,
46
+ },
47
+ limits: resolvePublishedServerLimits(),
48
+ agents: modelCapabilities
49
+ ? agents.map((a) => ({ ...a, models: modelCapabilities }))
50
+ : agents,
51
+ conversations,
52
+ });
53
+ publishedSessionSignatures.set(account.accountId, signature);
54
+ publishedIdentityAt.set(account.accountId, now);
55
+ }
56
+ export function schedulePublishedIdentityRefresh(service, account, delayMs = 250) {
57
+ const existing = scheduledIdentityRefreshes.get(account.accountId);
58
+ if (existing) {
59
+ clearTimeout(existing);
60
+ }
61
+ const timer = setTimeout(() => {
62
+ scheduledIdentityRefreshes.delete(account.accountId);
63
+ void syncPublishedIdentity(service, account).catch(() => { });
64
+ }, Math.max(0, delayMs));
65
+ scheduledIdentityRefreshes.set(account.accountId, timer);
66
+ }
67
+ async function collectPublishedModelCapabilities(_account) {
68
+ try {
69
+ const cfg = getRelayConfig();
70
+ const availableByID = new Map();
71
+ const addModel = (modelRef, label) => {
72
+ const normalized = modelRef?.trim();
73
+ if (!normalized) {
74
+ return;
75
+ }
76
+ const existing = availableByID.get(normalized);
77
+ availableByID.set(normalized, {
78
+ id: normalized,
79
+ label: existing?.label ?? (label?.trim() || undefined),
80
+ provider: normalized.includes("/") ? normalized.split("/")[0] : null,
81
+ });
82
+ };
83
+ const addModelConfig = (value) => {
84
+ if (!value) {
85
+ return;
86
+ }
87
+ if (typeof value === "string") {
88
+ addModel(value);
89
+ return;
90
+ }
91
+ addModel(value.primary);
92
+ for (const fallback of value.fallbacks ?? []) {
93
+ addModel(fallback);
94
+ }
95
+ };
96
+ for (const [key, entry] of Object.entries(cfg.agents?.defaults?.models ?? {})) {
97
+ const alias = entry?.alias;
98
+ addModel(key, typeof alias === "string" ? alias : null);
99
+ }
100
+ addModelConfig(cfg.agents?.defaults?.model);
101
+ for (const agent of cfg.agents?.list ?? []) {
102
+ addModelConfig(agent?.model);
103
+ addModelConfig(agent?.subagents?.model);
104
+ }
105
+ const available = Array.from(availableByID.values()).sort((lhs, rhs) => lhs.id.localeCompare(rhs.id));
106
+ const modelConfig = cfg.agents?.defaults?.model;
107
+ const defaultModel = typeof modelConfig === "string"
108
+ ? modelConfig
109
+ : modelConfig && typeof modelConfig.primary === "string"
110
+ ? modelConfig.primary
111
+ : null;
112
+ return available.length > 0 ? { available, default: defaultModel } : null;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ async function collectPublishedConversations(_account) {
119
+ const configuredAgents = listConfiguredAgents();
120
+ const agents = configuredAgents.map((agent) => ({
121
+ id: agent.id,
122
+ display_name: agent.name ?? null,
123
+ description: null,
124
+ is_default: agent.id === "main",
125
+ models: null,
126
+ default_route: { agent_id: agent.id, conversation_id: null, instance_id: null },
127
+ capabilities: null,
128
+ }));
129
+ const conversations = [];
130
+ for (const agent of configuredAgents) {
131
+ const agentId = agent.id;
132
+ try {
133
+ const store = await readPublishedSessionStore(agentId);
134
+ for (const [sessionKey, entry] of Object.entries(store)) {
135
+ if (typeof sessionKey !== "string" || !sessionKey.startsWith(`agent:${agentId}:`)) {
136
+ continue;
137
+ }
138
+ if (!shouldPublishIdentitySession(sessionKey)) {
139
+ continue;
140
+ }
141
+ conversations.push(identityConversationDocFromEntry(sessionKey, entry));
142
+ }
143
+ }
144
+ catch {
145
+ // No session store yet for this agent.
146
+ }
147
+ }
148
+ return {
149
+ agents,
150
+ conversations: conversations.sort((a, b) => (b.updated_at ?? 0) - (a.updated_at ?? 0)),
151
+ };
152
+ }
153
+ function shouldPublishIdentitySession(sessionKey) {
154
+ const normalized = sessionKey.trim().toLowerCase();
155
+ if (!normalized) {
156
+ return false;
157
+ }
158
+ if (normalized.startsWith("cron:")) {
159
+ return false;
160
+ }
161
+ if (normalized.startsWith("dreaming-")) {
162
+ return false;
163
+ }
164
+ const parts = normalized.split(":");
165
+ if (parts.length >= 3 && parts[0] === "agent") {
166
+ const scopedKey = parts.slice(2).join(":");
167
+ if (scopedKey.startsWith("cron:")) {
168
+ return false;
169
+ }
170
+ }
171
+ return true;
172
+ }
173
+ function listConfiguredAgents() {
174
+ const cfg = getRelayConfig();
175
+ const agents = new Map([
176
+ ["main", { id: "main", name: cfg.agents?.main?.name ?? null }],
177
+ ]);
178
+ for (const agent of cfg.agents?.list ?? []) {
179
+ const id = agent?.id?.trim();
180
+ if (id && !agents.has(id)) {
181
+ agents.set(id, { id, name: agent.name ?? null });
182
+ }
183
+ }
184
+ return Array.from(agents.values());
185
+ }
186
+ export function agentIdFromSessionKey(sessionKey) {
187
+ const parts = sessionKey.split(":");
188
+ if (parts.length >= 2 && parts[0] === "agent" && parts[1]?.trim()) {
189
+ return parts[1].trim();
190
+ }
191
+ return "main";
192
+ }
193
+ function identityConversationDocFromEntry(sessionKey, entry) {
194
+ const agentId = agentIdFromSessionKey(sessionKey);
195
+ return {
196
+ id: sessionKey,
197
+ display_title: resolveConversationDisplayTitle(sessionKey, entry),
198
+ route: {
199
+ agent_id: agentId,
200
+ conversation_id: sessionKey,
201
+ instance_id: readInstanceId(entry),
202
+ },
203
+ source: conversationSourceFromEntry(sessionKey, entry),
204
+ updated_at: typeof entry?.updatedAt === "number" ? entry.updatedAt : null,
205
+ };
206
+ }
207
+ async function readPublishedSessionStore(agentId = "main") {
208
+ const storePath = getRelayRuntime().channel.session.resolveStorePath(undefined, {
209
+ agentId,
210
+ });
211
+ const raw = await fs.readFile(storePath, "utf8");
212
+ return JSON.parse(raw);
213
+ }
214
+ export async function readPublishedConversationEntry(sessionKey) {
215
+ try {
216
+ const store = await readPublishedSessionStore(agentIdFromSessionKey(sessionKey));
217
+ const entry = store[sessionKey];
218
+ if (!entry) {
219
+ return null;
220
+ }
221
+ return identityConversationDocFromEntry(sessionKey, entry);
222
+ }
223
+ catch {
224
+ return null;
225
+ }
226
+ }
227
+ function displayTitleFromConversationId(conversationId) {
228
+ const parts = conversationId.split(":");
229
+ if (parts[0] === "agent" && parts[1]) {
230
+ const remainder = parts.slice(2).join(":");
231
+ return remainder || parts[1];
232
+ }
233
+ return conversationId;
234
+ }
235
+ function resolveConversationDisplayTitle(conversationId, entry) {
236
+ const candidates = [
237
+ typeof entry?.label === "string" ? entry.label : null,
238
+ typeof entry?.displayName === "string" ? entry.displayName : null,
239
+ typeof entry?.derivedTitle === "string" ? entry.derivedTitle : null,
240
+ ];
241
+ for (const candidate of candidates) {
242
+ const trimmed = candidate?.trim();
243
+ if (trimmed) {
244
+ return trimmed;
245
+ }
246
+ }
247
+ return displayTitleFromConversationId(conversationId);
248
+ }
249
+ function conversationSourceFromEntry(sessionKey, entry) {
250
+ const parsed = parseSessionKeySource(sessionKey);
251
+ const peerKind = firstNonEmptyString(entry?.peerKind, parsed.peerKind);
252
+ const threadId = firstNonEmptyString(entry?.threadId, parsed.threadId);
253
+ const channel = normalizeChannelName(entry?.channel ?? parsed.channel ?? "local");
254
+ return {
255
+ channel,
256
+ chat_kind: normalizeChatKind(peerKind, threadId),
257
+ account_id: firstNonEmptyString(entry?.accountId, entry?.workspaceId, entry?.orgId),
258
+ account_display: firstNonEmptyString(entry?.accountDisplay, entry?.workspaceName, entry?.orgName),
259
+ space_id: firstNonEmptyString(entry?.spaceId, entry?.guildId, entry?.teamId),
260
+ space_display: firstNonEmptyString(entry?.spaceDisplay, entry?.guildName, entry?.teamName),
261
+ chat_id: peerKind === "direct" ? null : firstNonEmptyString(entry?.chatId, entry?.peerId, parsed.peerId),
262
+ chat_display: peerKind === "direct" ? null : firstNonEmptyString(entry?.chatDisplay, entry?.channelName, entry?.roomName),
263
+ participant_id: peerKind === "direct" ? firstNonEmptyString(entry?.participantId, entry?.peerId, parsed.peerId) : null,
264
+ participant_display: peerKind === "direct"
265
+ ? firstNonEmptyString(entry?.participantDisplay, entry?.peerDisplayName, entry?.senderName, entry?.userName)
266
+ : null,
267
+ thread_id: threadId,
268
+ thread_display: firstNonEmptyString(entry?.threadDisplay, entry?.threadName),
269
+ sharing: normalizeSharing(entry?.sharing, entry?.sharedMultiUserSession),
270
+ };
271
+ }
272
+ function readInstanceId(entry) {
273
+ return firstNonEmptyString(entry?.instanceId, entry?.sessionId, entry?.runtimeSessionId);
274
+ }
275
+ function parseSessionKeySource(sessionKey) {
276
+ const parts = sessionKey.split(":");
277
+ const scoped = parts[0] === "agent" ? parts.slice(2) : parts;
278
+ const threadIndex = scoped.indexOf("thread");
279
+ const base = threadIndex >= 0 ? scoped.slice(0, threadIndex) : scoped;
280
+ const threadId = threadIndex >= 0 ? scoped.slice(threadIndex + 1).join(":") || null : null;
281
+ return {
282
+ channel: base[0] ?? null,
283
+ peerKind: base[1] ?? null,
284
+ peerId: base.length > 2 ? base.slice(2).join(":") : null,
285
+ threadId,
286
+ };
287
+ }
288
+ function normalizeChannelName(value) {
289
+ const normalized = firstNonEmptyString(value) ?? "local";
290
+ return normalized === "r2-relay-channel" ? "r2_relay" : normalized;
291
+ }
292
+ function normalizeChatKind(peerKind, threadId) {
293
+ if (threadId) {
294
+ return "thread";
295
+ }
296
+ switch (peerKind) {
297
+ case "direct":
298
+ case "dm":
299
+ case "im":
300
+ return "dm";
301
+ case "group":
302
+ return "group";
303
+ case "channel":
304
+ return "channel";
305
+ default:
306
+ return null;
307
+ }
308
+ }
309
+ function normalizeSharing(value, sharedFallback) {
310
+ const normalized = firstNonEmptyString(value)?.toLowerCase();
311
+ if (normalized === "private" || normalized === "shared" || normalized === "per-user") {
312
+ return normalized;
313
+ }
314
+ if (sharedFallback === true) {
315
+ return "shared";
316
+ }
317
+ return null;
318
+ }
319
+ function firstNonEmptyString(...values) {
320
+ for (const value of values) {
321
+ if (typeof value === "string" && value.trim()) {
322
+ return value.trim();
323
+ }
324
+ }
325
+ return null;
326
+ }
327
+ function deriveGatewayDisplayName(serverId) {
328
+ const cfg = getRelayConfig();
329
+ const candidates = [
330
+ cfg.identity?.name,
331
+ cfg.agents?.main?.name,
332
+ cfg.assistant?.name,
333
+ cfg.gateway?.name,
334
+ ];
335
+ for (const candidate of candidates) {
336
+ const trimmed = candidate?.trim();
337
+ if (trimmed) {
338
+ return trimmed;
339
+ }
340
+ }
341
+ return serverId
342
+ .replace(/[-_.]+/g, " ")
343
+ .replace(/\s+/g, " ")
344
+ .trim()
345
+ .replace(/\b\w/g, (ch) => ch.toUpperCase()) || "Gateway";
346
+ }
347
+ function resolvePublishedServerLimits() {
348
+ return {
349
+ inbound_attachment_max_bytes: {
350
+ image: MAX_IMAGE_BYTES,
351
+ video: MAX_VIDEO_BYTES,
352
+ audio: MAX_AUDIO_BYTES,
353
+ file: MAX_DOCUMENT_BYTES,
354
+ },
355
+ oversize_attachment_behavior: "reject",
356
+ };
357
+ }
358
+ function resolvePublishedAttachmentCapabilityLimits() {
359
+ return {
360
+ image: MAX_IMAGE_BYTES,
361
+ video: MAX_VIDEO_BYTES,
362
+ audio: MAX_AUDIO_BYTES,
363
+ file: MAX_DOCUMENT_BYTES,
364
+ };
365
+ }