@egoai/platform 0.1.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/src/chat.ts ADDED
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Platform chat interface.
3
+ *
4
+ * Mirrors the Telegram channel pattern: each platform user gets a stable
5
+ * session key derived from their user ID, and the plugin handles routing so
6
+ * the caller doesn't need to know openclaw's internal key format.
7
+ *
8
+ * Session key format: agent:{agentId}:platform:direct:{userId}
9
+ *
10
+ * RPC methods exposed to the platform (intercepted in PLUGIN_HANDLERS):
11
+ * platform.chat.send — send a message on behalf of a user
12
+ * platform.chat.history — fetch conversation history for a user
13
+ * platform.chat.abort — abort an in-flight run for a user
14
+ * platform.chat.reset — reset (clear) a user's session
15
+ * platform.session.resolve — return the computed session key
16
+ */
17
+
18
+ import { randomUUID } from "node:crypto";
19
+ import type { OpenClawConfig, PluginLogger } from "openclaw/plugin-sdk";
20
+ import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime";
21
+ import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
22
+ import type {
23
+ RpcRequest,
24
+ RpcResponse,
25
+ PlatformChatSendParams,
26
+ PlatformChatSendStreamParams,
27
+ PlatformChatHistoryParams,
28
+ PlatformChatAbortParams,
29
+ PlatformChatResetParams,
30
+ PlatformSessionResolveParams,
31
+ } from "./types.js";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Session key
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
38
+ const INVALID_CHARS_RE = /[^a-z0-9_-]+/g;
39
+ const LEADING_TRAILING_DASH_RE = /^-+|-+$/g;
40
+
41
+ function normalizeId(value: string | null | undefined): string {
42
+ const trimmed = (value ?? "").trim();
43
+ if (!trimmed) return "";
44
+ if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase();
45
+ return (
46
+ trimmed
47
+ .toLowerCase()
48
+ .replace(INVALID_CHARS_RE, "-")
49
+ .replace(LEADING_TRAILING_DASH_RE, "")
50
+ .slice(0, 64) || ""
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Build the openclaw session key for a platform user.
56
+ * Mirrors buildAgentPeerSessionKey with dmScope="per-channel-peer".
57
+ *
58
+ * Format: agent:{agentId}:platform:direct:{userId}
59
+ */
60
+ export function buildPlatformSessionKey(
61
+ agentId: string | null | undefined,
62
+ userId: string,
63
+ ): string {
64
+ const agent = normalizeId(agentId) || "main";
65
+ const user = normalizeId(userId) || "unknown";
66
+ return `agent:${agent}:platform:direct:${user}`;
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Gateway forwarder type
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /** Send an RPC request to the gateway and return its payload or throw on error. */
74
+ export type GatewayForwarder = (
75
+ method: string,
76
+ params: Record<string, unknown>,
77
+ ) => Promise<unknown>;
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Handler context
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export type ChatHandlerContext = {
84
+ forwardToGateway: GatewayForwarder;
85
+ cfg: OpenClawConfig;
86
+ logger?: PluginLogger;
87
+ /**
88
+ * Send a raw frame to the platform WS (used for streaming notifications).
89
+ * Called with `{ type: "notif", method, params }` frames during streaming.
90
+ */
91
+ sendToPlatform?: (frame: unknown) => void;
92
+ };
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Helpers
96
+ // ---------------------------------------------------------------------------
97
+
98
+ function ok(frame: RpcRequest, payload: unknown): RpcResponse {
99
+ return { type: "res", id: frame.id, ok: true, payload };
100
+ }
101
+
102
+ function fail(frame: RpcRequest, message: string): RpcResponse {
103
+ return { type: "res", id: frame.id, ok: false, error: { message } };
104
+ }
105
+
106
+ function requireString(params: Record<string, unknown>, key: string): string | null {
107
+ const v = params[key];
108
+ if (typeof v !== "string" || !v.trim()) return null;
109
+ return v.trim();
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // platform.chat.send
114
+ // ---------------------------------------------------------------------------
115
+
116
+ export async function handlePlatformChatSend(
117
+ frame: RpcRequest,
118
+ ctx: ChatHandlerContext,
119
+ ): Promise<RpcResponse> {
120
+ const p = frame.params as Partial<PlatformChatSendParams>;
121
+ const userId = requireString(p as Record<string, unknown>, "userId");
122
+ if (!userId) return fail(frame, "platform.chat.send: userId is required");
123
+ const message = requireString(p as Record<string, unknown>, "message");
124
+ if (!message) return fail(frame, "platform.chat.send: message is required");
125
+
126
+ const agentId = normalizeId(p.agentId) || "main";
127
+ const sessionKey = buildPlatformSessionKey(p.agentId, userId);
128
+ const runId = p.runId ?? randomUUID();
129
+ const streamId = p.streamId ?? null;
130
+
131
+ ctx.logger?.info(
132
+ `platform: chat.send sessionKey=${sessionKey} runId=${runId}${streamId ? ` streamId=${streamId}` : ""}`,
133
+ );
134
+
135
+ const pipeline = createChannelReplyPipeline({
136
+ cfg: ctx.cfg,
137
+ agentId,
138
+ channel: "platform",
139
+ accountId: userId,
140
+ });
141
+
142
+ const textParts: string[] = [];
143
+
144
+ try {
145
+ await dispatchReplyWithBufferedBlockDispatcher({
146
+ cfg: ctx.cfg,
147
+ ctx: {
148
+ Body: message,
149
+ From: userId,
150
+ To: agentId,
151
+ SessionKey: sessionKey,
152
+ AccountId: userId,
153
+ },
154
+ dispatcherOptions: {
155
+ ...pipeline,
156
+ deliver: async (payload) => {
157
+ if (payload.text && !payload.isReasoning && !payload.isError) {
158
+ textParts.push(payload.text);
159
+ if (streamId && ctx.sendToPlatform) {
160
+ ctx.sendToPlatform({
161
+ type: "notif",
162
+ method: "platform.chat.chunk",
163
+ params: { streamId, text: payload.text },
164
+ });
165
+ }
166
+ }
167
+ },
168
+ },
169
+ replyOptions: { runId },
170
+ });
171
+ } catch (err) {
172
+ return fail(frame, `platform.chat.send: ${err instanceof Error ? err.message : String(err)}`);
173
+ }
174
+
175
+ return ok(frame, { text: textParts.join(""), runId });
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // platform.chat.send.stream
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /**
183
+ * Fire-and-forget streaming variant of platform.chat.send.
184
+ *
185
+ * Returns immediately with { runId, streamId }. As the agent replies, the
186
+ * plugin pushes notification frames to the platform:
187
+ * { type: "notif", method: "platform.chat.chunk", params: { streamId, text } }
188
+ * { type: "notif", method: "platform.chat.done", params: { streamId, runId } }
189
+ * { type: "notif", method: "platform.chat.error", params: { streamId, error } }
190
+ */
191
+ export async function handlePlatformChatSendStream(
192
+ frame: RpcRequest,
193
+ ctx: ChatHandlerContext,
194
+ ): Promise<RpcResponse> {
195
+ const p = frame.params as Partial<PlatformChatSendStreamParams>;
196
+ const userId = requireString(p as Record<string, unknown>, "userId");
197
+ if (!userId) return fail(frame, "platform.chat.send.stream: userId is required");
198
+ const message = requireString(p as Record<string, unknown>, "message");
199
+ if (!message) return fail(frame, "platform.chat.send.stream: message is required");
200
+
201
+ const agentId = normalizeId(p.agentId) || "main";
202
+ const threadId = requireString(p as Record<string, unknown>, "threadId") ?? null;
203
+ const baseSessionKey = buildPlatformSessionKey(p.agentId, userId);
204
+ const sessionKey = threadId ? `${baseSessionKey}:thread:${threadId}` : baseSessionKey;
205
+ const runId = p.runId ?? randomUUID();
206
+ const streamId = randomUUID();
207
+
208
+ ctx.logger?.info(
209
+ `platform: chat.send.stream sessionKey=${sessionKey} runId=${runId} streamId=${streamId}${threadId ? ` threadId=${threadId}` : ""}`,
210
+ );
211
+
212
+ const pipeline = createChannelReplyPipeline({
213
+ cfg: ctx.cfg,
214
+ agentId,
215
+ channel: "platform",
216
+ accountId: userId,
217
+ });
218
+
219
+ // Fire and forget — return the streamId immediately so the caller can
220
+ // subscribe to notifications before chunks start arriving.
221
+ // onPartialReply fires per-token as the LLM streams; deliver fires once per
222
+ // completed block (too coarse for real-time output).
223
+ dispatchReplyWithBufferedBlockDispatcher({
224
+ cfg: ctx.cfg,
225
+ ctx: {
226
+ Body: message,
227
+ From: userId,
228
+ To: agentId,
229
+ SessionKey: sessionKey,
230
+ AccountId: userId,
231
+ // Surface/Provider identifies the channel for ACP dispatch and session tracking.
232
+ Surface: "platform",
233
+ // OriginatingChannel/To enable ACP reply routing back to this caller.
234
+ OriginatingChannel: "platform",
235
+ OriginatingTo: userId,
236
+ // MessageThreadId drives thread-scoped ACP session routing.
237
+ ...(threadId ? { MessageThreadId: threadId } : {}),
238
+ },
239
+ dispatcherOptions: {
240
+ ...pipeline,
241
+ deliver: async (_payload) => {
242
+ // Block-level delivery — not used for streaming; onPartialReply handles it.
243
+ },
244
+ },
245
+ replyOptions: {
246
+ runId,
247
+ onPartialReply: (() => {
248
+ let prevLen = 0;
249
+ return (payload) => {
250
+ if (payload.text && !payload.isReasoning && !payload.isError) {
251
+ const delta = payload.text.slice(prevLen);
252
+ prevLen = payload.text.length;
253
+ if (delta) {
254
+ ctx.sendToPlatform?.({
255
+ type: "notif",
256
+ method: "platform.chat.chunk",
257
+ params: { streamId, text: delta },
258
+ });
259
+ }
260
+ }
261
+ };
262
+ })(),
263
+ },
264
+ }).then(() => {
265
+ ctx.sendToPlatform?.({
266
+ type: "notif",
267
+ method: "platform.chat.done",
268
+ params: { streamId, runId },
269
+ });
270
+ }).catch((err: unknown) => {
271
+ ctx.sendToPlatform?.({
272
+ type: "notif",
273
+ method: "platform.chat.error",
274
+ params: { streamId, error: err instanceof Error ? err.message : String(err) },
275
+ });
276
+ });
277
+
278
+ return ok(frame, { runId, streamId });
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // platform.chat.history
283
+ // ---------------------------------------------------------------------------
284
+
285
+ export async function handlePlatformChatHistory(
286
+ frame: RpcRequest,
287
+ ctx: ChatHandlerContext,
288
+ ): Promise<RpcResponse> {
289
+ const p = frame.params as Partial<PlatformChatHistoryParams>;
290
+ const userId = requireString(p as Record<string, unknown>, "userId");
291
+ if (!userId) return fail(frame, "platform.chat.history: userId is required");
292
+
293
+ const sessionKey = buildPlatformSessionKey(p.agentId, userId);
294
+ ctx.logger?.info(`platform: chat.history sessionKey=${sessionKey}`);
295
+
296
+ const gatewayParams: Record<string, unknown> = { sessionKey };
297
+ if (typeof p.limit === "number") gatewayParams.limit = p.limit;
298
+
299
+ try {
300
+ const payload = await ctx.forwardToGateway("chat.history", gatewayParams);
301
+ return ok(frame, payload);
302
+ } catch (err) {
303
+ return fail(frame, `platform.chat.history: ${err instanceof Error ? err.message : String(err)}`);
304
+ }
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // platform.chat.abort
309
+ // ---------------------------------------------------------------------------
310
+
311
+ export async function handlePlatformChatAbort(
312
+ frame: RpcRequest,
313
+ ctx: ChatHandlerContext,
314
+ ): Promise<RpcResponse> {
315
+ const p = frame.params as Partial<PlatformChatAbortParams>;
316
+ const userId = requireString(p as Record<string, unknown>, "userId");
317
+ if (!userId) return fail(frame, "platform.chat.abort: userId is required");
318
+
319
+ const sessionKey = buildPlatformSessionKey(p.agentId, userId);
320
+ ctx.logger?.info(`platform: chat.abort sessionKey=${sessionKey}`);
321
+
322
+ const gatewayParams: Record<string, unknown> = { sessionKey };
323
+ if (p.runId) gatewayParams.runId = p.runId;
324
+
325
+ try {
326
+ const payload = await ctx.forwardToGateway("chat.abort", gatewayParams);
327
+ return ok(frame, payload);
328
+ } catch (err) {
329
+ return fail(frame, `platform.chat.abort: ${err instanceof Error ? err.message : String(err)}`);
330
+ }
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // platform.chat.reset
335
+ // ---------------------------------------------------------------------------
336
+
337
+ export async function handlePlatformChatReset(
338
+ frame: RpcRequest,
339
+ ctx: ChatHandlerContext,
340
+ ): Promise<RpcResponse> {
341
+ const p = frame.params as Partial<PlatformChatResetParams>;
342
+ const userId = requireString(p as Record<string, unknown>, "userId");
343
+ if (!userId) return fail(frame, "platform.chat.reset: userId is required");
344
+
345
+ const sessionKey = buildPlatformSessionKey(p.agentId, userId);
346
+ ctx.logger?.info(`platform: chat.reset sessionKey=${sessionKey}`);
347
+
348
+ try {
349
+ const payload = await ctx.forwardToGateway("sessions.reset", {
350
+ key: sessionKey,
351
+ reason: "reset",
352
+ });
353
+ return ok(frame, payload);
354
+ } catch (err) {
355
+ return fail(frame, `platform.chat.reset: ${err instanceof Error ? err.message : String(err)}`);
356
+ }
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // platform.session.resolve
361
+ // ---------------------------------------------------------------------------
362
+
363
+ export async function handlePlatformSessionResolve(
364
+ frame: RpcRequest,
365
+ _ctx: ChatHandlerContext,
366
+ ): Promise<RpcResponse> {
367
+ const p = frame.params as Partial<PlatformSessionResolveParams>;
368
+ const userId = requireString(p as Record<string, unknown>, "userId");
369
+ if (!userId) return fail(frame, "platform.session.resolve: userId is required");
370
+
371
+ const sessionKey = buildPlatformSessionKey(p.agentId, userId);
372
+ return ok(frame, { sessionKey });
373
+ }
package/src/config.ts ADDED
@@ -0,0 +1,79 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+
3
+ /**
4
+ * Typed config resolver for the platform channel plugin.
5
+ *
6
+ * The gateway token is NOT stored in config — the plugin reads it from the
7
+ * running gateway context and pushes it to platform over the pre-handshake.
8
+ */
9
+
10
+ export type PlatformAccount = {
11
+ accountId: string;
12
+ enabled: boolean;
13
+ /** true when platformUrl is set */
14
+ configured: boolean;
15
+ platformUrl: string;
16
+ platformKey?: string;
17
+ };
18
+
19
+ export function resolveAccount(cfg: OpenClawConfig, accountId?: string): PlatformAccount {
20
+ if (accountId && accountId !== "platform") {
21
+ console.warn(`platform: unexpected accountId "${accountId}", only "platform" is supported`);
22
+ }
23
+ const platform = cfg.channels?.platform ?? {};
24
+ const platformUrl = typeof platform.platformUrl === "string" ? platform.platformUrl.trim() : "ws://localhost:8000/openclaw/channel";
25
+
26
+ return {
27
+ accountId: "platform",
28
+ enabled: platform.enabled === true,
29
+ configured: platformUrl.length > 0,
30
+ platformUrl,
31
+ platformKey: typeof platform.platformKey === "string" ? platform.platformKey || undefined : undefined,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Inspect account status without materializing secrets.
37
+ * Used by the gateway dashboard and health checks.
38
+ */
39
+ export function inspectAccount(cfg: OpenClawConfig, _accountId?: string | null): {
40
+ enabled: boolean;
41
+ configured: boolean;
42
+ tokenStatus: "available" | "missing";
43
+ } {
44
+ const platform = cfg.channels?.platform ?? {};
45
+ const platformUrl = typeof platform.platformUrl === "string" && platform.platformUrl.trim().length > 0;
46
+ const hasPlatformKey = typeof platform.platformKey === "string" && platform.platformKey.length > 0;
47
+
48
+ return {
49
+ enabled: platform.enabled === true,
50
+ configured: platformUrl,
51
+ tokenStatus: hasPlatformKey ? "available" : "missing",
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Resolve the gateway token from the running gateway config or environment.
57
+ * The plugin pushes this to platform so it can complete the standard handshake.
58
+ */
59
+ export function resolveGatewayToken(cfg: OpenClawConfig): string {
60
+ // Prefer environment variable, fall back to config
61
+ const envToken = process.env.OPENCLAW_GATEWAY_TOKEN;
62
+ if (envToken) return envToken;
63
+
64
+ const token = cfg.gateway?.auth?.token;
65
+ if (typeof token === "string" && token.length > 0) return token;
66
+
67
+ throw new Error(
68
+ "platform: cannot resolve gateway token — set OPENCLAW_GATEWAY_TOKEN or configure gateway.auth.token",
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Resolve the gateway's local WS URL for the loopback relay connection.
74
+ * The plugin connects here and relays frames to/from platform.
75
+ */
76
+ export function resolveGatewayUrl(cfg: OpenClawConfig): string {
77
+ const port = cfg.gateway?.port ?? 18789;
78
+ return `ws://127.0.0.1:${port}`;
79
+ }
package/src/index.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Platform — OpenClaw channel plugin for real-time AI voice conversations.
3
+ *
4
+ * Connects an OpenClaw agent to the Platform voice platform, enabling the agent
5
+ * to participate in live voice calls with embodied characters. The gateway
6
+ * initiates the connection to Platform and maintains a persistent session
7
+ * for RPC communication.
8
+ */
9
+
10
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
11
+ import { PlatformChannel } from "./channel.js";
12
+ import { triggerReconnect, getConnectionStatus } from "./monitor.js";
13
+ import { resolveAccount } from "./config.js";
14
+ import {
15
+ handleBeforePromptBuild,
16
+ handleMessageReceived,
17
+ handleMessageSent,
18
+ handleAgentEnd,
19
+ handleSessionEnd,
20
+ } from "./brain.js";
21
+
22
+ export default function register(api: OpenClawPluginApi) {
23
+ api.registerChannel({ plugin: PlatformChannel });
24
+
25
+ // --- Brain (Mnemo) hooks ---
26
+ // RAG context injection: keyword-match brain/index.md, prepend relevant wiki pages
27
+ api.on("before_prompt_build", (event, ctx) => {
28
+ return handleBeforePromptBuild(event, ctx, api.logger);
29
+ });
30
+
31
+ // Per-turn conversation logging: buffer user message, then log on agent response
32
+ api.on("message_received", (event, ctx) => {
33
+ handleMessageReceived(event, ctx, api.logger);
34
+ });
35
+
36
+ api.on("message_sent", (event, ctx) => {
37
+ handleMessageSent(event, ctx, api.logger);
38
+ });
39
+
40
+ // Per-turn capture via agent_end (fires for TUI, unlike message_sent)
41
+ api.on("agent_end", (event, ctx) => {
42
+ handleAgentEnd(event, ctx, api.logger);
43
+ });
44
+
45
+ // Session-end: log to log.md and queue for extraction
46
+ api.on("session_end", (event, ctx) => {
47
+ handleSessionEnd(event, ctx, api.logger);
48
+ });
49
+
50
+ api.registerCommand({
51
+ name: "platform-pair",
52
+ description: "Show instructions for pairing OpenClaw with Platform",
53
+ handler: () => ({
54
+ text: [
55
+ "To pair OpenClaw with platform:",
56
+ "",
57
+ "1. Follow the setup instructions at https://platform.md",
58
+ "2. Register an account on Platform and add OpenClaw as a connection",
59
+ "3. Add OpenClaw and follow instructions to install pairing token in OpenClaw",
60
+ "",
61
+ "Once configured, restart the gateway and use /platform-status to verify the connection.",
62
+ ].join("\n"),
63
+ }),
64
+ });
65
+
66
+ api.registerCommand({
67
+ name: "platform-reconnect",
68
+ description: "Skip the retry delay and reconnect to Platform immediately",
69
+ handler: () => {
70
+ const triggered = triggerReconnect();
71
+ return triggered
72
+ ? { text: "platform: reconnecting now." }
73
+ : { text: "platform: no pending retry — already connected or not running." };
74
+ },
75
+ });
76
+
77
+ api.registerCommand({
78
+ name: "platform-status",
79
+ description: "Show Platform connection status and current configuration",
80
+ handler: (ctx) => {
81
+ const status = getConnectionStatus();
82
+ const account = resolveAccount(ctx.config);
83
+
84
+ const lines: string[] = ["**Platform Status**"];
85
+
86
+ // Connection state
87
+ switch (status.state) {
88
+ case "connected":
89
+ lines.push("Connection: connected");
90
+ break;
91
+ case "connecting":
92
+ lines.push("Connection: connecting…");
93
+ break;
94
+ case "retrying": {
95
+ const secsLeft = Math.max(0, Math.round((status.retryingAt - Date.now()) / 1000));
96
+ lines.push(`Connection: retrying in ${secsLeft}s`);
97
+ lines.push(`Last error: ${status.lastError}`);
98
+ break;
99
+ }
100
+ case "disconnected":
101
+ lines.push("Connection: disconnected");
102
+ break;
103
+ }
104
+
105
+ // Config
106
+ lines.push("");
107
+ lines.push("**Config**");
108
+ lines.push(`Enabled: ${account.enabled}`);
109
+ lines.push(`Platform URL: ${account.platformUrl || "(not set)"}`);
110
+ lines.push(`Pairing token: ${account.platformKey ? "set" : "missing"}`);
111
+
112
+ return { text: lines.join("\n") };
113
+ },
114
+ });
115
+
116
+ api.logger.info("platform: channel registered");
117
+ }