@twsxtd/hapi-openclaw 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/dist/index.js +414 -183
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,14 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Native OpenClaw plugin adapter for HAPI integration work.
|
|
4
4
|
|
|
5
|
-
This package
|
|
5
|
+
This package installs as an OpenClaw native plugin and exposes the HAPI-facing `/hapi/*` route surface from inside the OpenClaw Gateway. V1 now creates or resumes real OpenClaw sessions, starts real embedded-agent runs, and forwards real assistant transcript messages back into HAPI. Approval bridging is still deferred.
|
|
6
6
|
|
|
7
7
|
What it does:
|
|
8
8
|
|
|
9
9
|
- exposes `/hapi/health` and `/hapi/channel/*` through `api.registerHttpRoute(...)`
|
|
10
10
|
- enforces plugin-managed bearer auth
|
|
11
11
|
- signs callback events back to HAPI
|
|
12
|
-
-
|
|
12
|
+
- derives deterministic OpenClaw session keys from HAPI namespace + user key
|
|
13
|
+
- starts real OpenClaw embedded-agent runs for `send-message`
|
|
14
|
+
- bridges assistant transcript text updates into HAPI `message` / `state` callbacks
|
|
15
|
+
- returns `501` for approval endpoints until the real approval bridge lands
|
|
13
16
|
- records real transcript-update payloads to plugin state when `prototypeCaptureSessionKey` is configured
|
|
14
17
|
|
|
15
18
|
Plugin config lives under `plugins.entries.hapi-openclaw.config` in OpenClaw config:
|
|
@@ -53,7 +56,7 @@ Example OpenClaw config:
|
|
|
53
56
|
"hapiBaseUrl": "http://127.0.0.1:3006",
|
|
54
57
|
"sharedSecret": "test-secret",
|
|
55
58
|
"namespace": "default",
|
|
56
|
-
"prototypeCaptureSessionKey": "hapi-openclaw:default:debug-user"
|
|
59
|
+
"prototypeCaptureSessionKey": "agent:main:hapi-openclaw:default:debug-user"
|
|
57
60
|
}
|
|
58
61
|
}
|
|
59
62
|
}
|
|
@@ -65,4 +68,6 @@ Current milestone note:
|
|
|
65
68
|
|
|
66
69
|
- HAPI official mode should point `OPENCLAW_PLUGIN_BASE_URL` at the OpenClaw Gateway base URL
|
|
67
70
|
- the plugin route surface is native now
|
|
68
|
-
-
|
|
71
|
+
- `ensure-default-conversation` and `send-message` use the real OpenClaw runtime now
|
|
72
|
+
- assistant replies in HAPI come from real OpenClaw transcript updates, not mock text
|
|
73
|
+
- approval request / approve / deny bridging is still not implemented in this milestone
|
package/dist/index.js
CHANGED
|
@@ -106,17 +106,6 @@ function resolvePluginConfig(value) {
|
|
|
106
106
|
prototypeCaptureFileName: readOptionalNonEmptyString(value.prototypeCaptureFileName) ?? "transcript-capture.jsonl"
|
|
107
107
|
};
|
|
108
108
|
}
|
|
109
|
-
function resolvePluginConfigFromOpenClawConfig(config) {
|
|
110
|
-
const pluginConfig = config.plugins?.entries?.[OPENCLAW_PLUGIN_ID]?.config;
|
|
111
|
-
if (!pluginConfig) {
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
try {
|
|
115
|
-
return resolvePluginConfig(pluginConfig);
|
|
116
|
-
} catch {
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
109
|
|
|
121
110
|
// src/nativeRoute.ts
|
|
122
111
|
import { Readable } from "node:stream";
|
|
@@ -163,6 +152,233 @@ async function forwardNodeRequestToHono(app, req, res) {
|
|
|
163
152
|
return true;
|
|
164
153
|
}
|
|
165
154
|
|
|
155
|
+
// src/openclawAdapter.ts
|
|
156
|
+
import { randomUUID } from "node:crypto";
|
|
157
|
+
|
|
158
|
+
// src/adapterState.ts
|
|
159
|
+
var activeRuns = new Set;
|
|
160
|
+
var seenTranscriptMessageIds = new Set;
|
|
161
|
+
var adapterState = {
|
|
162
|
+
startRun(sessionKey) {
|
|
163
|
+
if (activeRuns.has(sessionKey)) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
activeRuns.add(sessionKey);
|
|
167
|
+
return true;
|
|
168
|
+
},
|
|
169
|
+
isRunActive(sessionKey) {
|
|
170
|
+
return activeRuns.has(sessionKey);
|
|
171
|
+
},
|
|
172
|
+
finishRun(sessionKey) {
|
|
173
|
+
return activeRuns.delete(sessionKey);
|
|
174
|
+
},
|
|
175
|
+
rememberTranscriptMessage(messageId) {
|
|
176
|
+
if (seenTranscriptMessageIds.has(messageId)) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
seenTranscriptMessageIds.add(messageId);
|
|
180
|
+
return true;
|
|
181
|
+
},
|
|
182
|
+
resetForTests() {
|
|
183
|
+
activeRuns.clear();
|
|
184
|
+
seenTranscriptMessageIds.clear();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// src/sessionKeys.ts
|
|
189
|
+
import { createHash } from "node:crypto";
|
|
190
|
+
var HAPI_SESSION_PREFIX = "hapi-openclaw";
|
|
191
|
+
var DEFAULT_AGENT_ID = "main";
|
|
192
|
+
var REPLY_TO_CURRENT_PREFIX = "[[reply_to_current]]";
|
|
193
|
+
function encodeUserKey(externalUserKey) {
|
|
194
|
+
const normalized = externalUserKey.trim();
|
|
195
|
+
if (!normalized) {
|
|
196
|
+
throw new Error("externalUserKey must be a non-empty string");
|
|
197
|
+
}
|
|
198
|
+
return encodeURIComponent(normalized);
|
|
199
|
+
}
|
|
200
|
+
function getDefaultAgentId() {
|
|
201
|
+
return DEFAULT_AGENT_ID;
|
|
202
|
+
}
|
|
203
|
+
function buildHapiConversationToken(namespace, externalUserKey) {
|
|
204
|
+
return `${HAPI_SESSION_PREFIX}:${namespace}:${encodeUserKey(externalUserKey)}`;
|
|
205
|
+
}
|
|
206
|
+
function buildHapiSessionKey(namespace, externalUserKey, agentId = DEFAULT_AGENT_ID) {
|
|
207
|
+
return `agent:${agentId}:${buildHapiConversationToken(namespace, externalUserKey)}`;
|
|
208
|
+
}
|
|
209
|
+
function parseHapiSessionKey(sessionKey) {
|
|
210
|
+
if (!sessionKey) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
const match = /^agent:([^:]+):hapi-openclaw:([^:]+):(.+)$/.exec(sessionKey.trim());
|
|
214
|
+
if (!match) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
return {
|
|
219
|
+
agentId: match[1],
|
|
220
|
+
namespace: match[2],
|
|
221
|
+
externalUserKey: decodeURIComponent(match[3])
|
|
222
|
+
};
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function deriveDeterministicSessionId(sessionKey) {
|
|
228
|
+
const hex = createHash("sha256").update(sessionKey).digest("hex").slice(0, 32);
|
|
229
|
+
return [
|
|
230
|
+
hex.slice(0, 8),
|
|
231
|
+
hex.slice(8, 12),
|
|
232
|
+
hex.slice(12, 16),
|
|
233
|
+
hex.slice(16, 20),
|
|
234
|
+
hex.slice(20, 32)
|
|
235
|
+
].join("-");
|
|
236
|
+
}
|
|
237
|
+
function stripReplyToCurrentPrefix(text) {
|
|
238
|
+
return text.replace(new RegExp(`^${REPLY_TO_CURRENT_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`), "").trim();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// src/openclawAdapter.ts
|
|
242
|
+
var CONVERSATION_TITLE = "OpenClaw";
|
|
243
|
+
var RUN_COMPLETION_SETTLE_MS = 50;
|
|
244
|
+
|
|
245
|
+
class ConversationBusyError extends Error {
|
|
246
|
+
constructor() {
|
|
247
|
+
super("Conversation already has an active OpenClaw run");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function createStateEvent(params) {
|
|
251
|
+
return {
|
|
252
|
+
type: "state",
|
|
253
|
+
eventId: randomUUID(),
|
|
254
|
+
occurredAt: Date.now(),
|
|
255
|
+
namespace: params.namespace,
|
|
256
|
+
conversationId: params.conversationId,
|
|
257
|
+
connected: true,
|
|
258
|
+
thinking: params.thinking,
|
|
259
|
+
lastError: params.lastError
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function getStateNamespace(sessionKey, fallbackNamespace) {
|
|
263
|
+
return parseHapiSessionKey(sessionKey)?.namespace ?? fallbackNamespace;
|
|
264
|
+
}
|
|
265
|
+
function delay(ms) {
|
|
266
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
267
|
+
}
|
|
268
|
+
async function ensureSessionBinding(runtime, sessionKey, agentId) {
|
|
269
|
+
const storePath = runtime.agent.session.resolveStorePath(undefined, { agentId });
|
|
270
|
+
const store = runtime.agent.session.loadSessionStore(storePath);
|
|
271
|
+
const existing = store[sessionKey];
|
|
272
|
+
const sessionId = existing?.sessionId?.trim() || deriveDeterministicSessionId(sessionKey);
|
|
273
|
+
const sessionFile = runtime.agent.session.resolveSessionFilePath(sessionId, existing, { agentId });
|
|
274
|
+
store[sessionKey] = {
|
|
275
|
+
...existing,
|
|
276
|
+
sessionId,
|
|
277
|
+
sessionFile,
|
|
278
|
+
updatedAt: Date.now(),
|
|
279
|
+
label: existing?.label ?? CONVERSATION_TITLE,
|
|
280
|
+
displayName: existing?.displayName ?? CONVERSATION_TITLE
|
|
281
|
+
};
|
|
282
|
+
await runtime.agent.session.saveSessionStore(storePath, store, {
|
|
283
|
+
activeSessionKey: sessionKey
|
|
284
|
+
});
|
|
285
|
+
return {
|
|
286
|
+
sessionId,
|
|
287
|
+
sessionFile
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
class RealOpenClawAdapter {
|
|
292
|
+
namespace;
|
|
293
|
+
runtime;
|
|
294
|
+
callbackClient;
|
|
295
|
+
supportsApprovals = false;
|
|
296
|
+
constructor(namespace, runtime, callbackClient) {
|
|
297
|
+
this.namespace = namespace;
|
|
298
|
+
this.runtime = runtime;
|
|
299
|
+
this.callbackClient = callbackClient;
|
|
300
|
+
}
|
|
301
|
+
async ensureDefaultConversation(externalUserKey) {
|
|
302
|
+
return {
|
|
303
|
+
conversationId: buildHapiSessionKey(this.namespace, externalUserKey, getDefaultAgentId()),
|
|
304
|
+
title: CONVERSATION_TITLE
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
isConversationBusy(conversationId) {
|
|
308
|
+
return adapterState.isRunActive(conversationId);
|
|
309
|
+
}
|
|
310
|
+
async sendMessage(action) {
|
|
311
|
+
if (!adapterState.startRun(action.conversationId)) {
|
|
312
|
+
throw new ConversationBusyError;
|
|
313
|
+
}
|
|
314
|
+
const namespace = getStateNamespace(action.conversationId, this.namespace);
|
|
315
|
+
await this.callbackClient.postEvent(createStateEvent({
|
|
316
|
+
namespace,
|
|
317
|
+
conversationId: action.conversationId,
|
|
318
|
+
thinking: true,
|
|
319
|
+
lastError: null
|
|
320
|
+
}));
|
|
321
|
+
try {
|
|
322
|
+
const config = this.runtime.config.loadConfig();
|
|
323
|
+
const agentId = parseHapiSessionKey(action.conversationId)?.agentId ?? getDefaultAgentId();
|
|
324
|
+
const workspaceDir = this.runtime.agent.resolveAgentWorkspaceDir(config, agentId);
|
|
325
|
+
await this.runtime.agent.ensureAgentWorkspace({ dir: workspaceDir });
|
|
326
|
+
const { sessionId, sessionFile } = await ensureSessionBinding(this.runtime, action.conversationId, agentId);
|
|
327
|
+
const result = await this.runtime.agent.runEmbeddedAgent({
|
|
328
|
+
sessionId,
|
|
329
|
+
sessionKey: action.conversationId,
|
|
330
|
+
sessionFile,
|
|
331
|
+
workspaceDir,
|
|
332
|
+
agentId,
|
|
333
|
+
prompt: action.text,
|
|
334
|
+
timeoutMs: this.runtime.agent.resolveAgentTimeoutMs({ cfg: config }),
|
|
335
|
+
runId: randomUUID(),
|
|
336
|
+
trigger: "user"
|
|
337
|
+
});
|
|
338
|
+
const runError = result.meta.error?.message?.trim() || null;
|
|
339
|
+
if (runError) {
|
|
340
|
+
if (adapterState.finishRun(action.conversationId)) {
|
|
341
|
+
await this.callbackClient.postEvent(createStateEvent({
|
|
342
|
+
namespace,
|
|
343
|
+
conversationId: action.conversationId,
|
|
344
|
+
thinking: false,
|
|
345
|
+
lastError: runError
|
|
346
|
+
}));
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (result.meta.finalAssistantVisibleText) {
|
|
351
|
+
await delay(RUN_COMPLETION_SETTLE_MS);
|
|
352
|
+
}
|
|
353
|
+
if (adapterState.finishRun(action.conversationId)) {
|
|
354
|
+
await this.callbackClient.postEvent(createStateEvent({
|
|
355
|
+
namespace,
|
|
356
|
+
conversationId: action.conversationId,
|
|
357
|
+
thinking: false,
|
|
358
|
+
lastError: null
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
} catch (error2) {
|
|
362
|
+
const message = error2 instanceof Error ? error2.message : "OpenClaw embedded run failed";
|
|
363
|
+
if (adapterState.finishRun(action.conversationId)) {
|
|
364
|
+
await this.callbackClient.postEvent(createStateEvent({
|
|
365
|
+
namespace,
|
|
366
|
+
conversationId: action.conversationId,
|
|
367
|
+
thinking: false,
|
|
368
|
+
lastError: message
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
371
|
+
throw error2;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async approve(_action) {
|
|
375
|
+
throw new Error("Real OpenClaw approval bridge is not implemented yet");
|
|
376
|
+
}
|
|
377
|
+
async deny(_action) {
|
|
378
|
+
throw new Error("Real OpenClaw approval bridge is not implemented yet");
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
166
382
|
// ../node_modules/.bun/openclaw@2026.4.11+63c81f13a188c8c3/node_modules/openclaw/dist/runtime-store-B1YLS6z5.js
|
|
167
383
|
function createPluginRuntimeStore(errorMessage) {
|
|
168
384
|
let runtime = null;
|
|
@@ -187,12 +403,157 @@ function createPluginRuntimeStore(errorMessage) {
|
|
|
187
403
|
// src/runtimeStore.ts
|
|
188
404
|
var runtimeStore = createPluginRuntimeStore("OpenClaw plugin runtime is not available outside native plugin registration");
|
|
189
405
|
|
|
406
|
+
// src/signing.ts
|
|
407
|
+
import { createHmac } from "node:crypto";
|
|
408
|
+
function signCallbackBody(timestamp, rawBody, secret) {
|
|
409
|
+
return createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// src/hapiClient.ts
|
|
413
|
+
class HapiCallbackClient {
|
|
414
|
+
hapiBaseUrl;
|
|
415
|
+
sharedSecret;
|
|
416
|
+
constructor(hapiBaseUrl, sharedSecret) {
|
|
417
|
+
this.hapiBaseUrl = hapiBaseUrl;
|
|
418
|
+
this.sharedSecret = sharedSecret;
|
|
419
|
+
}
|
|
420
|
+
async postEvent(event) {
|
|
421
|
+
const rawBody = JSON.stringify(event);
|
|
422
|
+
const timestamp = Date.now();
|
|
423
|
+
const signature = signCallbackBody(timestamp, rawBody, this.sharedSecret);
|
|
424
|
+
const response = await fetch(new URL("/api/openclaw/channel/events", this.hapiBaseUrl).toString(), {
|
|
425
|
+
method: "POST",
|
|
426
|
+
headers: {
|
|
427
|
+
"content-type": "application/json",
|
|
428
|
+
"x-openclaw-timestamp": `${timestamp}`,
|
|
429
|
+
"x-openclaw-signature": signature
|
|
430
|
+
},
|
|
431
|
+
body: rawBody
|
|
432
|
+
});
|
|
433
|
+
if (!response.ok) {
|
|
434
|
+
const text = await response.text().catch(() => "");
|
|
435
|
+
const detail = text ? `: ${text}` : "";
|
|
436
|
+
throw new Error(`HAPI callback failed with HTTP ${response.status}${detail}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/transcriptEvents.ts
|
|
442
|
+
function isRecord2(value) {
|
|
443
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
444
|
+
}
|
|
445
|
+
function extractAssistantText(content) {
|
|
446
|
+
if (typeof content === "string") {
|
|
447
|
+
const normalized = stripReplyToCurrentPrefix(content);
|
|
448
|
+
return normalized.length > 0 ? normalized : null;
|
|
449
|
+
}
|
|
450
|
+
if (!Array.isArray(content)) {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
const texts = content.flatMap((entry) => {
|
|
454
|
+
if (!isRecord2(entry)) {
|
|
455
|
+
return [];
|
|
456
|
+
}
|
|
457
|
+
const block = entry;
|
|
458
|
+
if (block.type !== "text" || typeof block.text !== "string") {
|
|
459
|
+
return [];
|
|
460
|
+
}
|
|
461
|
+
const normalized = stripReplyToCurrentPrefix(block.text);
|
|
462
|
+
return normalized.length > 0 ? [normalized] : [];
|
|
463
|
+
});
|
|
464
|
+
if (texts.length === 0) {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
return texts.join(`
|
|
468
|
+
|
|
469
|
+
`);
|
|
470
|
+
}
|
|
471
|
+
function normalizeAssistantTranscriptEvent(update) {
|
|
472
|
+
const parsed = parseHapiSessionKey(update.sessionKey);
|
|
473
|
+
if (!parsed || !isRecord2(update.message)) {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
const message = update.message;
|
|
477
|
+
if (message.role !== "assistant") {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
const text = extractAssistantText(message.content);
|
|
481
|
+
if (!text) {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
const externalMessageId = typeof update.messageId === "string" && update.messageId.length > 0 ? update.messageId : typeof message.responseId === "string" && message.responseId.length > 0 ? message.responseId : null;
|
|
485
|
+
if (!externalMessageId) {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
const createdAt = typeof message.timestamp === "number" && Number.isFinite(message.timestamp) ? message.timestamp : Date.now();
|
|
489
|
+
return {
|
|
490
|
+
type: "message",
|
|
491
|
+
eventId: `message:${externalMessageId}`,
|
|
492
|
+
occurredAt: createdAt,
|
|
493
|
+
namespace: parsed.namespace,
|
|
494
|
+
conversationId: update.sessionKey,
|
|
495
|
+
externalMessageId,
|
|
496
|
+
role: "assistant",
|
|
497
|
+
content: {
|
|
498
|
+
mode: "replace",
|
|
499
|
+
text
|
|
500
|
+
},
|
|
501
|
+
createdAt,
|
|
502
|
+
status: "completed"
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// src/transcriptBridge.ts
|
|
507
|
+
async function handleTranscriptUpdate(ctx, callbackClient, update) {
|
|
508
|
+
const event = normalizeAssistantTranscriptEvent(update);
|
|
509
|
+
if (!event) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (!adapterState.rememberTranscriptMessage(event.externalMessageId)) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
await callbackClient.postEvent(event);
|
|
516
|
+
if (adapterState.finishRun(event.conversationId)) {
|
|
517
|
+
await callbackClient.postEvent({
|
|
518
|
+
type: "state",
|
|
519
|
+
eventId: `${event.eventId}:state`,
|
|
520
|
+
occurredAt: Date.now(),
|
|
521
|
+
namespace: event.namespace,
|
|
522
|
+
conversationId: event.conversationId,
|
|
523
|
+
connected: true,
|
|
524
|
+
thinking: false,
|
|
525
|
+
lastError: null
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function createTranscriptBridgeService(config) {
|
|
530
|
+
let stopListening = null;
|
|
531
|
+
return {
|
|
532
|
+
id: `${OPENCLAW_PLUGIN_ID}:transcript-bridge`,
|
|
533
|
+
async start(ctx) {
|
|
534
|
+
const callbackClient = new HapiCallbackClient(config.hapiBaseUrl, config.sharedSecret);
|
|
535
|
+
const runtime = runtimeStore.getRuntime();
|
|
536
|
+
stopListening = runtime.events.onSessionTranscriptUpdate((update) => {
|
|
537
|
+
handleTranscriptUpdate(ctx, callbackClient, update).catch((error2) => {
|
|
538
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
539
|
+
ctx.logger.error(`Failed to bridge transcript update: ${message}`);
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
ctx.logger.info(`Started ${OPENCLAW_PLUGIN_ID} transcript-bridge service`);
|
|
543
|
+
},
|
|
544
|
+
async stop() {
|
|
545
|
+
stopListening?.();
|
|
546
|
+
stopListening = null;
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
190
551
|
// src/transcriptCapture.ts
|
|
191
552
|
import { appendFile, mkdir } from "node:fs/promises";
|
|
192
553
|
import { join } from "node:path";
|
|
193
554
|
var CAPTURE_DIRECTORY = "hapi-openclaw";
|
|
194
555
|
function resolveCaptureFilePath(ctx, config) {
|
|
195
|
-
const fileName = config
|
|
556
|
+
const fileName = config.prototypeCaptureFileName;
|
|
196
557
|
return join(ctx.stateDir, CAPTURE_DIRECTORY, fileName);
|
|
197
558
|
}
|
|
198
559
|
async function writeCaptureRecord(ctx, config, record) {
|
|
@@ -202,18 +563,17 @@ async function writeCaptureRecord(ctx, config, record) {
|
|
|
202
563
|
`, "utf8");
|
|
203
564
|
}
|
|
204
565
|
function shouldCapture(config, sessionKey) {
|
|
205
|
-
if (!config
|
|
566
|
+
if (!config.prototypeCaptureSessionKey) {
|
|
206
567
|
return false;
|
|
207
568
|
}
|
|
208
569
|
return sessionKey === config.prototypeCaptureSessionKey;
|
|
209
570
|
}
|
|
210
|
-
function createTranscriptCaptureService() {
|
|
571
|
+
function createTranscriptCaptureService(config) {
|
|
211
572
|
let stopListening = null;
|
|
212
573
|
return {
|
|
213
574
|
id: `${OPENCLAW_PLUGIN_ID}:transcript-capture`,
|
|
214
575
|
async start(ctx) {
|
|
215
576
|
const runtime = runtimeStore.getRuntime();
|
|
216
|
-
const config = resolvePluginConfigFromOpenClawConfig(ctx.config);
|
|
217
577
|
stopListening = runtime.events.onSessionTranscriptUpdate((update) => {
|
|
218
578
|
if (!shouldCapture(config, update.sessionKey)) {
|
|
219
579
|
return;
|
|
@@ -238,165 +598,6 @@ function createTranscriptCaptureService() {
|
|
|
238
598
|
};
|
|
239
599
|
}
|
|
240
600
|
|
|
241
|
-
// src/signing.ts
|
|
242
|
-
import { createHmac } from "node:crypto";
|
|
243
|
-
function signCallbackBody(timestamp, rawBody, secret) {
|
|
244
|
-
return createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// src/hapiClient.ts
|
|
248
|
-
class HapiCallbackClient {
|
|
249
|
-
hapiBaseUrl;
|
|
250
|
-
sharedSecret;
|
|
251
|
-
constructor(hapiBaseUrl, sharedSecret) {
|
|
252
|
-
this.hapiBaseUrl = hapiBaseUrl;
|
|
253
|
-
this.sharedSecret = sharedSecret;
|
|
254
|
-
}
|
|
255
|
-
async postEvent(event) {
|
|
256
|
-
const rawBody = JSON.stringify(event);
|
|
257
|
-
const timestamp = Date.now();
|
|
258
|
-
const signature = signCallbackBody(timestamp, rawBody, this.sharedSecret);
|
|
259
|
-
const response = await fetch(new URL("/api/openclaw/channel/events", this.hapiBaseUrl).toString(), {
|
|
260
|
-
method: "POST",
|
|
261
|
-
headers: {
|
|
262
|
-
"content-type": "application/json",
|
|
263
|
-
"x-openclaw-timestamp": `${timestamp}`,
|
|
264
|
-
"x-openclaw-signature": signature
|
|
265
|
-
},
|
|
266
|
-
body: rawBody
|
|
267
|
-
});
|
|
268
|
-
if (!response.ok) {
|
|
269
|
-
const text = await response.text().catch(() => "");
|
|
270
|
-
const detail = text ? `: ${text}` : "";
|
|
271
|
-
throw new Error(`HAPI callback failed with HTTP ${response.status}${detail}`);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// src/openclawRuntime.ts
|
|
277
|
-
import { randomUUID } from "node:crypto";
|
|
278
|
-
|
|
279
|
-
class MockOpenClawRuntime {
|
|
280
|
-
namespace;
|
|
281
|
-
constructor(namespace) {
|
|
282
|
-
this.namespace = namespace;
|
|
283
|
-
}
|
|
284
|
-
ensureDefaultConversation(externalUserKey) {
|
|
285
|
-
return {
|
|
286
|
-
conversationId: `openclaw-plugin:${externalUserKey}`,
|
|
287
|
-
title: "OpenClaw"
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
run(action) {
|
|
291
|
-
const now = Date.now();
|
|
292
|
-
if (action.kind === "send-message") {
|
|
293
|
-
if (action.text.toLowerCase().includes("approval")) {
|
|
294
|
-
const requestId = `approval:${randomUUID()}`;
|
|
295
|
-
return [
|
|
296
|
-
{
|
|
297
|
-
type: "state",
|
|
298
|
-
eventId: randomUUID(),
|
|
299
|
-
occurredAt: now,
|
|
300
|
-
namespace: this.namespace,
|
|
301
|
-
conversationId: action.conversationId,
|
|
302
|
-
connected: true,
|
|
303
|
-
thinking: true,
|
|
304
|
-
lastError: null
|
|
305
|
-
},
|
|
306
|
-
{
|
|
307
|
-
type: "approval-request",
|
|
308
|
-
eventId: randomUUID(),
|
|
309
|
-
occurredAt: now + 1,
|
|
310
|
-
namespace: this.namespace,
|
|
311
|
-
conversationId: action.conversationId,
|
|
312
|
-
requestId,
|
|
313
|
-
title: "Approve OpenClaw action",
|
|
314
|
-
description: action.text,
|
|
315
|
-
createdAt: now + 1
|
|
316
|
-
},
|
|
317
|
-
{
|
|
318
|
-
type: "state",
|
|
319
|
-
eventId: randomUUID(),
|
|
320
|
-
occurredAt: now + 2,
|
|
321
|
-
namespace: this.namespace,
|
|
322
|
-
conversationId: action.conversationId,
|
|
323
|
-
connected: true,
|
|
324
|
-
thinking: false,
|
|
325
|
-
lastError: null
|
|
326
|
-
}
|
|
327
|
-
];
|
|
328
|
-
}
|
|
329
|
-
const externalMessageId = `assistant:${randomUUID()}`;
|
|
330
|
-
return [
|
|
331
|
-
{
|
|
332
|
-
type: "state",
|
|
333
|
-
eventId: randomUUID(),
|
|
334
|
-
occurredAt: now,
|
|
335
|
-
namespace: this.namespace,
|
|
336
|
-
conversationId: action.conversationId,
|
|
337
|
-
connected: true,
|
|
338
|
-
thinking: true,
|
|
339
|
-
lastError: null
|
|
340
|
-
},
|
|
341
|
-
{
|
|
342
|
-
type: "message",
|
|
343
|
-
eventId: randomUUID(),
|
|
344
|
-
occurredAt: now + 1,
|
|
345
|
-
namespace: this.namespace,
|
|
346
|
-
conversationId: action.conversationId,
|
|
347
|
-
externalMessageId,
|
|
348
|
-
role: "assistant",
|
|
349
|
-
content: { mode: "replace", text: "OpenClaw plugin echo: " },
|
|
350
|
-
createdAt: now + 1,
|
|
351
|
-
status: "streaming"
|
|
352
|
-
},
|
|
353
|
-
{
|
|
354
|
-
type: "message",
|
|
355
|
-
eventId: randomUUID(),
|
|
356
|
-
occurredAt: now + 2,
|
|
357
|
-
namespace: this.namespace,
|
|
358
|
-
conversationId: action.conversationId,
|
|
359
|
-
externalMessageId,
|
|
360
|
-
role: "assistant",
|
|
361
|
-
content: { mode: "append", delta: action.text.trim() || "(empty message)" },
|
|
362
|
-
createdAt: now + 2,
|
|
363
|
-
status: "completed"
|
|
364
|
-
},
|
|
365
|
-
{
|
|
366
|
-
type: "state",
|
|
367
|
-
eventId: randomUUID(),
|
|
368
|
-
occurredAt: now + 3,
|
|
369
|
-
namespace: this.namespace,
|
|
370
|
-
conversationId: action.conversationId,
|
|
371
|
-
connected: true,
|
|
372
|
-
thinking: false,
|
|
373
|
-
lastError: null
|
|
374
|
-
}
|
|
375
|
-
];
|
|
376
|
-
}
|
|
377
|
-
if (action.kind === "approve") {
|
|
378
|
-
return [{
|
|
379
|
-
type: "approval-resolved",
|
|
380
|
-
eventId: randomUUID(),
|
|
381
|
-
occurredAt: now,
|
|
382
|
-
namespace: this.namespace,
|
|
383
|
-
conversationId: action.conversationId,
|
|
384
|
-
requestId: action.requestId,
|
|
385
|
-
status: "approved"
|
|
386
|
-
}];
|
|
387
|
-
}
|
|
388
|
-
return [{
|
|
389
|
-
type: "approval-resolved",
|
|
390
|
-
eventId: randomUUID(),
|
|
391
|
-
occurredAt: now,
|
|
392
|
-
namespace: this.namespace,
|
|
393
|
-
conversationId: action.conversationId,
|
|
394
|
-
requestId: action.requestId,
|
|
395
|
-
status: "denied"
|
|
396
|
-
}];
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
601
|
// src/routes.ts
|
|
401
602
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
402
603
|
|
|
@@ -1927,6 +2128,12 @@ async function dispatchEvents(callbackClient, events) {
|
|
|
1927
2128
|
await callbackClient.postEvent(event);
|
|
1928
2129
|
}
|
|
1929
2130
|
}
|
|
2131
|
+
async function dispatchMaybeEvents(callbackClient, maybeEvents) {
|
|
2132
|
+
if (!maybeEvents || maybeEvents.length === 0) {
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
await dispatchEvents(callbackClient, maybeEvents);
|
|
2136
|
+
}
|
|
1930
2137
|
function createPluginApp(deps) {
|
|
1931
2138
|
const app = new Hono2;
|
|
1932
2139
|
const healthHandler = (c) => {
|
|
@@ -1956,7 +2163,7 @@ function createPluginApp(deps) {
|
|
|
1956
2163
|
if (!body?.externalUserKey) {
|
|
1957
2164
|
return c.json({ error: "Invalid body" }, 400);
|
|
1958
2165
|
}
|
|
1959
|
-
return c.json(deps.runtime.ensureDefaultConversation(body.externalUserKey));
|
|
2166
|
+
return c.json(await deps.runtime.ensureDefaultConversation(body.externalUserKey));
|
|
1960
2167
|
};
|
|
1961
2168
|
const sendMessageHandler = async (c) => {
|
|
1962
2169
|
const idempotencyKey = c.req.header("idempotency-key");
|
|
@@ -1971,6 +2178,12 @@ function createPluginApp(deps) {
|
|
|
1971
2178
|
if (!body?.conversationId || typeof body.text !== "string" || !body.localMessageId) {
|
|
1972
2179
|
return c.json({ error: "Invalid body" }, 400);
|
|
1973
2180
|
}
|
|
2181
|
+
if (deps.runtime.isConversationBusy?.(body.conversationId)) {
|
|
2182
|
+
return c.json({
|
|
2183
|
+
error: "Conversation already has an active OpenClaw run",
|
|
2184
|
+
retryAfterMs: 1000
|
|
2185
|
+
}, 409);
|
|
2186
|
+
}
|
|
1974
2187
|
const ack = {
|
|
1975
2188
|
accepted: true,
|
|
1976
2189
|
upstreamRequestId: `plugin-send:${randomUUID2()}`,
|
|
@@ -1979,16 +2192,25 @@ function createPluginApp(deps) {
|
|
|
1979
2192
|
};
|
|
1980
2193
|
deps.idempotencyCache.set(idempotencyKey, ack);
|
|
1981
2194
|
queueMicrotask(() => {
|
|
1982
|
-
|
|
2195
|
+
deps.runtime.sendMessage({
|
|
1983
2196
|
kind: "send-message",
|
|
1984
2197
|
conversationId: body.conversationId,
|
|
1985
2198
|
text: body.text,
|
|
1986
2199
|
localMessageId: body.localMessageId
|
|
1987
|
-
}))
|
|
2200
|
+
}).then(async (events) => {
|
|
2201
|
+
await dispatchMaybeEvents(deps.callbackClient, events);
|
|
2202
|
+
}).catch((error2) => {
|
|
2203
|
+
if (error2 instanceof ConversationBusyError) {
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
});
|
|
1988
2207
|
});
|
|
1989
2208
|
return c.json(ack);
|
|
1990
2209
|
};
|
|
1991
2210
|
const approveHandler = async (c) => {
|
|
2211
|
+
if (!deps.runtime.supportsApprovals) {
|
|
2212
|
+
return c.json({ error: "OpenClaw approval bridge is not implemented yet" }, 501);
|
|
2213
|
+
}
|
|
1992
2214
|
const idempotencyKey = c.req.header("idempotency-key");
|
|
1993
2215
|
if (!idempotencyKey) {
|
|
1994
2216
|
return c.json({ error: "Missing idempotency-key" }, 400);
|
|
@@ -2009,15 +2231,20 @@ function createPluginApp(deps) {
|
|
|
2009
2231
|
};
|
|
2010
2232
|
deps.idempotencyCache.set(idempotencyKey, ack);
|
|
2011
2233
|
queueMicrotask(() => {
|
|
2012
|
-
|
|
2234
|
+
deps.runtime.approve({
|
|
2013
2235
|
kind: "approve",
|
|
2014
2236
|
conversationId: body.conversationId,
|
|
2015
2237
|
requestId: c.req.param("requestId")
|
|
2016
|
-
}))
|
|
2238
|
+
}).then(async (events) => {
|
|
2239
|
+
await dispatchMaybeEvents(deps.callbackClient, events);
|
|
2240
|
+
}).catch(() => {});
|
|
2017
2241
|
});
|
|
2018
2242
|
return c.json(ack);
|
|
2019
2243
|
};
|
|
2020
2244
|
const denyHandler = async (c) => {
|
|
2245
|
+
if (!deps.runtime.supportsApprovals) {
|
|
2246
|
+
return c.json({ error: "OpenClaw approval bridge is not implemented yet" }, 501);
|
|
2247
|
+
}
|
|
2021
2248
|
const idempotencyKey = c.req.header("idempotency-key");
|
|
2022
2249
|
if (!idempotencyKey) {
|
|
2023
2250
|
return c.json({ error: "Missing idempotency-key" }, 400);
|
|
@@ -2038,11 +2265,13 @@ function createPluginApp(deps) {
|
|
|
2038
2265
|
};
|
|
2039
2266
|
deps.idempotencyCache.set(idempotencyKey, ack);
|
|
2040
2267
|
queueMicrotask(() => {
|
|
2041
|
-
|
|
2268
|
+
deps.runtime.deny({
|
|
2042
2269
|
kind: "deny",
|
|
2043
2270
|
conversationId: body.conversationId,
|
|
2044
2271
|
requestId: c.req.param("requestId")
|
|
2045
|
-
}))
|
|
2272
|
+
}).then(async (events) => {
|
|
2273
|
+
await dispatchMaybeEvents(deps.callbackClient, events);
|
|
2274
|
+
}).catch(() => {});
|
|
2046
2275
|
});
|
|
2047
2276
|
return c.json(ack);
|
|
2048
2277
|
};
|
|
@@ -2057,7 +2286,7 @@ function createPluginApp(deps) {
|
|
|
2057
2286
|
function registerPluginRoutes(api) {
|
|
2058
2287
|
const config = resolvePluginConfig(api.pluginConfig ?? {});
|
|
2059
2288
|
const callbackClient = new HapiCallbackClient(config.hapiBaseUrl, config.sharedSecret);
|
|
2060
|
-
const runtime = new
|
|
2289
|
+
const runtime = new RealOpenClawAdapter(config.namespace, api.runtime, callbackClient);
|
|
2061
2290
|
const app = createPluginApp({
|
|
2062
2291
|
sharedSecret: config.sharedSecret,
|
|
2063
2292
|
namespace: config.namespace,
|
|
@@ -2098,7 +2327,9 @@ var src_default = definePluginEntry({
|
|
|
2098
2327
|
}
|
|
2099
2328
|
runtimeStore.setRuntime(api.runtime);
|
|
2100
2329
|
registerPluginRoutes(api);
|
|
2101
|
-
api.
|
|
2330
|
+
const config = resolvePluginConfig(api.pluginConfig ?? {});
|
|
2331
|
+
api.registerService(createTranscriptBridgeService(config));
|
|
2332
|
+
api.registerService(createTranscriptCaptureService(config));
|
|
2102
2333
|
}
|
|
2103
2334
|
});
|
|
2104
2335
|
export {
|