@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/LICENSE +21 -0
- package/README.md +111 -0
- package/openclaw.plugin.json +39 -0
- package/package.json +56 -0
- package/src/brain.ts +584 -0
- package/src/channel.ts +54 -0
- package/src/chat.ts +373 -0
- package/src/config.ts +79 -0
- package/src/index.ts +117 -0
- package/src/monitor.ts +591 -0
- package/src/types.ts +118 -0
- package/src/workspace.ts +324 -0
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
|
+
}
|