@stagewhisper/stagewhisper 0.51.0 → 0.53.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/service.ts DELETED
@@ -1,952 +0,0 @@
1
- import type { OpenClawPluginApi, OpenClawPluginServiceContext } from "openclaw/plugin-sdk/core";
2
- import { buildAgentSessionKey } from "openclaw/plugin-sdk/core";
3
- import { randomUUID } from "node:crypto";
4
- import { StageWhisperClient } from "./client.js";
5
- import type { TaskPayload } from "./client.js";
6
- import type { StageWhisperAccount } from "./channel.js";
7
- import { resolveAccount } from "./channel.js";
8
- import { createHealthTracker } from "./health.js";
9
- import { executeReasoningJob, probeOpenResponses, type ReasoningJobEnvelope } from "./reasoning.js";
10
- import { isResponsesEndpointEnabled } from "./openresponses.js";
11
- import { IdentityKeypair, seal, open, type BYOEnvelope } from "./crypto.js";
12
-
13
- const HEARTBEAT_INTERVAL_MS = 30_000;
14
- const RECONNECT_BASE_MS = 1_000;
15
- const RECONNECT_MAX_MS = 60_000;
16
-
17
- type ServiceState = {
18
- running: boolean;
19
- connected: boolean;
20
- lastHeartbeat: Date | null;
21
- reconnectAttempts: number;
22
- };
23
-
24
- export function createRelayService(api: OpenClawPluginApi) {
25
- const state: ServiceState = {
26
- running: false,
27
- connected: false,
28
- lastHeartbeat: null,
29
- reconnectAttempts: 0,
30
- };
31
-
32
- let abortController: AbortController | null = null;
33
- let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
34
- const health = createHealthTracker(null);
35
-
36
- const completedReasoningJobs = new Map<string, number>();
37
- const COMPLETED_JOB_TTL_MS = 5 * 60 * 1000; // 5 minutes
38
- const COMPLETED_JOB_MAX_SIZE = 5000;
39
- const processingReasoningJobs = new Set<string>();
40
-
41
- function evictStaleCompletedJobs(): void {
42
- const cutoff = Date.now() - COMPLETED_JOB_TTL_MS;
43
- for (const [jobId, completedAt] of completedReasoningJobs) {
44
- if (completedAt < cutoff) completedReasoningJobs.delete(jobId);
45
- }
46
- while (completedReasoningJobs.size > COMPLETED_JOB_MAX_SIZE) {
47
- completedReasoningJobs.delete(completedReasoningJobs.keys().next().value!);
48
- }
49
- }
50
-
51
- function resolveServiceAccount(): StageWhisperAccount {
52
- try {
53
- return resolveAccount(api.config);
54
- } catch {
55
- const pluginCfg = api.pluginConfig as Record<string, unknown>;
56
- return {
57
- accountId: null,
58
- apiBaseUrl: (pluginCfg["apiBaseUrl"] as string) ?? "",
59
- integrationId: (pluginCfg["integrationId"] as string) ?? "",
60
- relayToken: (pluginCfg["relayToken"] as string) ?? "",
61
- label: (pluginCfg["label"] as string) ?? "StageWhisper",
62
- pluginSecretKeyB64: (pluginCfg["pluginSecretKey"] as string) ?? undefined,
63
- desktopPublicKeyB64: (pluginCfg["desktopPublicKey"] as string) ?? undefined,
64
- };
65
- }
66
- }
67
-
68
- function buildTaskMessage(task: TaskPayload): string {
69
- const lines: string[] = [];
70
- lines.push(`**${task.title}**`);
71
- lines.push("");
72
- lines.push(task.request_text);
73
-
74
- if (task.evidence_payload) {
75
- const evidence = task.evidence_payload;
76
- if (evidence["transcript_excerpt"]) {
77
- lines.push("");
78
- lines.push(`Context: ${evidence["transcript_excerpt"]}`);
79
- }
80
- if (evidence["signal_summary"]) {
81
- lines.push(`Signal: ${evidence["signal_summary"]}`);
82
- }
83
- if (evidence["tone_summary"]) {
84
- lines.push(`Tone: ${evidence["tone_summary"]}`);
85
- }
86
- if (evidence["playbook_label"]) {
87
- lines.push(`Playbook: ${evidence["playbook_label"]}`);
88
- }
89
- }
90
-
91
- lines.push("");
92
- lines.push(`Action type: ${task.action_type}`);
93
- lines.push(`StageWhisper task: ${task.id}`);
94
- lines.push(`Session: ${task.session_id}`);
95
-
96
- return lines.join("\n");
97
- }
98
-
99
- function isTestTask(task: TaskPayload): boolean {
100
- return task.action_type === "connectivity_test";
101
- }
102
-
103
- async function updateStatus(
104
- client: StageWhisperClient,
105
- task: TaskPayload,
106
- status: string,
107
- ): Promise<void> {
108
- if (isTestTask(task)) return;
109
- await client.updateTaskStatus(task.id, status);
110
- }
111
-
112
- function extractContentFromMessage(msg: Record<string, unknown>): string | null {
113
- const content = msg["content"];
114
- if (typeof content === "string") return content;
115
-
116
- if (Array.isArray(content)) {
117
- for (const part of content) {
118
- if (
119
- typeof part === "object" &&
120
- part !== null &&
121
- (part as Record<string, unknown>)["type"] === "text" &&
122
- typeof (part as Record<string, unknown>)["text"] === "string"
123
- ) {
124
- return (part as Record<string, unknown>)["text"] as string;
125
- }
126
- }
127
- }
128
- return null;
129
- }
130
-
131
- function extractAssistantReply(messages: unknown[]): string | null {
132
- for (let i = messages.length - 1; i >= 0; i--) {
133
- const msg = messages[i] as Record<string, unknown> | undefined;
134
- if (!msg) continue;
135
- const role = msg["role"];
136
- if (role !== "assistant" && role !== "model") continue;
137
- return extractContentFromMessage(msg);
138
- }
139
- return null;
140
- }
141
-
142
- async function extractReplyForTask(
143
- sessionKey: string,
144
- taskId: string,
145
- maxAttempts: number = 3,
146
- ): Promise<string | null> {
147
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
148
- if (attempt > 0) {
149
- await new Promise((r) => setTimeout(r, 1500));
150
- }
151
- const session = await api.runtime.subagent.getSessionMessages({
152
- sessionKey,
153
- limit: 50,
154
- });
155
- const messages = session.messages as Record<string, unknown>[];
156
-
157
- for (let i = 0; i < messages.length; i++) {
158
- const msg = messages[i];
159
- if (msg["role"] !== "user") continue;
160
- const text = extractContentFromMessage(msg) ?? "";
161
- if (!text.includes(`StageWhisper task: ${taskId}`)) continue;
162
-
163
- for (let j = i + 1; j < messages.length; j++) {
164
- const reply = messages[j];
165
- const role = reply["role"];
166
- if (role === "assistant" || role === "model") {
167
- return extractContentFromMessage(reply);
168
- }
169
- if (role === "user") break;
170
- }
171
- }
172
- }
173
- return null;
174
- }
175
-
176
- async function extractReplyWithRetry(
177
- sessionKey: string,
178
- maxAttempts: number = 3,
179
- ): Promise<string | null> {
180
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
181
- if (attempt > 0) {
182
- await new Promise((r) => setTimeout(r, 1500));
183
- }
184
- const session = await api.runtime.subagent.getSessionMessages({
185
- sessionKey,
186
- limit: 20,
187
- });
188
- const reply = extractAssistantReply(session.messages);
189
- if (reply) return reply;
190
- }
191
- return null;
192
- }
193
-
194
- function decryptTaskFields(task: TaskPayload, client: StageWhisperClient): TaskPayload {
195
- const keypair = client.pluginKeypair;
196
- const desktopPub = client.desktopPublicKey;
197
- if (!keypair || !desktopPub) return task;
198
-
199
- const envelopeKey = keypair.deriveEnvelopeKey(desktopPub);
200
- const decrypted = { ...task };
201
- if (task.encrypted_title) {
202
- try {
203
- const envelope: BYOEnvelope = JSON.parse(task.encrypted_title);
204
- const data = open(envelopeKey, envelope);
205
- decrypted.title = new TextDecoder().decode(data);
206
- } catch {
207
- api.logger.warn(`Failed to decrypt task title for ${task.id}`);
208
- }
209
- }
210
- if (task.encrypted_request) {
211
- try {
212
- const envelope: BYOEnvelope = JSON.parse(task.encrypted_request);
213
- const data = open(envelopeKey, envelope);
214
- decrypted.request_text = new TextDecoder().decode(data);
215
- } catch {
216
- api.logger.warn(`Failed to decrypt task request for ${task.id}`);
217
- }
218
- }
219
- if (task.encrypted_evidence) {
220
- try {
221
- const envelope: BYOEnvelope = JSON.parse(task.encrypted_evidence);
222
- const data = open(envelopeKey, envelope);
223
- decrypted.evidence_payload = JSON.parse(new TextDecoder().decode(data));
224
- } catch {
225
- api.logger.warn(`Failed to decrypt task evidence for ${task.id}`);
226
- }
227
- }
228
- return decrypted;
229
- }
230
-
231
- function encryptReply(content: string, client: StageWhisperClient, sessionId: string, taskId: string): string | undefined {
232
- const keypair = client.pluginKeypair;
233
- const desktopPub = client.desktopPublicKey;
234
- if (!keypair || !desktopPub) return undefined;
235
-
236
- const envelopeKey = keypair.deriveEnvelopeKey(desktopPub);
237
- const envelope = seal(
238
- envelopeKey,
239
- "plugin",
240
- sessionId,
241
- taskId,
242
- "task_reply",
243
- new TextEncoder().encode(content),
244
- );
245
- return JSON.stringify(envelope);
246
- }
247
-
248
- async function handleNormalTask(task: TaskPayload, client: StageWhisperClient): Promise<void> {
249
- let deliveredMarked = false;
250
- for (let attempt = 0; attempt < 2; attempt++) {
251
- try {
252
- await updateStatus(client, task, "delivered");
253
- deliveredMarked = true;
254
- break;
255
- } catch (err) {
256
- api.logger.warn(`Failed to mark task as delivered (attempt ${attempt + 1}): ${err}`);
257
- if (attempt === 0) await new Promise((r) => setTimeout(r, 1000));
258
- }
259
- }
260
- if (!deliveredMarked) {
261
- api.logger.warn(`Skipping task ${task.id} — could not mark delivered after retries`);
262
- return;
263
- }
264
-
265
- const isByo = !!task.is_byo_encrypted;
266
- const effectiveTask = isByo ? decryptTaskFields(task, client) : task;
267
-
268
- const messageContent = buildTaskMessage(effectiveTask);
269
- const peerId = `sw-session-${task.session_id}`;
270
- const sessionKey = buildAgentSessionKey({
271
- agentId: "default",
272
- channel: "stagewhisper",
273
- peer: { kind: "direct", id: peerId },
274
- });
275
-
276
- const result = await api.runtime.subagent.run({
277
- sessionKey,
278
- message: messageContent,
279
- deliver: true,
280
- idempotencyKey: `sw-task-${task.id}`,
281
- });
282
-
283
- api.logger.info(`Task ${task.id} dispatched to agent session (runId: ${result.runId})`);
284
-
285
- try {
286
- await updateStatus(client, task, "running");
287
- } catch (err) {
288
- api.logger.warn(`Failed to mark task as running: ${err}`);
289
- }
290
-
291
- api.runtime.subagent
292
- .waitForRun({ runId: result.runId, timeoutMs: 120_000 })
293
- .then(async (waitResult) => {
294
- if (waitResult.status === "ok") {
295
- const reply = await extractReplyForTask(sessionKey, task.id);
296
- if (reply) {
297
- const encSummary = isByo ? encryptReply(reply, client, task.session_id, task.id) : undefined;
298
- await client.postReply(task.id, isByo ? "[BYO Encrypted]" : reply, undefined, encSummary);
299
- api.logger.info(`Task ${task.id} completed with reply`);
300
- } else {
301
- api.logger.warn(`Task ${task.id} completed but no reply found`);
302
- }
303
- await updateStatus(client, task, "completed").catch(() => {});
304
- } else {
305
- api.logger.error(`Agent run failed for task ${task.id}: ${waitResult.error}`);
306
- await updateStatus(client, task, "failed").catch(() => {});
307
- }
308
- })
309
- .catch(async (err) => {
310
- api.logger.error(`Failed to track task ${task.id}: ${err}`);
311
- await updateStatus(client, task, "failed").catch(() => {});
312
- });
313
- }
314
-
315
- async function handleTestTask(task: TaskPayload, client: StageWhisperClient): Promise<void> {
316
- const messageContent = buildTaskMessage(task);
317
- const sessionKey = buildAgentSessionKey({
318
- agentId: "default",
319
- channel: "stagewhisper",
320
- peer: { kind: "direct", id: `sw-test-${task.id}` },
321
- });
322
-
323
- const result = await api.runtime.subagent.run({
324
- sessionKey,
325
- message: messageContent,
326
- deliver: false,
327
- idempotencyKey: randomUUID(),
328
- });
329
-
330
- api.logger.info(`Test task ${task.id} dispatched (runId: ${result.runId})`);
331
-
332
- const waitResult = await api.runtime.subagent.waitForRun({
333
- runId: result.runId,
334
- timeoutMs: 120_000,
335
- });
336
-
337
- if (waitResult.status === "ok") {
338
- const reply = await extractReplyWithRetry(sessionKey);
339
- if (reply) {
340
- await client.testReply(task.id, reply);
341
- api.logger.info(`Test task ${task.id} completed with reply`);
342
- } else {
343
- api.logger.warn(`Test task ${task.id} completed but no reply found`);
344
- await client.testReply(task.id, "(no reply extracted)");
345
- }
346
- } else {
347
- api.logger.error(`Agent run failed for test task ${task.id}: ${waitResult.error}`);
348
- }
349
- }
350
-
351
- async function handleTask(task: TaskPayload, client: StageWhisperClient): Promise<void> {
352
- api.logger.info(`Received task: ${task.title} (${task.id})`);
353
-
354
- try {
355
- if (isTestTask(task)) {
356
- await handleTestTask(task, client);
357
- } else {
358
- await handleNormalTask(task, client);
359
- }
360
- } catch (err) {
361
- api.logger.error(`Failed to process task ${task.id}: ${err}`);
362
- await updateStatus(client, task, "failed").catch(() => {});
363
- }
364
- }
365
-
366
- async function handleCapabilityProbe(
367
- job: ReasoningJobEnvelope,
368
- client: StageWhisperClient,
369
- ): Promise<boolean> {
370
- const correlationId = job.correlation_id;
371
- const displayModel = health.get().displayModel ?? null;
372
- const prompt =
373
- ((job.payload as Record<string, unknown>)?.prompt as string) ??
374
- "Briefly describe your capabilities, personality, tools, expertise, goals, and constraints. Plain text, no JSON.";
375
-
376
- const sessionKey = buildAgentSessionKey({
377
- agentId: "default",
378
- channel: "stagewhisper",
379
- peer: { kind: "direct", id: `sw-probe-${job.job_id}` },
380
- });
381
-
382
- let callbackDelivered = false;
383
-
384
- try {
385
- const result = await api.runtime.subagent.run({
386
- sessionKey,
387
- message: prompt,
388
- deliver: false,
389
- idempotencyKey: `sw-probe-${job.job_id}`,
390
- });
391
-
392
- const waitResult = await api.runtime.subagent.waitForRun({
393
- runId: result.runId,
394
- timeoutMs: 35_000,
395
- });
396
-
397
- if (waitResult.status === "ok") {
398
- const reply = await extractReplyWithRetry(sessionKey);
399
-
400
- await client.postReasoningResult(
401
- job.job_id,
402
- {
403
- job_id: job.job_id,
404
- status: "completed",
405
- provider_run_id: result.runId,
406
- model_ref: displayModel,
407
- usage: null,
408
- output: { raw_description: (reply ?? "").slice(0, 2000) },
409
- error_code: null,
410
- error_message: null,
411
- },
412
- correlationId,
413
- );
414
- callbackDelivered = true;
415
- api.logger.info(`Capability probe ${job.job_id} completed`);
416
- } else {
417
- await client.postReasoningResult(
418
- job.job_id,
419
- {
420
- job_id: job.job_id,
421
- status: "failed",
422
- provider_run_id: result.runId,
423
- model_ref: displayModel,
424
- usage: null,
425
- output: null,
426
- error_code: "agent_error",
427
- error_message: waitResult.error ?? "Agent run failed",
428
- },
429
- correlationId,
430
- );
431
- callbackDelivered = true;
432
- }
433
- } catch (err) {
434
- const errMsg = err instanceof Error ? err.message : String(err);
435
- api.logger.error(`Capability probe ${job.job_id} failed: ${errMsg}`);
436
- try {
437
- await client.postReasoningResult(
438
- job.job_id,
439
- {
440
- job_id: job.job_id,
441
- status: "failed",
442
- provider_run_id: null,
443
- model_ref: displayModel,
444
- usage: null,
445
- output: null,
446
- error_code: "execution_error",
447
- error_message: errMsg,
448
- },
449
- correlationId,
450
- );
451
- callbackDelivered = true;
452
- } catch (postErr) {
453
- api.logger.error(`Failed to report probe failure: ${postErr}`);
454
- }
455
- }
456
-
457
- return callbackDelivered;
458
- }
459
-
460
- async function handleBYOEncryptedJob(
461
- job: ReasoningJobEnvelope,
462
- client: StageWhisperClient,
463
- ): Promise<void> {
464
- const correlationId = job.correlation_id;
465
- api.logger.info(
466
- `Received BYO encrypted reasoning job: ${job.job_id}`,
467
- );
468
-
469
- const rawPayload = job.payload as Record<string, unknown>;
470
- const envelope = rawPayload.envelope as Record<string, unknown> | undefined;
471
- if (!envelope || !envelope.ciphertext) {
472
- api.logger.error(`BYO job ${job.job_id} missing encrypted envelope`);
473
- try {
474
- await client.postReasoningResult(
475
- job.job_id,
476
- {
477
- job_id: job.job_id,
478
- status: "failed",
479
- provider_run_id: null,
480
- model_ref: null,
481
- usage: null,
482
- output: null,
483
- error_code: "missing_envelope",
484
- error_message: "No encrypted envelope in BYO reasoning job",
485
- byo_encrypted: true,
486
- },
487
- correlationId,
488
- );
489
- } catch (postErr) {
490
- api.logger.error(`Failed to report BYO failure: ${postErr}`);
491
- }
492
- return;
493
- }
494
-
495
- const pluginKeypair = client.getPluginKeypair?.();
496
- const desktopPublicKey = client.getDesktopPublicKey?.();
497
-
498
- if (!pluginKeypair || !desktopPublicKey) {
499
- api.logger.error(`BYO job ${job.job_id}: missing crypto keys`);
500
- try {
501
- await client.postReasoningResult(
502
- job.job_id,
503
- {
504
- job_id: job.job_id,
505
- status: "failed",
506
- provider_run_id: null,
507
- model_ref: null,
508
- usage: null,
509
- output: null,
510
- error_code: "missing_keys",
511
- error_message: "Plugin or desktop keys not configured for BYO mode",
512
- byo_encrypted: true,
513
- },
514
- correlationId,
515
- );
516
- } catch (postErr) {
517
- api.logger.error(`Failed to report BYO key failure: ${postErr}`);
518
- }
519
- return;
520
- }
521
-
522
- let decryptedPayload: Record<string, unknown>;
523
- const envelopeKey = pluginKeypair.deriveEnvelopeKey(desktopPublicKey);
524
- try {
525
- const byoEnvelope: BYOEnvelope = {
526
- version: (envelope.version as string) ?? "static_v1",
527
- sender_role: (envelope.sender_role as "desktop" | "plugin") ?? "desktop",
528
- message_id: (envelope.message_id as string) ?? "",
529
- session_id: (envelope.session_id as string) ?? "",
530
- correlation_id: (envelope.correlation_id as string) ?? "",
531
- content_type: (envelope.content_type as BYOEnvelope["content_type"]) ?? "reasoning_input",
532
- nonce: envelope.nonce as string,
533
- ciphertext: envelope.ciphertext as string,
534
- };
535
-
536
- const plaintext = open(envelopeKey, byoEnvelope);
537
- decryptedPayload = JSON.parse(new TextDecoder().decode(plaintext));
538
- } catch (err) {
539
- api.logger.error(`BYO job ${job.job_id} decryption failed: ${err}`);
540
- try {
541
- await client.postReasoningResult(
542
- job.job_id,
543
- {
544
- job_id: job.job_id,
545
- status: "failed",
546
- provider_run_id: null,
547
- model_ref: null,
548
- usage: null,
549
- output: null,
550
- error_code: "decryption_error",
551
- error_message: "Failed to decrypt BYO envelope",
552
- byo_encrypted: true,
553
- },
554
- correlationId,
555
- );
556
- } catch (postErr) {
557
- api.logger.error(`Failed to report BYO decryption failure: ${postErr}`);
558
- }
559
- return;
560
- }
561
-
562
- const plainJob: ReasoningJobEnvelope = {
563
- ...job,
564
- payload: decryptedPayload,
565
- response_schema: decryptedPayload.response_schema as Record<string, unknown> ?? job.response_schema,
566
- };
567
-
568
- const displayModel = health.get().displayModel ?? null;
569
- let result;
570
- try {
571
- result = await executeReasoningJob(api, plainJob, displayModel);
572
- } catch (err) {
573
- const errMsg = err instanceof Error ? err.message : String(err);
574
- health.recordFailure(errMsg);
575
- api.logger.error(`BYO reasoning job ${job.job_id} failed: ${errMsg}`);
576
- try {
577
- await client.postReasoningResult(
578
- job.job_id,
579
- {
580
- job_id: job.job_id,
581
- status: "failed",
582
- provider_run_id: null,
583
- model_ref: displayModel,
584
- usage: null,
585
- output: null,
586
- error_code: "execution_error",
587
- error_message: errMsg,
588
- byo_encrypted: true,
589
- },
590
- correlationId,
591
- );
592
- } catch (postErr) {
593
- api.logger.error(`Failed to report BYO reasoning failure: ${postErr}`);
594
- }
595
- return;
596
- }
597
-
598
- if (result.status === "completed") {
599
- health.recordSuccess();
600
- } else {
601
- health.recordFailure(result.error_message ?? `reasoning ${result.status}`);
602
- }
603
-
604
- let encryptedResult: Record<string, unknown>;
605
- try {
606
- const resultJson = JSON.stringify(result.output ?? {});
607
- const resultBytes = new TextEncoder().encode(resultJson);
608
-
609
- const sessionId = (envelope.session_id as string) ?? "";
610
- const resultCorrelationId = (envelope.correlation_id as string) ?? "";
611
-
612
- const resultEnvelope = seal(
613
- envelopeKey,
614
- "plugin",
615
- sessionId,
616
- resultCorrelationId,
617
- "reasoning_output",
618
- resultBytes,
619
- );
620
-
621
- encryptedResult = {
622
- byo_encrypted: true,
623
- envelope: resultEnvelope,
624
- job_id: job.job_id,
625
- status: result.status,
626
- usage: result.usage,
627
- model_ref: result.model_ref,
628
- };
629
- } catch (err) {
630
- api.logger.error(`BYO job ${job.job_id} result encryption failed: ${err}`);
631
- encryptedResult = {
632
- job_id: job.job_id,
633
- status: "failed",
634
- error_code: "encryption_error",
635
- error_message: "Failed to encrypt result",
636
- byo_encrypted: true,
637
- };
638
- }
639
-
640
- try {
641
- await client.postReasoningResult(
642
- job.job_id,
643
- encryptedResult,
644
- correlationId,
645
- );
646
- completedReasoningJobs.set(job.job_id, Date.now());
647
- api.logger.info(`BYO reasoning job ${job.job_id} completed (status: ${result.status})`);
648
- } catch (postErr) {
649
- api.logger.error(`Failed to post BYO reasoning result for ${job.job_id}: ${postErr}`);
650
- }
651
- }
652
-
653
- async function handleReasoningJob(
654
- job: ReasoningJobEnvelope,
655
- client: StageWhisperClient,
656
- ): Promise<void> {
657
- const correlationId = job.correlation_id;
658
- api.logger.info(
659
- `Received reasoning job: ${job.job_id} (purpose: ${job.purpose}, correlation: ${correlationId ?? "none"})`,
660
- );
661
-
662
- if (completedReasoningJobs.has(job.job_id)) {
663
- api.logger.info(`Skipping completed reasoning job: ${job.job_id}`);
664
- return;
665
- }
666
- if (processingReasoningJobs.has(job.job_id)) {
667
- api.logger.info(`Skipping in-flight reasoning job: ${job.job_id}`);
668
- return;
669
- }
670
- processingReasoningJobs.add(job.job_id);
671
-
672
- if (job.purpose === "capability_probe") {
673
- try {
674
- const delivered = await handleCapabilityProbe(job, client);
675
- if (delivered) {
676
- completedReasoningJobs.set(job.job_id, Date.now());
677
- }
678
- } finally {
679
- processingReasoningJobs.delete(job.job_id);
680
- }
681
- return;
682
- }
683
-
684
- try {
685
- const isBYOEncrypted =
686
- job.payload &&
687
- (job.payload as Record<string, unknown>).byo_encrypted === true;
688
-
689
- if (isBYOEncrypted) {
690
- await handleBYOEncryptedJob(job, client);
691
- return;
692
- }
693
-
694
- const displayModel = health.get().displayModel ?? null;
695
-
696
- let result;
697
- try {
698
- result = await executeReasoningJob(api, job, displayModel);
699
- } catch (err) {
700
- const errMsg = err instanceof Error ? err.message : String(err);
701
- health.recordFailure(errMsg);
702
- api.logger.error(`Reasoning job ${job.job_id} failed: ${errMsg}`);
703
-
704
- try {
705
- await client.postReasoningResult(
706
- job.job_id,
707
- {
708
- job_id: job.job_id,
709
- status: "failed",
710
- provider_run_id: null,
711
- model_ref: displayModel,
712
- usage: null,
713
- output: null,
714
- error_code: "execution_error",
715
- error_message: errMsg,
716
- },
717
- correlationId,
718
- );
719
- } catch (postErr) {
720
- api.logger.error(`Failed to report reasoning failure: ${postErr}`);
721
- }
722
- return;
723
- }
724
-
725
- if (result.status === "completed") {
726
- health.recordSuccess();
727
- } else {
728
- health.recordFailure(result.error_message ?? `reasoning ${result.status}`);
729
- }
730
-
731
- if (result.model_ref && result.model_ref !== displayModel) {
732
- health.setModel(result.model_ref);
733
- }
734
-
735
- try {
736
- await client.postReasoningResult(
737
- job.job_id,
738
- result as unknown as Record<string, unknown>,
739
- correlationId,
740
- );
741
- completedReasoningJobs.set(job.job_id, Date.now());
742
- if (completedReasoningJobs.size > COMPLETED_JOB_MAX_SIZE) {
743
- evictStaleCompletedJobs();
744
- }
745
- api.logger.info(`Reasoning job ${job.job_id} completed (status: ${result.status})`);
746
- } catch (postErr) {
747
- api.logger.error(`Failed to post reasoning result for ${job.job_id}: ${postErr}`);
748
- }
749
- } finally {
750
- processingReasoningJobs.delete(job.job_id);
751
- }
752
- }
753
-
754
- function createClient(account: StageWhisperAccount): StageWhisperClient {
755
- const client = new StageWhisperClient(
756
- account.apiBaseUrl,
757
- account.integrationId,
758
- account.relayToken,
759
- );
760
- if (account.pluginSecretKeyB64) {
761
- try {
762
- const secretBytes = Uint8Array.from(atob(account.pluginSecretKeyB64), c => c.charCodeAt(0));
763
- client.setPluginKeypair(IdentityKeypair.fromSecretBytes(secretBytes));
764
- } catch (err) {
765
- api.logger.warn(`Failed to load plugin keypair from config: ${err}`);
766
- }
767
- }
768
- if (account.desktopPublicKeyB64) {
769
- try {
770
- client.setDesktopPublicKey(
771
- IdentityKeypair.publicKeyFromBase64(account.desktopPublicKeyB64),
772
- );
773
- } catch (err) {
774
- api.logger.warn(`Failed to load desktop public key from config: ${err}`);
775
- }
776
- }
777
- return client;
778
- }
779
-
780
- async function connectStream(account: StageWhisperAccount): Promise<void> {
781
- const client = createClient(account);
782
-
783
- abortController = new AbortController();
784
- const url = client.streamUrl();
785
- api.logger.info(`Connecting to relay stream: ${url}`);
786
-
787
- const res = await fetch(url, {
788
- headers: client.streamHeaders(),
789
- signal: abortController.signal,
790
- });
791
-
792
- if (!res.ok) {
793
- throw new Error(`Stream connection failed (${res.status})`);
794
- }
795
-
796
- if (!res.body) {
797
- throw new Error("Stream response has no body");
798
- }
799
-
800
- state.connected = true;
801
- state.reconnectAttempts = 0;
802
- health.setConnected();
803
- api.logger.info("Connected to StageWhisper relay stream");
804
-
805
- if (health.get().status !== "healthy") {
806
- api.logger.info("Re-probing /v1/responses after reconnect...");
807
- const probe = await probeOpenResponses(api);
808
- if (probe.ok) {
809
- health.recordSuccess();
810
- if (probe.model) health.setModel(probe.model);
811
- api.logger.info(`Local AI verified on reconnect — model: ${probe.model ?? "unknown"}`);
812
- } else {
813
- api.logger.warn(`Reconnect probe failed: ${probe.error}`);
814
- }
815
- }
816
-
817
- const reader = res.body.getReader();
818
- const decoder = new TextDecoder();
819
- let buffer = "";
820
-
821
- try {
822
- while (state.running) {
823
- const { done, value } = await reader.read();
824
- if (done) break;
825
-
826
- buffer += decoder.decode(value, { stream: true });
827
- const lines = buffer.split("\n");
828
- buffer = lines.pop() ?? "";
829
-
830
- for (const line of lines) {
831
- if (!line.startsWith("data: ")) continue;
832
- const jsonStr = line.slice(6).trim();
833
- if (!jsonStr) continue;
834
-
835
- try {
836
- const envelope = JSON.parse(jsonStr) as Record<string, unknown>;
837
- if (envelope.event_type === "reasoning_job") {
838
- handleReasoningJob(envelope as unknown as ReasoningJobEnvelope, client).catch((err) =>
839
- api.logger.error(`Error handling reasoning job: ${err}`),
840
- );
841
- } else {
842
- const task = envelope as unknown as TaskPayload;
843
- await handleTask(task, client);
844
- }
845
- } catch (err) {
846
- api.logger.error(`Error processing event from stream: ${err}`);
847
- }
848
- }
849
- }
850
- } finally {
851
- reader.releaseLock();
852
- state.connected = false;
853
- health.setDisconnected();
854
- }
855
- }
856
-
857
- function backoffMs(): number {
858
- const ms = Math.min(RECONNECT_BASE_MS * Math.pow(2, state.reconnectAttempts), RECONNECT_MAX_MS);
859
- return ms + Math.random() * 1000;
860
- }
861
-
862
- async function runLoop(account: StageWhisperAccount): Promise<void> {
863
- while (state.running) {
864
- try {
865
- await connectStream(account);
866
- } catch (err) {
867
- if (!state.running) break;
868
- health.setDisconnected();
869
- state.reconnectAttempts++;
870
- const delay = backoffMs();
871
- api.logger.warn(
872
- `Stream disconnected (attempt ${state.reconnectAttempts}), reconnecting in ${Math.round(delay)}ms: ${err}`,
873
- );
874
- await new Promise((r) => setTimeout(r, delay));
875
- }
876
- }
877
- }
878
-
879
- function startHeartbeat(account: StageWhisperAccount): void {
880
- const client = createClient(account);
881
-
882
- heartbeatTimer = setInterval(async () => {
883
- try {
884
- await client.heartbeat(health.toHeartbeatPayload());
885
- state.lastHeartbeat = new Date();
886
- } catch (err) {
887
- api.logger.warn(`Heartbeat failed: ${err}`);
888
- }
889
- }, HEARTBEAT_INTERVAL_MS);
890
- }
891
-
892
- return {
893
- id: "stagewhisper-relay",
894
-
895
- async start(_ctx: OpenClawPluginServiceContext): Promise<void> {
896
- const account = resolveServiceAccount();
897
- if (!account.apiBaseUrl || !account.integrationId || !account.relayToken) {
898
- api.logger.info(
899
- "StageWhisper not configured — skipping relay service. Pair first with: openclaw stagewhisper pair",
900
- );
901
- return;
902
- }
903
-
904
- api.logger.info(
905
- `StageWhisper relay starting — backend: ${account.apiBaseUrl}, integration: ${account.integrationId}`,
906
- );
907
-
908
- state.running = true;
909
-
910
- if (!isResponsesEndpointEnabled(api)) {
911
- api.logger.warn(
912
- "gateway.http.endpoints.responses.enabled is not true — reasoning jobs will fail with 404. " +
913
- "Enable it in config and restart the gateway, or re-pair with: openclaw stagewhisper pair --code <CODE> --enable-responses",
914
- );
915
- }
916
-
917
- api.logger.info("Probing /v1/responses to verify local AI connectivity...");
918
- const probe = await probeOpenResponses(api);
919
- if (probe.ok) {
920
- health.recordSuccess();
921
- if (probe.model) health.setModel(probe.model);
922
- api.logger.info(`Local AI verified — model: ${probe.model ?? "unknown"}`);
923
- } else {
924
- health.recordFailure(probe.error ?? "probe_failed");
925
- api.logger.warn(`Local AI probe failed: ${probe.error} — relay starts as unverified`);
926
- }
927
-
928
- startHeartbeat(account);
929
- runLoop(account).catch((err) => {
930
- api.logger.error(`Relay service crashed: ${err}`);
931
- });
932
- api.logger.info(`StageWhisper relay service started for ${account.label}`);
933
- },
934
-
935
- async stop(_ctx: OpenClawPluginServiceContext): Promise<void> {
936
- state.running = false;
937
- if (heartbeatTimer) {
938
- clearInterval(heartbeatTimer);
939
- heartbeatTimer = null;
940
- }
941
- if (abortController) {
942
- abortController.abort();
943
- abortController = null;
944
- }
945
- api.logger.info("StageWhisper relay service stopped");
946
- },
947
-
948
- getState(): ServiceState {
949
- return { ...state };
950
- },
951
- };
952
- }