@mutirolabs/openclaw-brain 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/LICENSE +15 -0
- package/README.md +266 -0
- package/docs/guides/manage-allowlist.md +184 -0
- package/docs/guides/use-openclaw-as-brain.md +401 -0
- package/index.ts +21 -0
- package/openclaw.plugin.json +47 -0
- package/package.json +79 -0
- package/src/actions.ts +53 -0
- package/src/agent-tools.ts +640 -0
- package/src/bridge-client.ts +236 -0
- package/src/bridge-messages.ts +307 -0
- package/src/bridge-protocol.ts +77 -0
- package/src/bridge-session.ts +433 -0
- package/src/channel.runtime.ts +454 -0
- package/src/channel.ts +130 -0
- package/src/config.ts +100 -0
- package/src/inbound.ts +151 -0
- package/src/live-snapshot.ts +210 -0
- package/src/outbound.ts +326 -0
- package/src/setup-surface.ts +281 -0
- package/src/signal-forwarder.ts +153 -0
package/src/inbound.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Translates bridge observed messages into the inbound delivery the OpenClaw
|
|
2
|
+
// gateway expects from a channel plugin. The runtime installs a single inbound
|
|
3
|
+
// delivery callback during plugin startup; this module's job is to shape each
|
|
4
|
+
// observed bridge envelope into that callback's input.
|
|
5
|
+
|
|
6
|
+
import { saveMediaBuffer } from "openclaw/plugin-sdk/browser-setup-tools";
|
|
7
|
+
|
|
8
|
+
import type { ObservedTurn } from "./bridge-protocol.js";
|
|
9
|
+
import { buildObservedTurn, isSelfEventMessage } from "./bridge-messages.js";
|
|
10
|
+
|
|
11
|
+
export type InboundMessage = {
|
|
12
|
+
channelId: "mutiro";
|
|
13
|
+
accountId: string;
|
|
14
|
+
conversationId: string;
|
|
15
|
+
messageId: string;
|
|
16
|
+
replyToMessageId?: string;
|
|
17
|
+
senderUsername: string;
|
|
18
|
+
text: string;
|
|
19
|
+
rawMessage: unknown;
|
|
20
|
+
attachmentContext?: string;
|
|
21
|
+
mediaPaths?: string[];
|
|
22
|
+
mediaTypes?: string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type InboundDeliver = (message: InboundMessage) => Promise<void> | void;
|
|
26
|
+
|
|
27
|
+
export type InboundRoute = {
|
|
28
|
+
accountId: string;
|
|
29
|
+
agentUsername: string;
|
|
30
|
+
deliver: InboundDeliver;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type BridgeImage = {
|
|
34
|
+
data?: string;
|
|
35
|
+
mime_type?: string;
|
|
36
|
+
filename?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The bridge delivers image attachments inline as base64 under
|
|
41
|
+
* `envelope.payload.images`. We persist each one into OpenClaw's media
|
|
42
|
+
* directory via `saveMediaBuffer`, which stages under `~/.openclaw/media/`
|
|
43
|
+
* — the only root OpenClaw's sandbox staging policy accepts. Writing to
|
|
44
|
+
* `os.tmpdir()` silently fails staging and leaves the agent with only a
|
|
45
|
+
* text path reference, so the bytes never reach the model.
|
|
46
|
+
*/
|
|
47
|
+
const persistInlineImages = async (
|
|
48
|
+
images: BridgeImage[] | undefined,
|
|
49
|
+
): Promise<{ paths: string[]; types: string[] }> => {
|
|
50
|
+
if (!Array.isArray(images) || images.length === 0) {
|
|
51
|
+
return { paths: [], types: [] };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const paths: string[] = [];
|
|
55
|
+
const types: string[] = [];
|
|
56
|
+
for (const entry of images) {
|
|
57
|
+
if (!entry?.data) continue;
|
|
58
|
+
try {
|
|
59
|
+
const saved = await saveMediaBuffer(
|
|
60
|
+
Buffer.from(entry.data, "base64"),
|
|
61
|
+
entry.mime_type,
|
|
62
|
+
"inbound",
|
|
63
|
+
undefined,
|
|
64
|
+
entry.filename,
|
|
65
|
+
);
|
|
66
|
+
paths.push(saved.path);
|
|
67
|
+
types.push(saved.contentType ?? entry.mime_type ?? "application/octet-stream");
|
|
68
|
+
} catch {
|
|
69
|
+
// Best-effort: skip a single bad attachment rather than aborting the turn.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { paths, types };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Shapes a bridge envelope into the OpenClaw inbound message the gateway
|
|
77
|
+
* delivers to core. Returns the observed turn it extracted (useful for the
|
|
78
|
+
* caller to key session state) or null when the envelope was self-authored or
|
|
79
|
+
* did not carry a deliverable message.
|
|
80
|
+
*/
|
|
81
|
+
export const deliverObservedEnvelope = async (
|
|
82
|
+
envelope: {
|
|
83
|
+
type?: string;
|
|
84
|
+
payload?: {
|
|
85
|
+
message?: unknown;
|
|
86
|
+
attachment_context?: string;
|
|
87
|
+
images?: BridgeImage[];
|
|
88
|
+
};
|
|
89
|
+
} & Parameters<typeof buildObservedTurn>[0],
|
|
90
|
+
route: InboundRoute,
|
|
91
|
+
): Promise<ObservedTurn | null> => {
|
|
92
|
+
if (envelope.type === "event.message" && isSelfEventMessage(envelope, route.agentUsername)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Reactions and other bare events are now delivered to OpenClaw like any
|
|
97
|
+
// other observation. The agent may choose to stay silent, which today
|
|
98
|
+
// surfaces "Agent couldn't generate a response" — acceptable trade-off
|
|
99
|
+
// for letting the agent actually see reactions happen. If OpenClaw grows
|
|
100
|
+
// a "silent turn is ok" dispatch option later, filter these through that.
|
|
101
|
+
const turn = buildObservedTurn(envelope);
|
|
102
|
+
if (!turn) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { paths, types } = await persistInlineImages(envelope.payload?.images);
|
|
107
|
+
|
|
108
|
+
// attachment_context is the host's narrative of downloaded attachments:
|
|
109
|
+
// [SYSTEM: Downloaded 1 file(s) to your workspace:
|
|
110
|
+
// • nf-2976.pdf → /Users/.../Downloads/nf-2976.pdf (PDF, 450 KB)]
|
|
111
|
+
// For PDFs/docs this is the ONLY pointer the agent gets — it needs the
|
|
112
|
+
// path to invoke `read` or a doc-extraction tool. We earlier stripped
|
|
113
|
+
// this string because the host's image probe produced noisy "0x0 pixels"
|
|
114
|
+
// metadata that fooled vision models. Image turns now route bytes
|
|
115
|
+
// through MediaPaths instead, so attachment_context is pure signal for
|
|
116
|
+
// non-image attachments. Append only when the message carries file-type
|
|
117
|
+
// parts; skip for image-only or text-only turns.
|
|
118
|
+
const hasFileParts = extractHasFileParts(
|
|
119
|
+
envelope.payload?.message as { parts?: Array<{ type?: string }> } | undefined,
|
|
120
|
+
);
|
|
121
|
+
const contextText = (envelope.payload?.attachment_context ?? "").trim();
|
|
122
|
+
const bodyText =
|
|
123
|
+
hasFileParts && contextText
|
|
124
|
+
? turn.text
|
|
125
|
+
? `${turn.text}\n\n${contextText}`
|
|
126
|
+
: contextText
|
|
127
|
+
: turn.text;
|
|
128
|
+
|
|
129
|
+
await route.deliver({
|
|
130
|
+
channelId: "mutiro",
|
|
131
|
+
accountId: route.accountId,
|
|
132
|
+
conversationId: turn.conversationId,
|
|
133
|
+
messageId: turn.messageId,
|
|
134
|
+
replyToMessageId: turn.replyToMessageId,
|
|
135
|
+
senderUsername: turn.senderUsername,
|
|
136
|
+
text: bodyText,
|
|
137
|
+
rawMessage: envelope.payload?.message,
|
|
138
|
+
attachmentContext: envelope.payload?.attachment_context,
|
|
139
|
+
...(paths.length > 0 ? { mediaPaths: paths, mediaTypes: types } : {}),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return turn;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const extractHasFileParts = (
|
|
146
|
+
message: { parts?: Array<{ type?: string }> } | undefined,
|
|
147
|
+
): boolean => {
|
|
148
|
+
if (!message) return false;
|
|
149
|
+
const parts = Array.isArray(message.parts) ? message.parts : [];
|
|
150
|
+
return parts.some((part) => part?.type === "file");
|
|
151
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Builds the live-handoff snapshot the host requests when a voice call
|
|
2
|
+
// starts. This is the brain's chance to hand OpenClaw's agent persona,
|
|
3
|
+
// the real session transcript, and channel-owned tool hints to Mutiro's
|
|
4
|
+
// live voice model so the call doesn't start with a generic LLM.
|
|
5
|
+
//
|
|
6
|
+
// The three fields returned map 1:1 to ChatBridgeSessionSnapshotResult:
|
|
7
|
+
// system_instruction ← agent's systemPromptOverride (or a minimal header)
|
|
8
|
+
// recent_messages ← recent turns parsed from OpenClaw's session jsonl
|
|
9
|
+
// tool_hints ← channel-owned agent tools advertised by the plugin
|
|
10
|
+
|
|
11
|
+
import { promises as fs } from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
|
|
14
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
15
|
+
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
|
16
|
+
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
|
17
|
+
|
|
18
|
+
import type { LiveSnapshot, LiveToolHint } from "./bridge-session.js";
|
|
19
|
+
|
|
20
|
+
const MAX_RECENT_TRANSCRIPT_TURNS = 30;
|
|
21
|
+
|
|
22
|
+
type AgentConfigLike = {
|
|
23
|
+
name?: string;
|
|
24
|
+
systemPromptOverride?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type SessionEntryLike = {
|
|
28
|
+
sessionId?: string;
|
|
29
|
+
sessionFile?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type SessionStoreRecord = Record<string, SessionEntryLike>;
|
|
33
|
+
|
|
34
|
+
const resolveAgentConfig = (
|
|
35
|
+
cfg: OpenClawConfig,
|
|
36
|
+
agentId: string,
|
|
37
|
+
): AgentConfigLike | undefined => {
|
|
38
|
+
const agents = (cfg as { agents?: { agents?: Record<string, AgentConfigLike> } }).agents;
|
|
39
|
+
return agents?.agents?.[agentId];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const buildSystemInstruction = (params: {
|
|
43
|
+
agentId: string;
|
|
44
|
+
agentConfig: AgentConfigLike | undefined;
|
|
45
|
+
agentUsername: string;
|
|
46
|
+
callerUsername?: string;
|
|
47
|
+
}): string => {
|
|
48
|
+
const override = params.agentConfig?.systemPromptOverride?.trim();
|
|
49
|
+
if (override) {
|
|
50
|
+
return override;
|
|
51
|
+
}
|
|
52
|
+
const displayName = params.agentConfig?.name?.trim() || params.agentId;
|
|
53
|
+
const callerSuffix = params.callerUsername ? ` Caller: @${params.callerUsername}.` : "";
|
|
54
|
+
return [
|
|
55
|
+
`You are ${displayName}, the same agent this user speaks with in chat.`,
|
|
56
|
+
"You are now speaking live over voice.",
|
|
57
|
+
"Stay concise, conversational, and in the agent's established persona.",
|
|
58
|
+
`Agent identity: @${params.agentUsername}.${callerSuffix}`,
|
|
59
|
+
].join(" ");
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type JsonlLine = {
|
|
63
|
+
type?: string;
|
|
64
|
+
message?: {
|
|
65
|
+
role?: string;
|
|
66
|
+
content?: Array<{ type?: string; text?: string; thinking?: string }>;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type JsonlContent = NonNullable<NonNullable<JsonlLine["message"]>["content"]>;
|
|
71
|
+
|
|
72
|
+
const extractTextFromContent = (content: JsonlContent | undefined): string => {
|
|
73
|
+
if (!Array.isArray(content)) return "";
|
|
74
|
+
const parts: string[] = [];
|
|
75
|
+
for (const entry of content) {
|
|
76
|
+
if (!entry || typeof entry !== "object") continue;
|
|
77
|
+
if (entry.type === "text" && typeof entry.text === "string") {
|
|
78
|
+
parts.push(entry.text);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return parts.join("").trim();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const readRecentTranscriptTurns = async (
|
|
85
|
+
sessionFile: string,
|
|
86
|
+
conversationId: string,
|
|
87
|
+
agentUsername: string,
|
|
88
|
+
): Promise<unknown[]> => {
|
|
89
|
+
const raw = await fs.readFile(sessionFile, "utf8");
|
|
90
|
+
const lines = raw.split(/\r?\n/);
|
|
91
|
+
const turns: unknown[] = [];
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
const trimmed = line.trim();
|
|
94
|
+
if (!trimmed) continue;
|
|
95
|
+
let parsed: JsonlLine;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(trimmed) as JsonlLine;
|
|
98
|
+
} catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (parsed.type !== "message") continue;
|
|
102
|
+
const role = parsed.message?.role;
|
|
103
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
104
|
+
const text = extractTextFromContent(parsed.message?.content);
|
|
105
|
+
if (!text) continue;
|
|
106
|
+
const fromUsername = role === "assistant" ? agentUsername : "user";
|
|
107
|
+
turns.push({
|
|
108
|
+
id: `transcript-${turns.length}`,
|
|
109
|
+
conversation_id: conversationId,
|
|
110
|
+
from: { username: fromUsername },
|
|
111
|
+
text,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return turns.slice(-MAX_RECENT_TRANSCRIPT_TURNS);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const readRecentMessagesFromStore = async (params: {
|
|
118
|
+
cfg: OpenClawConfig;
|
|
119
|
+
agentId: string;
|
|
120
|
+
sessionKey: string;
|
|
121
|
+
conversationId: string;
|
|
122
|
+
agentUsername: string;
|
|
123
|
+
}): Promise<unknown[] | undefined> => {
|
|
124
|
+
try {
|
|
125
|
+
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId });
|
|
126
|
+
const { loadSessionStore } = await import("openclaw/plugin-sdk/config-runtime");
|
|
127
|
+
const store = loadSessionStore(storePath) as SessionStoreRecord;
|
|
128
|
+
const entry = store[params.sessionKey];
|
|
129
|
+
if (!entry?.sessionId) return undefined;
|
|
130
|
+
const sessionFile =
|
|
131
|
+
entry.sessionFile && entry.sessionFile.trim()
|
|
132
|
+
? entry.sessionFile
|
|
133
|
+
: path.join(path.dirname(storePath), `${entry.sessionId}.jsonl`);
|
|
134
|
+
return await readRecentTranscriptTurns(sessionFile, params.conversationId, params.agentUsername);
|
|
135
|
+
} catch {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const buildToolHints = async (): Promise<LiveToolHint[]> => {
|
|
141
|
+
// Lazy import so the light startup path stays clean.
|
|
142
|
+
const { mutiroAgentTools } = await import("./agent-tools.js");
|
|
143
|
+
return mutiroAgentTools().map((tool) => {
|
|
144
|
+
const rawDescription = tool.description;
|
|
145
|
+
const description = typeof rawDescription === "string" ? rawDescription.trim() : "";
|
|
146
|
+
return {
|
|
147
|
+
name: tool.name,
|
|
148
|
+
description,
|
|
149
|
+
metadata: {},
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export type LiveSnapshotContext = {
|
|
155
|
+
cfg: OpenClawConfig;
|
|
156
|
+
accountId: string;
|
|
157
|
+
conversationId: string;
|
|
158
|
+
callerUsername?: string;
|
|
159
|
+
callId?: string;
|
|
160
|
+
agentUsername: string;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Assemble everything the live voice model needs to speak as OpenClaw's
|
|
165
|
+
* agent. Safe to call even when config/session state is incomplete: each
|
|
166
|
+
* section degrades independently (missing agent config → minimal header,
|
|
167
|
+
* missing transcript → undefined recent_messages, tool enumeration always
|
|
168
|
+
* succeeds).
|
|
169
|
+
*/
|
|
170
|
+
export const buildLiveSnapshot = async (
|
|
171
|
+
ctx: LiveSnapshotContext,
|
|
172
|
+
): Promise<LiveSnapshot> => {
|
|
173
|
+
const route = resolveAgentRoute({
|
|
174
|
+
cfg: ctx.cfg,
|
|
175
|
+
channel: "mutiro",
|
|
176
|
+
accountId: ctx.accountId,
|
|
177
|
+
peer: { kind: "direct", id: ctx.callerUsername ?? "unknown" },
|
|
178
|
+
});
|
|
179
|
+
const agentConfig = resolveAgentConfig(ctx.cfg, route.agentId);
|
|
180
|
+
|
|
181
|
+
const systemInstruction = buildSystemInstruction({
|
|
182
|
+
agentId: route.agentId,
|
|
183
|
+
agentConfig,
|
|
184
|
+
agentUsername: ctx.agentUsername,
|
|
185
|
+
callerUsername: ctx.callerUsername,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const recentMessages = await readRecentMessagesFromStore({
|
|
189
|
+
cfg: ctx.cfg,
|
|
190
|
+
agentId: route.agentId,
|
|
191
|
+
sessionKey: route.sessionKey,
|
|
192
|
+
conversationId: ctx.conversationId,
|
|
193
|
+
agentUsername: ctx.agentUsername,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const toolHints = await buildToolHints();
|
|
197
|
+
|
|
198
|
+
const metadata: Record<string, string> = {
|
|
199
|
+
agent_id: route.agentId,
|
|
200
|
+
session_key: route.sessionKey,
|
|
201
|
+
};
|
|
202
|
+
if (ctx.callId) metadata.call_id = ctx.callId;
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
systemInstruction,
|
|
206
|
+
...(recentMessages ? { recentMessages } : {}),
|
|
207
|
+
toolHints,
|
|
208
|
+
metadata,
|
|
209
|
+
};
|
|
210
|
+
};
|
package/src/outbound.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// Outbound adapter that translates OpenClaw reply-dispatch calls into
|
|
2
|
+
// bridge-local commands. Mirrors pi-brain's tool surface (send_message,
|
|
3
|
+
// send_voice_message, send_card, react_to_message, send_file_message,
|
|
4
|
+
// forward_message, recall, recall_get) but reshaped so OpenClaw's
|
|
5
|
+
// ChannelOutboundAdapter is the consumer instead of a Pi tool runtime.
|
|
6
|
+
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
|
|
9
|
+
import type { BridgeClient } from "./bridge-client.js";
|
|
10
|
+
import { applyVoiceLanguage, normalizeOutputText } from "./bridge-messages.js";
|
|
11
|
+
import { TYPE_URLS } from "./bridge-protocol.js";
|
|
12
|
+
|
|
13
|
+
export type MutiroOutboundTarget = {
|
|
14
|
+
conversationId: string;
|
|
15
|
+
replyToMessageId: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type MutiroOutbound = {
|
|
19
|
+
sendText: (
|
|
20
|
+
target: MutiroOutboundTarget,
|
|
21
|
+
text: string,
|
|
22
|
+
) => Promise<unknown>;
|
|
23
|
+
sendVoice: (
|
|
24
|
+
target: MutiroOutboundTarget,
|
|
25
|
+
params: { toUsername: string; speech: string; language?: string },
|
|
26
|
+
) => Promise<unknown>;
|
|
27
|
+
sendCard: (
|
|
28
|
+
target: MutiroOutboundTarget,
|
|
29
|
+
params: { components: unknown[]; data?: Record<string, unknown>; cardId?: string },
|
|
30
|
+
) => Promise<unknown>;
|
|
31
|
+
sendCardJsonl: (
|
|
32
|
+
target: MutiroOutboundTarget,
|
|
33
|
+
params: { jsonl: string; version?: string; cardId?: string },
|
|
34
|
+
) => Promise<unknown>;
|
|
35
|
+
sendFile: (
|
|
36
|
+
target: MutiroOutboundTarget,
|
|
37
|
+
params: { filePath: string; caption?: string },
|
|
38
|
+
) => Promise<unknown>;
|
|
39
|
+
react: (params: { messageId: string; emoji: string }) => Promise<unknown>;
|
|
40
|
+
forward: (params: {
|
|
41
|
+
messageId: string;
|
|
42
|
+
// Destination is a `oneof` in ForwardMessageRequest: exactly one of
|
|
43
|
+
// `targetConversationId` or `toUsername` must be set.
|
|
44
|
+
targetConversationId?: string;
|
|
45
|
+
toUsername?: string;
|
|
46
|
+
comment?: string;
|
|
47
|
+
}) => Promise<unknown>;
|
|
48
|
+
recallSearch: (params: {
|
|
49
|
+
query: string;
|
|
50
|
+
conversationId?: string;
|
|
51
|
+
maxResults?: number;
|
|
52
|
+
}) => Promise<unknown>;
|
|
53
|
+
recallGet: (params: { entryId: string; conversationId?: string }) => Promise<unknown>;
|
|
54
|
+
endTurn: (target: MutiroOutboundTarget) => void;
|
|
55
|
+
emitSignal: (
|
|
56
|
+
target: MutiroOutboundTarget,
|
|
57
|
+
signalType: string,
|
|
58
|
+
detailText?: string,
|
|
59
|
+
) => void;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const DEFAULT_VOICE = "en-US-Chirp3-HD-Orus";
|
|
63
|
+
|
|
64
|
+
const buildCardJson = (
|
|
65
|
+
components: Array<Record<string, unknown>>,
|
|
66
|
+
data?: Record<string, unknown>,
|
|
67
|
+
cardId?: string,
|
|
68
|
+
) => {
|
|
69
|
+
let rootId = (components[0] as { id?: string } | undefined)?.id || "root";
|
|
70
|
+
for (const component of components) {
|
|
71
|
+
const c = component as { parentId?: string; parent_id?: string; id?: string };
|
|
72
|
+
if (!c.parentId && !c.parent_id) {
|
|
73
|
+
rootId = c.id || rootId;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const lines: string[] = [
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
surfaceUpdate: {
|
|
81
|
+
surfaceId: "main",
|
|
82
|
+
components,
|
|
83
|
+
clearBefore: true,
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
if (data) {
|
|
89
|
+
const contents = Object.keys(data).map((key) => ({
|
|
90
|
+
key,
|
|
91
|
+
valueString:
|
|
92
|
+
typeof data[key] === "object" ? JSON.stringify(data[key]) : String(data[key]),
|
|
93
|
+
}));
|
|
94
|
+
lines.push(
|
|
95
|
+
JSON.stringify({
|
|
96
|
+
dataModelUpdate: {
|
|
97
|
+
surfaceId: "main",
|
|
98
|
+
contents,
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
lines.push(
|
|
105
|
+
JSON.stringify({
|
|
106
|
+
beginRendering: {
|
|
107
|
+
surfaceId: "main",
|
|
108
|
+
root: rootId,
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
// Field names must match Mutiro's CardPart protobuf schema (see
|
|
115
|
+
// spec/protobuf/shared/messaging.proto). pi-brain uses stale names
|
|
116
|
+
// (`json_data` / `version`) which the host's strict JSON-to-proto
|
|
117
|
+
// decoder rejects as unknown fields.
|
|
118
|
+
a2ui_json: lines.join("\n"),
|
|
119
|
+
schema_version: "0.8",
|
|
120
|
+
card_id: cardId || `openclaw-card-${Math.random().toString(36).slice(2, 10)}`,
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const createMutiroOutbound = (bridge: BridgeClient): MutiroOutbound => {
|
|
125
|
+
const extras = (target: MutiroOutboundTarget) => ({
|
|
126
|
+
conversation_id: target.conversationId,
|
|
127
|
+
reply_to_message_id: target.replyToMessageId,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
sendText: async (target, text) => {
|
|
132
|
+
const normalized = normalizeOutputText(text);
|
|
133
|
+
if (!normalized) return { ok: false, reason: "noop" };
|
|
134
|
+
return bridge.request(
|
|
135
|
+
"message.send",
|
|
136
|
+
{
|
|
137
|
+
"@type": TYPE_URLS.bridgeSendMessageCommand,
|
|
138
|
+
conversation_id: target.conversationId,
|
|
139
|
+
reply_to_message_id: target.replyToMessageId,
|
|
140
|
+
text: { text: normalized },
|
|
141
|
+
},
|
|
142
|
+
extras(target),
|
|
143
|
+
);
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
sendVoice: async (target, params) => {
|
|
147
|
+
const normalized = normalizeOutputText(params.speech);
|
|
148
|
+
if (!normalized) return { ok: false, reason: "noop" };
|
|
149
|
+
const voiceName = params.language
|
|
150
|
+
? applyVoiceLanguage(DEFAULT_VOICE, params.language)
|
|
151
|
+
: DEFAULT_VOICE;
|
|
152
|
+
return bridge.request(
|
|
153
|
+
"message.send_voice",
|
|
154
|
+
{
|
|
155
|
+
"@type": TYPE_URLS.bridgeSendVoiceMessageCommand,
|
|
156
|
+
to_username: params.toUsername.replace(/^@/, ""),
|
|
157
|
+
speech: normalized,
|
|
158
|
+
voice_name: voiceName,
|
|
159
|
+
reply_to_message_id: target.replyToMessageId,
|
|
160
|
+
},
|
|
161
|
+
extras(target),
|
|
162
|
+
);
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
sendCard: async (target, params) => {
|
|
166
|
+
return bridge.request(
|
|
167
|
+
"message.send",
|
|
168
|
+
{
|
|
169
|
+
"@type": TYPE_URLS.bridgeSendMessageCommand,
|
|
170
|
+
conversation_id: target.conversationId,
|
|
171
|
+
reply_to_message_id: target.replyToMessageId,
|
|
172
|
+
parts: {
|
|
173
|
+
parts: [
|
|
174
|
+
{
|
|
175
|
+
card: buildCardJson(
|
|
176
|
+
params.components as Array<Record<string, unknown>>,
|
|
177
|
+
params.data,
|
|
178
|
+
params.cardId,
|
|
179
|
+
),
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
extras(target),
|
|
185
|
+
);
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
// Ship pre-built A2UI JSONL (same format the canvas tool's a2ui_push
|
|
189
|
+
// action emits). Skips the components-to-JSONL conversion entirely, so
|
|
190
|
+
// the agent can reuse its canvas mental model verbatim.
|
|
191
|
+
sendCardJsonl: async (target, params) => {
|
|
192
|
+
const jsonl = (params.jsonl ?? "").trim();
|
|
193
|
+
if (!jsonl) return { ok: false, reason: "empty_jsonl" };
|
|
194
|
+
return bridge.request(
|
|
195
|
+
"message.send",
|
|
196
|
+
{
|
|
197
|
+
"@type": TYPE_URLS.bridgeSendMessageCommand,
|
|
198
|
+
conversation_id: target.conversationId,
|
|
199
|
+
reply_to_message_id: target.replyToMessageId,
|
|
200
|
+
parts: {
|
|
201
|
+
parts: [
|
|
202
|
+
{
|
|
203
|
+
card: {
|
|
204
|
+
a2ui_json: jsonl,
|
|
205
|
+
schema_version: params.version ?? "0.8",
|
|
206
|
+
card_id:
|
|
207
|
+
params.cardId || `openclaw-card-${Math.random().toString(36).slice(2, 10)}`,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
extras(target),
|
|
214
|
+
);
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
sendFile: async (target, params) => {
|
|
218
|
+
const uploadRes = (await bridge.request(
|
|
219
|
+
"media.upload",
|
|
220
|
+
{
|
|
221
|
+
"@type": TYPE_URLS.bridgeMediaUploadCommand,
|
|
222
|
+
local_path: params.filePath,
|
|
223
|
+
filename: path.basename(params.filePath),
|
|
224
|
+
mime_type: "application/octet-stream",
|
|
225
|
+
},
|
|
226
|
+
extras(target),
|
|
227
|
+
)) as { media?: unknown } | null;
|
|
228
|
+
|
|
229
|
+
if (!uploadRes?.media) {
|
|
230
|
+
throw new Error(`failed to upload media: ${JSON.stringify(uploadRes)}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return bridge.request(
|
|
234
|
+
"message.send",
|
|
235
|
+
{
|
|
236
|
+
"@type": TYPE_URLS.bridgeSendMessageCommand,
|
|
237
|
+
conversation_id: target.conversationId,
|
|
238
|
+
reply_to_message_id: target.replyToMessageId,
|
|
239
|
+
parts: {
|
|
240
|
+
parts: [
|
|
241
|
+
{
|
|
242
|
+
file: uploadRes.media,
|
|
243
|
+
...(params.caption ? { metadata: { caption: params.caption } } : {}),
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
extras(target),
|
|
249
|
+
);
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
react: async (params) => {
|
|
253
|
+
return bridge.request(
|
|
254
|
+
"message.react",
|
|
255
|
+
{
|
|
256
|
+
"@type": TYPE_URLS.addReactionRequest,
|
|
257
|
+
message_id: params.messageId,
|
|
258
|
+
emoji: params.emoji,
|
|
259
|
+
},
|
|
260
|
+
{ message_id: params.messageId },
|
|
261
|
+
);
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
forward: async (params) => {
|
|
265
|
+
const toUsername = params.toUsername?.trim().replace(/^@/, "");
|
|
266
|
+
const targetConversationId = params.targetConversationId?.trim();
|
|
267
|
+
if (!toUsername && !targetConversationId) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
"forward requires exactly one of { toUsername, targetConversationId }",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
// `destination` is a proto `oneof`; set exactly one of the two fields.
|
|
273
|
+
return bridge.request("message.forward", {
|
|
274
|
+
"@type": TYPE_URLS.forwardMessageRequest,
|
|
275
|
+
message_id: params.messageId,
|
|
276
|
+
...(toUsername
|
|
277
|
+
? { to_username: toUsername }
|
|
278
|
+
: { conversation_id: targetConversationId }),
|
|
279
|
+
comment: params.comment || "",
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
recallSearch: async (params) => {
|
|
284
|
+
return bridge.request("recall.search", {
|
|
285
|
+
"@type": TYPE_URLS.recallSearchRequest,
|
|
286
|
+
query: params.query,
|
|
287
|
+
conversation_id: params.conversationId,
|
|
288
|
+
limit: params.maxResults,
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
recallGet: async (params) => {
|
|
293
|
+
return bridge.request("recall.get", {
|
|
294
|
+
"@type": TYPE_URLS.recallGetRequest,
|
|
295
|
+
entry_id: params.entryId,
|
|
296
|
+
conversation_id: params.conversationId,
|
|
297
|
+
});
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
endTurn: (target) => {
|
|
301
|
+
bridge.send(
|
|
302
|
+
"turn.end",
|
|
303
|
+
{
|
|
304
|
+
"@type": TYPE_URLS.bridgeTurnEndCommand,
|
|
305
|
+
status: "completed",
|
|
306
|
+
},
|
|
307
|
+
extras(target),
|
|
308
|
+
);
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
emitSignal: (target, signalType, detailText = "") => {
|
|
312
|
+
if (!target.conversationId) return;
|
|
313
|
+
bridge.send(
|
|
314
|
+
"signal.emit",
|
|
315
|
+
{
|
|
316
|
+
"@type": TYPE_URLS.sendSignalRequest,
|
|
317
|
+
conversation_id: target.conversationId,
|
|
318
|
+
signal_type: signalType,
|
|
319
|
+
detail_text: detailText,
|
|
320
|
+
in_reply_to: target.replyToMessageId,
|
|
321
|
+
},
|
|
322
|
+
extras(target),
|
|
323
|
+
);
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
};
|