@stagewhisper/stagewhisper 0.49.0 → 0.52.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,662 +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
-
12
- const HEARTBEAT_INTERVAL_MS = 30_000;
13
- const RECONNECT_BASE_MS = 1_000;
14
- const RECONNECT_MAX_MS = 60_000;
15
-
16
- type ServiceState = {
17
- running: boolean;
18
- connected: boolean;
19
- lastHeartbeat: Date | null;
20
- reconnectAttempts: number;
21
- };
22
-
23
- export function createRelayService(api: OpenClawPluginApi) {
24
- const state: ServiceState = {
25
- running: false,
26
- connected: false,
27
- lastHeartbeat: null,
28
- reconnectAttempts: 0,
29
- };
30
-
31
- let abortController: AbortController | null = null;
32
- let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
33
- const health = createHealthTracker(null);
34
-
35
- const completedReasoningJobs = new Map<string, number>();
36
- const COMPLETED_JOB_TTL_MS = 5 * 60 * 1000; // 5 minutes
37
- const COMPLETED_JOB_MAX_SIZE = 5000;
38
- const processingReasoningJobs = new Set<string>();
39
-
40
- function evictStaleCompletedJobs(): void {
41
- const cutoff = Date.now() - COMPLETED_JOB_TTL_MS;
42
- for (const [jobId, completedAt] of completedReasoningJobs) {
43
- if (completedAt < cutoff) completedReasoningJobs.delete(jobId);
44
- }
45
- while (completedReasoningJobs.size > COMPLETED_JOB_MAX_SIZE) {
46
- completedReasoningJobs.delete(completedReasoningJobs.keys().next().value!);
47
- }
48
- }
49
-
50
- function resolveServiceAccount(): StageWhisperAccount {
51
- try {
52
- return resolveAccount(api.config);
53
- } catch {
54
- const pluginCfg = api.pluginConfig as Record<string, unknown>;
55
- return {
56
- accountId: null,
57
- apiBaseUrl: (pluginCfg["apiBaseUrl"] as string) ?? "",
58
- integrationId: (pluginCfg["integrationId"] as string) ?? "",
59
- relayToken: (pluginCfg["relayToken"] as string) ?? "",
60
- label: (pluginCfg["label"] as string) ?? "StageWhisper",
61
- };
62
- }
63
- }
64
-
65
- function buildTaskMessage(task: TaskPayload): string {
66
- const lines: string[] = [];
67
- lines.push(`**${task.title}**`);
68
- lines.push("");
69
- lines.push(task.request_text);
70
-
71
- if (task.evidence_payload) {
72
- const evidence = task.evidence_payload;
73
- if (evidence["transcript_excerpt"]) {
74
- lines.push("");
75
- lines.push(`Context: ${evidence["transcript_excerpt"]}`);
76
- }
77
- if (evidence["signal_summary"]) {
78
- lines.push(`Signal: ${evidence["signal_summary"]}`);
79
- }
80
- if (evidence["tone_summary"]) {
81
- lines.push(`Tone: ${evidence["tone_summary"]}`);
82
- }
83
- if (evidence["playbook_label"]) {
84
- lines.push(`Playbook: ${evidence["playbook_label"]}`);
85
- }
86
- }
87
-
88
- lines.push("");
89
- lines.push(`Action type: ${task.action_type}`);
90
- lines.push(`StageWhisper task: ${task.id}`);
91
- lines.push(`Session: ${task.session_id}`);
92
-
93
- return lines.join("\n");
94
- }
95
-
96
- function isTestTask(task: TaskPayload): boolean {
97
- return task.action_type === "connectivity_test";
98
- }
99
-
100
- async function updateStatus(
101
- client: StageWhisperClient,
102
- task: TaskPayload,
103
- status: string,
104
- ): Promise<void> {
105
- if (isTestTask(task)) return;
106
- await client.updateTaskStatus(task.id, status);
107
- }
108
-
109
- function extractContentFromMessage(msg: Record<string, unknown>): string | null {
110
- const content = msg["content"];
111
- if (typeof content === "string") return content;
112
-
113
- if (Array.isArray(content)) {
114
- for (const part of content) {
115
- if (
116
- typeof part === "object" &&
117
- part !== null &&
118
- (part as Record<string, unknown>)["type"] === "text" &&
119
- typeof (part as Record<string, unknown>)["text"] === "string"
120
- ) {
121
- return (part as Record<string, unknown>)["text"] as string;
122
- }
123
- }
124
- }
125
- return null;
126
- }
127
-
128
- function extractAssistantReply(messages: unknown[]): string | null {
129
- for (let i = messages.length - 1; i >= 0; i--) {
130
- const msg = messages[i] as Record<string, unknown> | undefined;
131
- if (!msg) continue;
132
- const role = msg["role"];
133
- if (role !== "assistant" && role !== "model") continue;
134
- return extractContentFromMessage(msg);
135
- }
136
- return null;
137
- }
138
-
139
- async function extractReplyForTask(
140
- sessionKey: string,
141
- taskId: string,
142
- maxAttempts: number = 3,
143
- ): Promise<string | null> {
144
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
145
- if (attempt > 0) {
146
- await new Promise((r) => setTimeout(r, 1500));
147
- }
148
- const session = await api.runtime.subagent.getSessionMessages({
149
- sessionKey,
150
- limit: 50,
151
- });
152
- const messages = session.messages as Record<string, unknown>[];
153
-
154
- for (let i = 0; i < messages.length; i++) {
155
- const msg = messages[i];
156
- if (msg["role"] !== "user") continue;
157
- const text = extractContentFromMessage(msg) ?? "";
158
- if (!text.includes(`StageWhisper task: ${taskId}`)) continue;
159
-
160
- for (let j = i + 1; j < messages.length; j++) {
161
- const reply = messages[j];
162
- const role = reply["role"];
163
- if (role === "assistant" || role === "model") {
164
- return extractContentFromMessage(reply);
165
- }
166
- if (role === "user") break;
167
- }
168
- }
169
- }
170
- return null;
171
- }
172
-
173
- async function extractReplyWithRetry(
174
- sessionKey: string,
175
- maxAttempts: number = 3,
176
- ): Promise<string | null> {
177
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
178
- if (attempt > 0) {
179
- await new Promise((r) => setTimeout(r, 1500));
180
- }
181
- const session = await api.runtime.subagent.getSessionMessages({
182
- sessionKey,
183
- limit: 20,
184
- });
185
- const reply = extractAssistantReply(session.messages);
186
- if (reply) return reply;
187
- }
188
- return null;
189
- }
190
-
191
- async function handleNormalTask(task: TaskPayload, client: StageWhisperClient): Promise<void> {
192
- let deliveredMarked = false;
193
- for (let attempt = 0; attempt < 2; attempt++) {
194
- try {
195
- await updateStatus(client, task, "delivered");
196
- deliveredMarked = true;
197
- break;
198
- } catch (err) {
199
- api.logger.warn(`Failed to mark task as delivered (attempt ${attempt + 1}): ${err}`);
200
- if (attempt === 0) await new Promise((r) => setTimeout(r, 1000));
201
- }
202
- }
203
- if (!deliveredMarked) {
204
- api.logger.warn(`Skipping task ${task.id} — could not mark delivered after retries`);
205
- return;
206
- }
207
-
208
- const messageContent = buildTaskMessage(task);
209
- const peerId = `sw-session-${task.session_id}`;
210
- const sessionKey = buildAgentSessionKey({
211
- agentId: "default",
212
- channel: "stagewhisper",
213
- peer: { kind: "direct", id: peerId },
214
- });
215
-
216
- const result = await api.runtime.subagent.run({
217
- sessionKey,
218
- message: messageContent,
219
- deliver: true,
220
- idempotencyKey: `sw-task-${task.id}`,
221
- });
222
-
223
- api.logger.info(`Task ${task.id} dispatched to agent session (runId: ${result.runId})`);
224
-
225
- try {
226
- await updateStatus(client, task, "running");
227
- } catch (err) {
228
- api.logger.warn(`Failed to mark task as running: ${err}`);
229
- }
230
-
231
- api.runtime.subagent
232
- .waitForRun({ runId: result.runId, timeoutMs: 120_000 })
233
- .then(async (waitResult) => {
234
- if (waitResult.status === "ok") {
235
- const reply = await extractReplyForTask(sessionKey, task.id);
236
- if (reply) {
237
- await client.postReply(task.id, reply);
238
- api.logger.info(`Task ${task.id} completed with reply`);
239
- } else {
240
- api.logger.warn(`Task ${task.id} completed but no reply found`);
241
- }
242
- await updateStatus(client, task, "completed").catch(() => {});
243
- } else {
244
- api.logger.error(`Agent run failed for task ${task.id}: ${waitResult.error}`);
245
- await updateStatus(client, task, "failed").catch(() => {});
246
- }
247
- })
248
- .catch(async (err) => {
249
- api.logger.error(`Failed to track task ${task.id}: ${err}`);
250
- await updateStatus(client, task, "failed").catch(() => {});
251
- });
252
- }
253
-
254
- async function handleTestTask(task: TaskPayload, client: StageWhisperClient): Promise<void> {
255
- const messageContent = buildTaskMessage(task);
256
- const sessionKey = buildAgentSessionKey({
257
- agentId: "default",
258
- channel: "stagewhisper",
259
- peer: { kind: "direct", id: `sw-test-${task.id}` },
260
- });
261
-
262
- const result = await api.runtime.subagent.run({
263
- sessionKey,
264
- message: messageContent,
265
- deliver: false,
266
- idempotencyKey: randomUUID(),
267
- });
268
-
269
- api.logger.info(`Test task ${task.id} dispatched (runId: ${result.runId})`);
270
-
271
- const waitResult = await api.runtime.subagent.waitForRun({
272
- runId: result.runId,
273
- timeoutMs: 120_000,
274
- });
275
-
276
- if (waitResult.status === "ok") {
277
- const reply = await extractReplyWithRetry(sessionKey);
278
- if (reply) {
279
- await client.testReply(task.id, reply);
280
- api.logger.info(`Test task ${task.id} completed with reply`);
281
- } else {
282
- api.logger.warn(`Test task ${task.id} completed but no reply found`);
283
- await client.testReply(task.id, "(no reply extracted)");
284
- }
285
- } else {
286
- api.logger.error(`Agent run failed for test task ${task.id}: ${waitResult.error}`);
287
- }
288
- }
289
-
290
- async function handleTask(task: TaskPayload, client: StageWhisperClient): Promise<void> {
291
- api.logger.info(`Received task: ${task.title} (${task.id})`);
292
-
293
- try {
294
- if (isTestTask(task)) {
295
- await handleTestTask(task, client);
296
- } else {
297
- await handleNormalTask(task, client);
298
- }
299
- } catch (err) {
300
- api.logger.error(`Failed to process task ${task.id}: ${err}`);
301
- await updateStatus(client, task, "failed").catch(() => {});
302
- }
303
- }
304
-
305
- async function handleCapabilityProbe(
306
- job: ReasoningJobEnvelope,
307
- client: StageWhisperClient,
308
- ): Promise<void> {
309
- const correlationId = job.correlation_id;
310
- const displayModel = health.get().displayModel ?? null;
311
- const prompt =
312
- ((job.payload as Record<string, unknown>)?.prompt as string) ??
313
- "Briefly describe your capabilities, personality, tools, expertise, goals, and constraints. Plain text, no JSON.";
314
-
315
- const sessionKey = buildAgentSessionKey({
316
- agentId: "default",
317
- channel: "stagewhisper",
318
- peer: { kind: "direct", id: `sw-probe-${job.job_id}` },
319
- });
320
-
321
- try {
322
- const result = await api.runtime.subagent.run({
323
- sessionKey,
324
- message: prompt,
325
- deliver: false,
326
- idempotencyKey: `sw-probe-${job.job_id}`,
327
- });
328
-
329
- const waitResult = await api.runtime.subagent.waitForRun({
330
- runId: result.runId,
331
- timeoutMs: 35_000,
332
- });
333
-
334
- if (waitResult.status === "ok") {
335
- const reply = await extractReplyWithRetry(sessionKey);
336
-
337
- await client.postReasoningResult(
338
- job.job_id,
339
- {
340
- job_id: job.job_id,
341
- status: "completed",
342
- provider_run_id: result.runId,
343
- model_ref: displayModel,
344
- usage: null,
345
- output: { raw_description: (reply ?? "").slice(0, 2000) },
346
- error_code: null,
347
- error_message: null,
348
- },
349
- correlationId,
350
- );
351
- api.logger.info(`Capability probe ${job.job_id} completed`);
352
- } else {
353
- await client.postReasoningResult(
354
- job.job_id,
355
- {
356
- job_id: job.job_id,
357
- status: "failed",
358
- provider_run_id: result.runId,
359
- model_ref: displayModel,
360
- usage: null,
361
- output: null,
362
- error_code: "agent_error",
363
- error_message: waitResult.error ?? "Agent run failed",
364
- },
365
- correlationId,
366
- );
367
- }
368
- } catch (err) {
369
- const errMsg = err instanceof Error ? err.message : String(err);
370
- api.logger.error(`Capability probe ${job.job_id} failed: ${errMsg}`);
371
- try {
372
- await client.postReasoningResult(
373
- job.job_id,
374
- {
375
- job_id: job.job_id,
376
- status: "failed",
377
- provider_run_id: null,
378
- model_ref: displayModel,
379
- usage: null,
380
- output: null,
381
- error_code: "execution_error",
382
- error_message: errMsg,
383
- },
384
- correlationId,
385
- );
386
- } catch (postErr) {
387
- api.logger.error(`Failed to report probe failure: ${postErr}`);
388
- }
389
- }
390
- }
391
-
392
- async function handleReasoningJob(
393
- job: ReasoningJobEnvelope,
394
- client: StageWhisperClient,
395
- ): Promise<void> {
396
- const correlationId = job.correlation_id;
397
- api.logger.info(
398
- `Received reasoning job: ${job.job_id} (purpose: ${job.purpose}, correlation: ${correlationId ?? "none"})`,
399
- );
400
-
401
- if (completedReasoningJobs.has(job.job_id)) {
402
- api.logger.info(`Skipping completed reasoning job: ${job.job_id}`);
403
- return;
404
- }
405
- if (processingReasoningJobs.has(job.job_id)) {
406
- api.logger.info(`Skipping in-flight reasoning job: ${job.job_id}`);
407
- return;
408
- }
409
- processingReasoningJobs.add(job.job_id);
410
-
411
- if (job.purpose === "capability_probe") {
412
- try {
413
- await handleCapabilityProbe(job, client);
414
- completedReasoningJobs.set(job.job_id, Date.now());
415
- } finally {
416
- processingReasoningJobs.delete(job.job_id);
417
- }
418
- return;
419
- }
420
-
421
- try {
422
- const displayModel = health.get().displayModel ?? null;
423
-
424
- let result;
425
- try {
426
- result = await executeReasoningJob(api, job, displayModel);
427
- } catch (err) {
428
- const errMsg = err instanceof Error ? err.message : String(err);
429
- health.recordFailure(errMsg);
430
- api.logger.error(`Reasoning job ${job.job_id} failed: ${errMsg}`);
431
-
432
- try {
433
- await client.postReasoningResult(
434
- job.job_id,
435
- {
436
- job_id: job.job_id,
437
- status: "failed",
438
- provider_run_id: null,
439
- model_ref: displayModel,
440
- usage: null,
441
- output: null,
442
- error_code: "execution_error",
443
- error_message: errMsg,
444
- },
445
- correlationId,
446
- );
447
- } catch (postErr) {
448
- api.logger.error(`Failed to report reasoning failure: ${postErr}`);
449
- }
450
- return;
451
- }
452
-
453
- if (result.status === "completed") {
454
- health.recordSuccess();
455
- } else {
456
- health.recordFailure(result.error_message ?? `reasoning ${result.status}`);
457
- }
458
-
459
- if (result.model_ref && result.model_ref !== displayModel) {
460
- health.setModel(result.model_ref);
461
- }
462
-
463
- try {
464
- await client.postReasoningResult(
465
- job.job_id,
466
- result as unknown as Record<string, unknown>,
467
- correlationId,
468
- );
469
- completedReasoningJobs.set(job.job_id, Date.now());
470
- if (completedReasoningJobs.size > COMPLETED_JOB_MAX_SIZE) {
471
- evictStaleCompletedJobs();
472
- }
473
- api.logger.info(`Reasoning job ${job.job_id} completed (status: ${result.status})`);
474
- } catch (postErr) {
475
- api.logger.error(`Failed to post reasoning result for ${job.job_id}: ${postErr}`);
476
- }
477
- } finally {
478
- processingReasoningJobs.delete(job.job_id);
479
- }
480
- }
481
-
482
- async function connectStream(account: StageWhisperAccount): Promise<void> {
483
- const client = new StageWhisperClient(
484
- account.apiBaseUrl,
485
- account.integrationId,
486
- account.relayToken,
487
- );
488
-
489
- abortController = new AbortController();
490
- const url = client.streamUrl();
491
- api.logger.info(`Connecting to relay stream: ${url}`);
492
-
493
- const res = await fetch(url, {
494
- headers: client.streamHeaders(),
495
- signal: abortController.signal,
496
- });
497
-
498
- if (!res.ok) {
499
- throw new Error(`Stream connection failed (${res.status})`);
500
- }
501
-
502
- if (!res.body) {
503
- throw new Error("Stream response has no body");
504
- }
505
-
506
- state.connected = true;
507
- state.reconnectAttempts = 0;
508
- health.setConnected();
509
- api.logger.info("Connected to StageWhisper relay stream");
510
-
511
- if (health.get().status !== "healthy") {
512
- api.logger.info("Re-probing /v1/responses after reconnect...");
513
- const probe = await probeOpenResponses(api);
514
- if (probe.ok) {
515
- health.recordSuccess();
516
- if (probe.model) health.setModel(probe.model);
517
- api.logger.info(`Local AI verified on reconnect — model: ${probe.model ?? "unknown"}`);
518
- } else {
519
- api.logger.warn(`Reconnect probe failed: ${probe.error}`);
520
- }
521
- }
522
-
523
- const reader = res.body.getReader();
524
- const decoder = new TextDecoder();
525
- let buffer = "";
526
-
527
- try {
528
- while (state.running) {
529
- const { done, value } = await reader.read();
530
- if (done) break;
531
-
532
- buffer += decoder.decode(value, { stream: true });
533
- const lines = buffer.split("\n");
534
- buffer = lines.pop() ?? "";
535
-
536
- for (const line of lines) {
537
- if (!line.startsWith("data: ")) continue;
538
- const jsonStr = line.slice(6).trim();
539
- if (!jsonStr) continue;
540
-
541
- try {
542
- const envelope = JSON.parse(jsonStr) as Record<string, unknown>;
543
- if (envelope.event_type === "reasoning_job") {
544
- handleReasoningJob(envelope as unknown as ReasoningJobEnvelope, client).catch((err) =>
545
- api.logger.error(`Error handling reasoning job: ${err}`),
546
- );
547
- } else {
548
- const task = envelope as unknown as TaskPayload;
549
- await handleTask(task, client);
550
- }
551
- } catch (err) {
552
- api.logger.error(`Error processing event from stream: ${err}`);
553
- }
554
- }
555
- }
556
- } finally {
557
- reader.releaseLock();
558
- state.connected = false;
559
- health.setDisconnected();
560
- }
561
- }
562
-
563
- function backoffMs(): number {
564
- const ms = Math.min(RECONNECT_BASE_MS * Math.pow(2, state.reconnectAttempts), RECONNECT_MAX_MS);
565
- return ms + Math.random() * 1000;
566
- }
567
-
568
- async function runLoop(account: StageWhisperAccount): Promise<void> {
569
- while (state.running) {
570
- try {
571
- await connectStream(account);
572
- } catch (err) {
573
- if (!state.running) break;
574
- health.setDisconnected();
575
- state.reconnectAttempts++;
576
- const delay = backoffMs();
577
- api.logger.warn(
578
- `Stream disconnected (attempt ${state.reconnectAttempts}), reconnecting in ${Math.round(delay)}ms: ${err}`,
579
- );
580
- await new Promise((r) => setTimeout(r, delay));
581
- }
582
- }
583
- }
584
-
585
- function startHeartbeat(account: StageWhisperAccount): void {
586
- const client = new StageWhisperClient(
587
- account.apiBaseUrl,
588
- account.integrationId,
589
- account.relayToken,
590
- );
591
-
592
- heartbeatTimer = setInterval(async () => {
593
- try {
594
- await client.heartbeat(health.toHeartbeatPayload());
595
- state.lastHeartbeat = new Date();
596
- } catch (err) {
597
- api.logger.warn(`Heartbeat failed: ${err}`);
598
- }
599
- }, HEARTBEAT_INTERVAL_MS);
600
- }
601
-
602
- return {
603
- id: "stagewhisper-relay",
604
-
605
- async start(_ctx: OpenClawPluginServiceContext): Promise<void> {
606
- const account = resolveServiceAccount();
607
- if (!account.apiBaseUrl || !account.integrationId || !account.relayToken) {
608
- api.logger.info(
609
- "StageWhisper not configured — skipping relay service. Pair first with: openclaw stagewhisper pair",
610
- );
611
- return;
612
- }
613
-
614
- api.logger.info(
615
- `StageWhisper relay starting — backend: ${account.apiBaseUrl}, integration: ${account.integrationId}`,
616
- );
617
-
618
- state.running = true;
619
-
620
- if (!isResponsesEndpointEnabled(api)) {
621
- api.logger.warn(
622
- "gateway.http.endpoints.responses.enabled is not true — reasoning jobs will fail with 404. " +
623
- "Enable it in config and restart the gateway, or re-pair with: openclaw stagewhisper pair --code <CODE> --enable-responses",
624
- );
625
- }
626
-
627
- api.logger.info("Probing /v1/responses to verify local AI connectivity...");
628
- const probe = await probeOpenResponses(api);
629
- if (probe.ok) {
630
- health.recordSuccess();
631
- if (probe.model) health.setModel(probe.model);
632
- api.logger.info(`Local AI verified — model: ${probe.model ?? "unknown"}`);
633
- } else {
634
- health.recordFailure(probe.error ?? "probe_failed");
635
- api.logger.warn(`Local AI probe failed: ${probe.error} — relay starts as unverified`);
636
- }
637
-
638
- startHeartbeat(account);
639
- runLoop(account).catch((err) => {
640
- api.logger.error(`Relay service crashed: ${err}`);
641
- });
642
- api.logger.info(`StageWhisper relay service started for ${account.label}`);
643
- },
644
-
645
- async stop(_ctx: OpenClawPluginServiceContext): Promise<void> {
646
- state.running = false;
647
- if (heartbeatTimer) {
648
- clearInterval(heartbeatTimer);
649
- heartbeatTimer = null;
650
- }
651
- if (abortController) {
652
- abortController.abort();
653
- abortController = null;
654
- }
655
- api.logger.info("StageWhisper relay service stopped");
656
- },
657
-
658
- getState(): ServiceState {
659
- return { ...state };
660
- },
661
- };
662
- }