@vauxr/openclaw 2026.4.12-1 → 2026.5.31
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 +30 -2
- package/dist/index.js +60 -0
- package/{setup-entry.ts → dist/setup-entry.js} +0 -1
- package/dist/src/api-client.js +61 -0
- package/dist/src/bridge.js +360 -0
- package/dist/src/channel.js +174 -0
- package/dist/src/defaults.js +1 -0
- package/dist/src/tools.js +80 -0
- package/openclaw.plugin.json +19 -0
- package/package.json +21 -9
- package/.github/workflows/publish.yml +0 -102
- package/index.ts +0 -68
- package/src/api-client.ts +0 -76
- package/src/bridge.ts +0 -237
- package/src/channel.ts +0 -107
- package/src/defaults.ts +0 -2
- package/src/tools.ts +0 -102
- package/tsconfig.json +0 -14
package/README.md
CHANGED
|
@@ -59,7 +59,13 @@ These tools use the Vauxr REST API and work in any session, not just vauxr voice
|
|
|
59
59
|
|
|
60
60
|
## Installation
|
|
61
61
|
|
|
62
|
-
Install from
|
|
62
|
+
Install from ClawHub 🦞
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
openclaw plugins install clawhub:@vauxr/openclaw
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or install from the repo directly:
|
|
63
69
|
|
|
64
70
|
```bash
|
|
65
71
|
openclaw plugins install path:/path/to/vauxr-openclaw
|
|
@@ -90,11 +96,32 @@ Then configure in your OpenClaw config:
|
|
|
90
96
|
```
|
|
91
97
|
|
|
92
98
|
- `url` — Vauxr base URL (HTTP)
|
|
93
|
-
- `token` — channel token generated in the Vauxr
|
|
99
|
+
- `token` — channel token generated in the Vauxr web client
|
|
94
100
|
- `voiceSystemPrompt` — optional, appended to the system prompt for all vauxr sessions
|
|
101
|
+
- `alsoAllow` — optional, extra tools to grant vauxr-originated agent runs (see below)
|
|
102
|
+
- `targetAgent` — required if `alsoAllow` is set; the id of the agent that handles vauxr sessions
|
|
95
103
|
|
|
96
104
|
The `allowPromptInjection` hook permission is required for the voice system prompt to take effect.
|
|
97
105
|
|
|
106
|
+
### Granting broader tools to vauxr sessions
|
|
107
|
+
|
|
108
|
+
OpenClaw's runtime treats the internal `webchat` channel more permissively than third-party channels: tools like `gateway` and `nodes` are stripped from vauxr-originated runs even when the agent's profile would otherwise allow them. To restore those tools on vauxr sessions, set `alsoAllow` and `targetAgent`:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"channels": {
|
|
113
|
+
"vauxr": {
|
|
114
|
+
"url": "http://vauxr:8765",
|
|
115
|
+
"token": "your-channel-token",
|
|
116
|
+
"alsoAllow": ["gateway", "nodes"],
|
|
117
|
+
"targetAgent": "nova-cloud"
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
On configure, the plugin writes a `channel:vauxr:*` entry into `agents.list[id=targetAgent].tools.toolsBySender`. The expansion is scoped to vauxr-originated runs only — other channels are unaffected. Be deliberate about what you grant: `gateway` lets the model restart OpenClaw, `nodes` lets it invoke commands on connected hardware nodes.
|
|
124
|
+
|
|
98
125
|
---
|
|
99
126
|
|
|
100
127
|
## Usage
|
|
@@ -147,3 +174,4 @@ Vauxr device (speaker)
|
|
|
147
174
|
## License
|
|
148
175
|
|
|
149
176
|
Vauxr OpenClaw is licensed under the [MIT License](LICENSE).
|
|
177
|
+
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { vauxrPlugin } from "./src/channel.js";
|
|
3
|
+
import { VauxrAPIClient } from "./src/api-client.js";
|
|
4
|
+
import { registerTools } from "./src/tools.js";
|
|
5
|
+
import { VauxrBridge } from "./src/bridge.js";
|
|
6
|
+
import { DEFAULT_VOICE_SYSTEM_PROMPT } from "./src/defaults.js";
|
|
7
|
+
function resolveConfig(api) {
|
|
8
|
+
if (api.pluginConfig && typeof api.pluginConfig === "object" && "url" in api.pluginConfig) {
|
|
9
|
+
return api.pluginConfig;
|
|
10
|
+
}
|
|
11
|
+
const cfg = api.config;
|
|
12
|
+
const channels = cfg.channels;
|
|
13
|
+
return (channels?.vauxr ?? {});
|
|
14
|
+
}
|
|
15
|
+
const entry = defineChannelPluginEntry({
|
|
16
|
+
id: "vauxr",
|
|
17
|
+
name: "Vauxr",
|
|
18
|
+
description: "Vauxr voice device channel plugin for OpenClaw",
|
|
19
|
+
plugin: vauxrPlugin,
|
|
20
|
+
registerFull(api) {
|
|
21
|
+
const config = resolveConfig(api);
|
|
22
|
+
// REST tools — use explicit httpUrl if set, otherwise derive from ws url
|
|
23
|
+
// (vauxr WS is on :8765, HTTP API is on :8080)
|
|
24
|
+
const httpBase = config.httpUrl ?? (config.url ? config.url.replace(/:8765(\/?$)/, ":8080") : "");
|
|
25
|
+
if (!httpBase) {
|
|
26
|
+
// config.url not available yet (early registration) — skip bridge/tools
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const client = new VauxrAPIClient(httpBase, config.token ?? "");
|
|
30
|
+
registerTools(api, client);
|
|
31
|
+
// Construct the WS bridge but DO NOT start it here. `registerFull` is
|
|
32
|
+
// invoked from introspection paths too (e.g. `openclaw doctor`), and
|
|
33
|
+
// starting the bridge here would open a live WebSocket to vauxr during
|
|
34
|
+
// diagnostics. The bridge is started later by `gateway.startAccount`
|
|
35
|
+
// (see channel.ts), which only fires when the gateway is actually
|
|
36
|
+
// bringing the channel up for runtime use. The globalThis stash bridges
|
|
37
|
+
// the two scopes because `startAccount` doesn't have access to `api`.
|
|
38
|
+
//
|
|
39
|
+
// The single-bridge guard remains here so multiple `registerFull`
|
|
40
|
+
// invocations in the same process don't reconstruct the bridge — they'd
|
|
41
|
+
// contend for the single active channel slot in vauxr otherwise.
|
|
42
|
+
const g = globalThis;
|
|
43
|
+
if (!g.__vauxrBridge) {
|
|
44
|
+
g.__vauxrBridge = new VauxrBridge(api, config);
|
|
45
|
+
}
|
|
46
|
+
// Voice system prompt injection for vauxr sessions. Match both the bare
|
|
47
|
+
// form (`vauxr:<deviceId>`) used by the old subagent.run path and the
|
|
48
|
+
// fully-prefixed form (`agent:<agentId>:vauxr:<deviceId>`) used by the
|
|
49
|
+
// current channel.turn.run path. Either form means it's a vauxr turn.
|
|
50
|
+
api.on("before_prompt_build", (_event, ctx) => {
|
|
51
|
+
if (ctx.sessionKey && /(?:^|:)vauxr:/.test(ctx.sessionKey)) {
|
|
52
|
+
return {
|
|
53
|
+
appendSystemContext: config.voiceSystemPrompt ?? DEFAULT_VOICE_SYSTEM_PROMPT,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
export default entry;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export class VauxrAPIClient {
|
|
2
|
+
baseUrl;
|
|
3
|
+
token;
|
|
4
|
+
constructor(baseUrl, token) {
|
|
5
|
+
this.baseUrl = baseUrl;
|
|
6
|
+
this.token = token;
|
|
7
|
+
}
|
|
8
|
+
async request(method, path, body) {
|
|
9
|
+
const url = `${this.baseUrl}${path}`;
|
|
10
|
+
const headers = {
|
|
11
|
+
Authorization: `Bearer ${this.token}`,
|
|
12
|
+
"Content-Type": "application/json",
|
|
13
|
+
};
|
|
14
|
+
const res = await fetch(url, {
|
|
15
|
+
method,
|
|
16
|
+
headers,
|
|
17
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
let message = `HTTP ${res.status}`;
|
|
21
|
+
try {
|
|
22
|
+
const errorBody = (await res.json());
|
|
23
|
+
const detail = errorBody.error ?? errorBody.message;
|
|
24
|
+
if (typeof detail === "string") {
|
|
25
|
+
message = detail;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// response body wasn't JSON — keep generic message
|
|
30
|
+
}
|
|
31
|
+
if (res.status === 401) {
|
|
32
|
+
throw new Error(`Unauthorized: ${message}`);
|
|
33
|
+
}
|
|
34
|
+
if (res.status === 404) {
|
|
35
|
+
throw new Error(`Device not found: ${message}`);
|
|
36
|
+
}
|
|
37
|
+
if (res.status === 409) {
|
|
38
|
+
throw new Error(`Device busy: ${message}`);
|
|
39
|
+
}
|
|
40
|
+
throw new Error(message);
|
|
41
|
+
}
|
|
42
|
+
const text = await res.text();
|
|
43
|
+
if (!text)
|
|
44
|
+
return undefined;
|
|
45
|
+
return JSON.parse(text);
|
|
46
|
+
}
|
|
47
|
+
async listDevices() {
|
|
48
|
+
return this.request("GET", "/api/devices");
|
|
49
|
+
}
|
|
50
|
+
async announce(deviceId, text) {
|
|
51
|
+
await this.request("POST", `/api/devices/${encodeURIComponent(deviceId)}/announce`, {
|
|
52
|
+
text,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async command(deviceId, command, params) {
|
|
56
|
+
await this.request("POST", `/api/devices/${encodeURIComponent(deviceId)}/command`, {
|
|
57
|
+
command,
|
|
58
|
+
params,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
const INITIAL_RECONNECT_MS = 1000;
|
|
3
|
+
const MAX_RECONNECT_MS = 30000;
|
|
4
|
+
export class VauxrBridge {
|
|
5
|
+
api;
|
|
6
|
+
config;
|
|
7
|
+
ws = null;
|
|
8
|
+
reconnectMs = INITIAL_RECONNECT_MS;
|
|
9
|
+
reconnectTimer = null;
|
|
10
|
+
unsubscribeEvents = null;
|
|
11
|
+
started = false;
|
|
12
|
+
// Inflight turns keyed by deviceId. channel.turn.run doesn't surface an SDK
|
|
13
|
+
// runId to the caller (unlike the old subagent.run path), so dispatch-time
|
|
14
|
+
// bookkeeping is keyed by deviceId; we then latch onto the SDK runId on the
|
|
15
|
+
// first event that arrives carrying a sessionKey (typically lifecycle.start)
|
|
16
|
+
// and use that runId for all subsequent events from the same run. Most
|
|
17
|
+
// event streams (assistant/tool/item) do NOT carry sessionKey — only the
|
|
18
|
+
// lifecycle stream does — so runId-based correlation is load-bearing once
|
|
19
|
+
// we've latched. One inflight turn per device at a time.
|
|
20
|
+
activeRuns = new Map(); // deviceId → turn
|
|
21
|
+
runIdToTurn = new Map(); // sdkRunId → turn
|
|
22
|
+
// Per-device silent-reply sentinel state. "NO_REPLY" often arrives split
|
|
23
|
+
// across streaming deltas (e.g. "NO" then "_REPLY"), so we buffer until
|
|
24
|
+
// the accumulated text either matches the sentinel (suppress the whole
|
|
25
|
+
// run) or diverges (flush and pass through).
|
|
26
|
+
sentinelBuffer = new Map(); // deviceId → held delta text
|
|
27
|
+
sentinelMode = new Map();
|
|
28
|
+
wsUrl;
|
|
29
|
+
constructor(api, config) {
|
|
30
|
+
this.api = api;
|
|
31
|
+
this.config = config;
|
|
32
|
+
// Derive WS URL from HTTP base URL
|
|
33
|
+
const base = config.url.replace(/\/$/, "");
|
|
34
|
+
this.wsUrl = base.replace(/^http/, "ws") + "/channel";
|
|
35
|
+
}
|
|
36
|
+
start() {
|
|
37
|
+
if (this.started)
|
|
38
|
+
return;
|
|
39
|
+
this.started = true;
|
|
40
|
+
this.connect();
|
|
41
|
+
this.subscribeAgentEvents();
|
|
42
|
+
}
|
|
43
|
+
stop() {
|
|
44
|
+
if (!this.started)
|
|
45
|
+
return;
|
|
46
|
+
this.started = false;
|
|
47
|
+
if (this.reconnectTimer) {
|
|
48
|
+
clearTimeout(this.reconnectTimer);
|
|
49
|
+
this.reconnectTimer = null;
|
|
50
|
+
}
|
|
51
|
+
if (this.unsubscribeEvents) {
|
|
52
|
+
this.unsubscribeEvents();
|
|
53
|
+
this.unsubscribeEvents = null;
|
|
54
|
+
}
|
|
55
|
+
if (this.ws) {
|
|
56
|
+
this.ws.close();
|
|
57
|
+
this.ws = null;
|
|
58
|
+
}
|
|
59
|
+
// Reset the backoff so a subsequent stop/start cycle (e.g. channel
|
|
60
|
+
// aborts then restarts) begins reconnecting at INITIAL_RECONNECT_MS
|
|
61
|
+
// instead of inheriting whatever escalated delay the previous run
|
|
62
|
+
// had accumulated.
|
|
63
|
+
this.reconnectMs = INITIAL_RECONNECT_MS;
|
|
64
|
+
}
|
|
65
|
+
connect() {
|
|
66
|
+
this.api.logger.debug?.(`[vauxr-bridge] Connecting to vauxr: ${this.wsUrl}`);
|
|
67
|
+
const ws = new WebSocket(this.wsUrl);
|
|
68
|
+
this.ws = ws;
|
|
69
|
+
ws.on("open", () => {
|
|
70
|
+
this.api.logger.debug?.("[vauxr-bridge] Connected to vauxr");
|
|
71
|
+
this.reconnectMs = INITIAL_RECONNECT_MS;
|
|
72
|
+
// Authenticate with channel token
|
|
73
|
+
if (this.config.token) {
|
|
74
|
+
this.send({ type: "channel.auth", token: this.config.token });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
ws.on("message", (data) => {
|
|
78
|
+
try {
|
|
79
|
+
const frame = JSON.parse(String(data));
|
|
80
|
+
this.handleFrame(frame);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
this.api.logger.warn(`[vauxr-bridge] Failed to parse inbound frame: ${String(err)}`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
ws.on("close", () => {
|
|
87
|
+
this.api.logger.debug?.("[vauxr-bridge] Disconnected from vauxr");
|
|
88
|
+
// Identity check: a stop() during the close-event async delay can be
|
|
89
|
+
// followed by another start() that opens a fresh ws. If we cleared
|
|
90
|
+
// `this.ws` blindly here we'd wipe the new socket's reference and
|
|
91
|
+
// also fire a duplicate reconnect. Only act if we're still the
|
|
92
|
+
// bridge's current ws.
|
|
93
|
+
if (this.ws !== ws)
|
|
94
|
+
return;
|
|
95
|
+
this.ws = null;
|
|
96
|
+
if (this.started)
|
|
97
|
+
this.scheduleReconnect();
|
|
98
|
+
});
|
|
99
|
+
ws.on("error", (err) => {
|
|
100
|
+
this.api.logger.warn(`[vauxr-bridge] WS error: ${String(err)}`);
|
|
101
|
+
// 'close' event will fire after this — reconnect handled there
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
scheduleReconnect() {
|
|
105
|
+
if (this.reconnectTimer)
|
|
106
|
+
return;
|
|
107
|
+
this.api.logger.debug?.(`[vauxr-bridge] Reconnecting in ${this.reconnectMs}ms`);
|
|
108
|
+
this.reconnectTimer = setTimeout(() => {
|
|
109
|
+
this.reconnectTimer = null;
|
|
110
|
+
this.connect();
|
|
111
|
+
}, this.reconnectMs);
|
|
112
|
+
this.reconnectMs = Math.min(this.reconnectMs * 2, MAX_RECONNECT_MS);
|
|
113
|
+
}
|
|
114
|
+
handleFrame(frame) {
|
|
115
|
+
switch (frame.type) {
|
|
116
|
+
case "channel.transcript":
|
|
117
|
+
if (frame.deviceId && frame.text) {
|
|
118
|
+
void this.dispatchTranscript(frame.deviceId, frame.text);
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
case "channel.device_state":
|
|
122
|
+
this.api.logger.info(`[vauxr-bridge] Device ${frame.deviceId ?? "unknown"}: ${frame.state ?? "unknown"}`);
|
|
123
|
+
break;
|
|
124
|
+
case "channel.ready":
|
|
125
|
+
this.api.logger.debug?.("[vauxr-bridge] Channel authenticated");
|
|
126
|
+
break;
|
|
127
|
+
case "error":
|
|
128
|
+
this.api.logger.warn(`[vauxr-bridge] Error from vauxr: ${frame.code ?? "UNKNOWN"} — ${frame.message ?? "no details"}`);
|
|
129
|
+
break;
|
|
130
|
+
default:
|
|
131
|
+
this.api.logger.warn(`[vauxr-bridge] Unknown frame type: ${String(frame.type)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async dispatchTranscript(deviceId, text) {
|
|
135
|
+
const cfg = this.api.config;
|
|
136
|
+
// Construct the sessionKey in the same form the old subagent.run path
|
|
137
|
+
// ended up producing after openclaw's internal normalization
|
|
138
|
+
// (`agent:<agentId>:vauxr:<deviceId>`). channel.turn.run does NOT apply
|
|
139
|
+
// that same normalization to routeSessionKey — it stores under whatever
|
|
140
|
+
// string we pass — so we have to build the full form ourselves to
|
|
141
|
+
// preserve session continuity with prior turns / restarts.
|
|
142
|
+
const agentId = resolveTargetAgentId(cfg);
|
|
143
|
+
const sessionKey = `agent:${agentId}:vauxr:${deviceId}`;
|
|
144
|
+
// Protocol-level runId sent to vauxr-ws in response frames so it can
|
|
145
|
+
// correlate delta/end/error chunks back to this transcript.
|
|
146
|
+
const protocolRunId = crypto.randomUUID();
|
|
147
|
+
const turnId = `vauxr-${deviceId}-${Date.now()}`;
|
|
148
|
+
this.api.logger.info(`[vauxr-bridge] Dispatching transcript for ${sessionKey} (runId=${protocolRunId}): "${text}"`);
|
|
149
|
+
// Register before dispatch so onAgentEvent can correlate any event the
|
|
150
|
+
// agent runtime emits for this turn back to the originating device.
|
|
151
|
+
this.activeRuns.set(deviceId, { deviceId, protocolRunId });
|
|
152
|
+
const storePath = this.api.runtime.channel.session.resolveStorePath(cfg.session?.store);
|
|
153
|
+
// Minimal inbound context. Voice channels don't carry replies, media,
|
|
154
|
+
// mentions, forwards, etc. — most MsgContext fields stay undefined.
|
|
155
|
+
const ctxPayload = {
|
|
156
|
+
Body: text,
|
|
157
|
+
BodyForAgent: text,
|
|
158
|
+
From: deviceId,
|
|
159
|
+
SenderId: deviceId,
|
|
160
|
+
SenderName: deviceId,
|
|
161
|
+
SessionKey: sessionKey,
|
|
162
|
+
Provider: "vauxr",
|
|
163
|
+
Surface: "vauxr",
|
|
164
|
+
Timestamp: Date.now(),
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
// OpenClaw 2026.5.28 renamed `runtime.channel.turn` to
|
|
168
|
+
// `runtime.channel.inbound` (pure rename — same signature, same
|
|
169
|
+
// ChannelInboundEventRunnerParams shape as the prior
|
|
170
|
+
// RunChannelTurnParams). Earlier vauxr-openclaw releases that
|
|
171
|
+
// referenced `.turn.run` will throw `Cannot read properties of
|
|
172
|
+
// undefined (reading 'run')` on gateways 2026.5.28+.
|
|
173
|
+
await this.api.runtime.channel.inbound.run({
|
|
174
|
+
channel: "vauxr",
|
|
175
|
+
raw: { deviceId, text },
|
|
176
|
+
adapter: {
|
|
177
|
+
ingest: () => ({
|
|
178
|
+
id: turnId,
|
|
179
|
+
timestamp: Date.now(),
|
|
180
|
+
rawText: text,
|
|
181
|
+
raw: { deviceId, text },
|
|
182
|
+
}),
|
|
183
|
+
classify: () => ({ kind: "message", canStartAgentTurn: true }),
|
|
184
|
+
resolveTurn: () => ({
|
|
185
|
+
channel: "vauxr",
|
|
186
|
+
routeSessionKey: sessionKey,
|
|
187
|
+
storePath,
|
|
188
|
+
// FinalizedMsgContext has ~80 optional fields; ours is a minimal
|
|
189
|
+
// voice-channel subset. The kernel reads what it needs and ignores
|
|
190
|
+
// the rest, so an unsafe cast is acceptable here.
|
|
191
|
+
ctxPayload: ctxPayload,
|
|
192
|
+
recordInboundSession: this.api.runtime.channel.session.recordInboundSession,
|
|
193
|
+
runDispatch: async () => {
|
|
194
|
+
// Outbound delivery flows through the existing onAgentEvent
|
|
195
|
+
// delta tap (subscribeAgentEvents) for lowest TTS latency — see
|
|
196
|
+
// spec decision D2. The reply dispatcher's `deliver` is a no-op
|
|
197
|
+
// here; the dispatcher exists only to satisfy the channel-turn
|
|
198
|
+
// contract and to give the dispatch-from-config pipeline a sink
|
|
199
|
+
// to write into.
|
|
200
|
+
const { dispatcher } = this.api.runtime.channel.reply.createReplyDispatcherWithTyping({
|
|
201
|
+
deliver: async () => undefined,
|
|
202
|
+
});
|
|
203
|
+
return await this.api.runtime.channel.reply.dispatchReplyFromConfig({
|
|
204
|
+
ctx: ctxPayload,
|
|
205
|
+
cfg,
|
|
206
|
+
dispatcher,
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
}),
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
this.api.logger.warn(`[vauxr-bridge] Failed to dispatch transcript for ${sessionKey}: ${String(err)}`);
|
|
215
|
+
this.send({
|
|
216
|
+
type: "channel.response.error",
|
|
217
|
+
deviceId,
|
|
218
|
+
runId: protocolRunId,
|
|
219
|
+
message: String(err),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
// channel.turn.run awaits the full turn (including the agent run inside
|
|
224
|
+
// runDispatch), so by this point all events have fired and the turn is
|
|
225
|
+
// done. Safe to clean up correlation state here. runIdToTurn entries
|
|
226
|
+
// for this device are cleaned in the lifecycle.end branch of the event
|
|
227
|
+
// handler — best-effort sweep here in case lifecycle.end never fired.
|
|
228
|
+
this.activeRuns.delete(deviceId);
|
|
229
|
+
this.sentinelBuffer.delete(deviceId);
|
|
230
|
+
this.sentinelMode.delete(deviceId);
|
|
231
|
+
for (const [rid, turn] of this.runIdToTurn) {
|
|
232
|
+
if (turn.deviceId === deviceId)
|
|
233
|
+
this.runIdToTurn.delete(rid);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
subscribeAgentEvents() {
|
|
238
|
+
this.unsubscribeEvents = this.api.runtime.events.onAgentEvent((event) => {
|
|
239
|
+
// Two-stage correlation:
|
|
240
|
+
// 1. If the event carries a sessionKey (lifecycle events do, most
|
|
241
|
+
// others don't), parse the deviceId and look up the inflight turn
|
|
242
|
+
// we registered in dispatchTranscript. Cache the SDK runId so
|
|
243
|
+
// subsequent sessionKey-less events from the same run can be
|
|
244
|
+
// matched by runId alone.
|
|
245
|
+
// 2. Otherwise, look up by event.runId — populated by step (1) for
|
|
246
|
+
// this turn's prior lifecycle event.
|
|
247
|
+
// pi-embedded's first lifecycle.start carries sessionKey and arrives
|
|
248
|
+
// well before any assistant deltas, so the latch is always primed
|
|
249
|
+
// before delta events need to route.
|
|
250
|
+
let active;
|
|
251
|
+
const sk = event.sessionKey;
|
|
252
|
+
if (sk) {
|
|
253
|
+
const m = sk.match(/(?:^|:)vauxr:([^:]+)/);
|
|
254
|
+
if (m) {
|
|
255
|
+
active = this.activeRuns.get(m[1]);
|
|
256
|
+
if (active)
|
|
257
|
+
this.runIdToTurn.set(event.runId, active);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (!active)
|
|
261
|
+
active = this.runIdToTurn.get(event.runId);
|
|
262
|
+
if (!active)
|
|
263
|
+
return;
|
|
264
|
+
const { deviceId, protocolRunId: runId } = active;
|
|
265
|
+
if (event.stream === "assistant") {
|
|
266
|
+
// Only forward the incremental delta. data.text is the running
|
|
267
|
+
// accumulated reply — forwarding it as a delta would re-send
|
|
268
|
+
// the entire reply on top of the deltas we've already sent,
|
|
269
|
+
// duplicating it in TTS. The OpenClaw runtime emits at least
|
|
270
|
+
// one final assistant event per run with `{ text }` only (no
|
|
271
|
+
// `delta`); those carry no new content and must be dropped.
|
|
272
|
+
const delta = event.data["delta"];
|
|
273
|
+
if (typeof delta !== "string" || delta.length === 0)
|
|
274
|
+
return;
|
|
275
|
+
const mode = this.sentinelMode.get(deviceId);
|
|
276
|
+
if (mode === "suppressed")
|
|
277
|
+
return;
|
|
278
|
+
if (mode === "passthrough") {
|
|
279
|
+
this.send({ type: "channel.response.delta", deviceId, runId, text: delta });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// Buffering: hold deltas while the accumulated text could
|
|
283
|
+
// still complete the silent-reply sentinel.
|
|
284
|
+
const SENTINEL = "NO_REPLY";
|
|
285
|
+
const buffered = (this.sentinelBuffer.get(deviceId) ?? "") + delta;
|
|
286
|
+
const normalized = buffered.trim().toUpperCase();
|
|
287
|
+
if (normalized === SENTINEL) {
|
|
288
|
+
// Confirmed sentinel — suppress everything for this run.
|
|
289
|
+
this.sentinelMode.set(deviceId, "suppressed");
|
|
290
|
+
this.sentinelBuffer.delete(deviceId);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (SENTINEL.startsWith(normalized)) {
|
|
294
|
+
// Could still become the sentinel — keep holding.
|
|
295
|
+
this.sentinelBuffer.set(deviceId, buffered);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
// Diverged from sentinel — flush the held text and pass through
|
|
299
|
+
// the rest of the run.
|
|
300
|
+
this.sentinelMode.set(deviceId, "passthrough");
|
|
301
|
+
this.sentinelBuffer.delete(deviceId);
|
|
302
|
+
this.send({ type: "channel.response.delta", deviceId, runId, text: buffered });
|
|
303
|
+
}
|
|
304
|
+
// Signal end-of-turn to vauxr-ws so TTS finalizes. dispatchTranscript's
|
|
305
|
+
// finally clears activeRuns once channel.turn.run returns; we don't
|
|
306
|
+
// clean up here to avoid racing that path.
|
|
307
|
+
if (event.stream === "lifecycle" && event.data["phase"] === "end") {
|
|
308
|
+
this.send({
|
|
309
|
+
type: "channel.response.end",
|
|
310
|
+
deviceId,
|
|
311
|
+
runId,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (event.stream === "error") {
|
|
315
|
+
this.api.logger.warn(`[vauxr-bridge] Agent error for device ${deviceId}: ${JSON.stringify(event.data)}`);
|
|
316
|
+
this.send({
|
|
317
|
+
type: "channel.response.error",
|
|
318
|
+
deviceId,
|
|
319
|
+
runId,
|
|
320
|
+
message: String(event.data["message"] ?? "Agent error"),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
send(frame) {
|
|
326
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
327
|
+
this.ws.send(JSON.stringify(frame));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Resolve the agent id that vauxr turns should route to.
|
|
333
|
+
*
|
|
334
|
+
* Preference order:
|
|
335
|
+
* 1. channels.vauxr.targetAgent (explicit operator config)
|
|
336
|
+
* 2. plugins.entries.vauxr.config.targetAgent (alternate config slot)
|
|
337
|
+
* 3. agents.list[].default === true
|
|
338
|
+
* 4. agents.list[0].id (first declared agent)
|
|
339
|
+
* 5. "default" sentinel (last resort — produces an obviously-wrong key the
|
|
340
|
+
* operator can spot in logs)
|
|
341
|
+
*/
|
|
342
|
+
function resolveTargetAgentId(cfg) {
|
|
343
|
+
const raw = cfg;
|
|
344
|
+
const fromChannels = raw.channels?.vauxr;
|
|
345
|
+
if (fromChannels?.targetAgent)
|
|
346
|
+
return fromChannels.targetAgent;
|
|
347
|
+
const fromPlugins = raw.plugins?.entries?.vauxr;
|
|
348
|
+
if (fromPlugins?.config?.targetAgent)
|
|
349
|
+
return fromPlugins.config.targetAgent;
|
|
350
|
+
const agents = raw.agents
|
|
351
|
+
?.list;
|
|
352
|
+
if (Array.isArray(agents)) {
|
|
353
|
+
const defaultAgent = agents.find((a) => a.default && a.id);
|
|
354
|
+
if (defaultAgent?.id)
|
|
355
|
+
return defaultAgent.id;
|
|
356
|
+
if (agents[0]?.id)
|
|
357
|
+
return agents[0].id;
|
|
358
|
+
}
|
|
359
|
+
return "default";
|
|
360
|
+
}
|