mercury-agent 0.4.5
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/LICENSE +22 -0
- package/README.md +438 -0
- package/container/Dockerfile +127 -0
- package/container/Dockerfile.base +109 -0
- package/container/Dockerfile.power +17 -0
- package/container/agent-package.json +8 -0
- package/container/build.sh +54 -0
- package/docs/TODOS.md +147 -0
- package/docs/auth/dashboard.md +28 -0
- package/docs/auth/overview.md +109 -0
- package/docs/auth/whatsapp.md +173 -0
- package/docs/configuration.md +54 -0
- package/docs/container-lifecycle.md +349 -0
- package/docs/context-architecture.md +87 -0
- package/docs/deployment.md +199 -0
- package/docs/extensions.md +375 -0
- package/docs/graceful-shutdown.md +62 -0
- package/docs/kb-distillation.md +77 -0
- package/docs/media/overview.md +140 -0
- package/docs/media/whatsapp.md +171 -0
- package/docs/memory.md +137 -0
- package/docs/permissions.md +217 -0
- package/docs/pipeline.md +228 -0
- package/docs/prd-chat-memory.md +76 -0
- package/docs/prd-config-load.md +82 -0
- package/docs/rate-limiting.md +166 -0
- package/docs/scheduler.md +288 -0
- package/docs/setup-discord.md +100 -0
- package/docs/setup-slack.md +119 -0
- package/docs/setup-whatsapp.md +94 -0
- package/docs/subagents.md +166 -0
- package/docs/web-search.md +62 -0
- package/examples/extensions/README.md +12 -0
- package/examples/extensions/charts/index.ts +13 -0
- package/examples/extensions/charts/skill/SKILL.md +98 -0
- package/examples/extensions/gws/README.md +52 -0
- package/examples/extensions/gws/index.ts +106 -0
- package/examples/extensions/gws/skill/SKILL.md +57 -0
- package/examples/extensions/gws/skill/references/calendar.md +101 -0
- package/examples/extensions/gws/skill/references/docs.md +65 -0
- package/examples/extensions/gws/skill/references/drive.md +79 -0
- package/examples/extensions/gws/skill/references/gmail.md +85 -0
- package/examples/extensions/gws/skill/references/sheets.md +60 -0
- package/examples/extensions/napkin/index.ts +821 -0
- package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
- package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
- package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
- package/examples/extensions/napkin/skill/SKILL.md +728 -0
- package/examples/extensions/pdf/index.ts +23 -0
- package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
- package/examples/extensions/pdf/skill/SKILL.md +314 -0
- package/examples/extensions/pdf/skill/forms.md +294 -0
- package/examples/extensions/pdf/skill/reference.md +612 -0
- package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
- package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
- package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
- package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
- package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
- package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
- package/examples/extensions/permission-guard/index.ts +65 -0
- package/examples/extensions/pinchtab/index.ts +199 -0
- package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
- package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
- package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
- package/examples/extensions/pinchtab/skill/references/api.md +297 -0
- package/examples/extensions/pinchtab/skill/references/env.md +45 -0
- package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
- package/examples/extensions/tradestation/host/refresh.ts +102 -0
- package/examples/extensions/tradestation/index.ts +153 -0
- package/examples/extensions/tradestation/skill/SKILL.md +67 -0
- package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
- package/examples/extensions/voice-synth/index.ts +94 -0
- package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
- package/examples/extensions/voice-transcribe/index.ts +381 -0
- package/examples/extensions/voice-transcribe/requirements.txt +8 -0
- package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
- package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
- package/examples/extensions/web-search/index.ts +22 -0
- package/examples/extensions/web-search/skill/SKILL.md +114 -0
- package/examples/extensions/web-search/skill/references/apartments.md +178 -0
- package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
- package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
- package/examples/extensions/web-search/skill/references/flights.md +133 -0
- package/examples/extensions/web-search/skill/references/hotels.md +148 -0
- package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
- package/examples/extensions/yahoo-mail/cli/package.json +13 -0
- package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
- package/examples/extensions/yahoo-mail/index.ts +57 -0
- package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
- package/package.json +106 -0
- package/resources/agents/explore.md +50 -0
- package/resources/agents/worker.md +24 -0
- package/resources/builtin-extensions.txt +3 -0
- package/resources/connection-env-vars.json +25 -0
- package/resources/extensions/.gitkeep +0 -0
- package/resources/pi-extensions/subagent/agents.ts +126 -0
- package/resources/pi-extensions/subagent/index.ts +964 -0
- package/resources/profiles/coding/AGENTS.md +43 -0
- package/resources/profiles/coding/mercury-profile.yaml +15 -0
- package/resources/profiles/general/AGENTS.md +31 -0
- package/resources/profiles/general/mercury-profile.yaml +15 -0
- package/resources/profiles/research/AGENTS.md +40 -0
- package/resources/profiles/research/mercury-profile.yaml +15 -0
- package/resources/skills/config/SKILL.md +25 -0
- package/resources/skills/context/SKILL.md +33 -0
- package/resources/skills/conversation-recap/SKILL.md +19 -0
- package/resources/skills/media/SKILL.md +27 -0
- package/resources/skills/mutes/SKILL.md +31 -0
- package/resources/skills/permissions/SKILL.md +19 -0
- package/resources/skills/preferences/SKILL.md +31 -0
- package/resources/skills/recall/SKILL.md +24 -0
- package/resources/skills/roles/SKILL.md +18 -0
- package/resources/skills/spaces/SKILL.md +18 -0
- package/resources/skills/tasks/SKILL.md +45 -0
- package/resources/templates/AGENTS.md +157 -0
- package/resources/templates/env.template +34 -0
- package/resources/templates/mercury.example.yaml +75 -0
- package/src/adapters/discord-native.ts +534 -0
- package/src/adapters/discord.ts +38 -0
- package/src/adapters/setup.ts +89 -0
- package/src/adapters/slack.ts +9 -0
- package/src/adapters/whatsapp-media.ts +337 -0
- package/src/adapters/whatsapp.ts +629 -0
- package/src/agent/api-socket.ts +127 -0
- package/src/agent/container-entry.ts +967 -0
- package/src/agent/container-error.ts +49 -0
- package/src/agent/container-runner.ts +1272 -0
- package/src/agent/model-capabilities-core.ts +23 -0
- package/src/agent/model-capabilities.ts +231 -0
- package/src/agent/pi-failure-class.ts +83 -0
- package/src/agent/pi-jsonl-parser.ts +306 -0
- package/src/agent/preferences-prompt.ts +20 -0
- package/src/agent/user-error-messages.ts +78 -0
- package/src/bridges/discord.ts +171 -0
- package/src/bridges/slack.ts +177 -0
- package/src/bridges/teams.ts +160 -0
- package/src/bridges/telegram.ts +571 -0
- package/src/bridges/whatsapp.ts +290 -0
- package/src/chat-shim.ts +259 -0
- package/src/cli/mercury.ts +2508 -0
- package/src/cli/mrctl-http.ts +27 -0
- package/src/cli/mrctl.ts +611 -0
- package/src/cli/whatsapp-auth.ts +260 -0
- package/src/config-file.ts +397 -0
- package/src/config-model-chain.ts +30 -0
- package/src/config.ts +316 -0
- package/src/core/api-types.ts +58 -0
- package/src/core/api.ts +105 -0
- package/src/core/commands.ts +76 -0
- package/src/core/conversation.ts +47 -0
- package/src/core/handler.ts +206 -0
- package/src/core/media.ts +200 -0
- package/src/core/mute-duration.ts +22 -0
- package/src/core/outbox.ts +76 -0
- package/src/core/permissions.ts +192 -0
- package/src/core/profiles.ts +245 -0
- package/src/core/rate-limiter.ts +127 -0
- package/src/core/router.ts +191 -0
- package/src/core/routes/chat.ts +172 -0
- package/src/core/routes/config-builtin.ts +107 -0
- package/src/core/routes/config.ts +81 -0
- package/src/core/routes/connections.ts +190 -0
- package/src/core/routes/console.ts +668 -0
- package/src/core/routes/control.ts +46 -0
- package/src/core/routes/conversations.ts +66 -0
- package/src/core/routes/dashboard.ts +2491 -0
- package/src/core/routes/extensions.ts +37 -0
- package/src/core/routes/index.ts +14 -0
- package/src/core/routes/media.ts +72 -0
- package/src/core/routes/messages.ts +37 -0
- package/src/core/routes/mutes.ts +89 -0
- package/src/core/routes/prefs.ts +95 -0
- package/src/core/routes/roles.ts +125 -0
- package/src/core/routes/spaces.ts +60 -0
- package/src/core/routes/storage.ts +126 -0
- package/src/core/routes/tasks.ts +189 -0
- package/src/core/routes/tradestation.ts +268 -0
- package/src/core/routes/tts.ts +51 -0
- package/src/core/runtime.ts +1140 -0
- package/src/core/space-queue.ts +103 -0
- package/src/core/storage-cleanup.ts +140 -0
- package/src/core/storage-guard.ts +24 -0
- package/src/core/task-scheduler.ts +132 -0
- package/src/core/telegram-format.ts +178 -0
- package/src/core/trigger.ts +142 -0
- package/src/dashboard/index.html +729 -0
- package/src/dashboard/tokens.css +53 -0
- package/src/extensions/api.ts +252 -0
- package/src/extensions/catalog.ts +117 -0
- package/src/extensions/config-registry.ts +83 -0
- package/src/extensions/context.ts +36 -0
- package/src/extensions/hooks.ts +156 -0
- package/src/extensions/image-builder.ts +617 -0
- package/src/extensions/installer.ts +306 -0
- package/src/extensions/jobs.ts +122 -0
- package/src/extensions/loader.ts +271 -0
- package/src/extensions/permission-guard.ts +52 -0
- package/src/extensions/reserved.ts +28 -0
- package/src/extensions/skills.ts +123 -0
- package/src/extensions/types.ts +462 -0
- package/src/logger.ts +174 -0
- package/src/main.ts +586 -0
- package/src/server.ts +391 -0
- package/src/storage/db.ts +1624 -0
- package/src/storage/memory.ts +45 -0
- package/src/storage/pi-auth.ts +95 -0
- package/src/text/markdown.ts +117 -0
- package/src/text/rtl.ts +38 -0
- package/src/tradestation/host-api.ts +77 -0
- package/src/tradestation/pending-orders.ts +69 -0
- package/src/tts/azure.ts +52 -0
- package/src/tts/google.ts +128 -0
- package/src/tts/index.ts +8 -0
- package/src/tts/language.ts +20 -0
- package/src/tts/synthesize.ts +133 -0
- package/src/types.ts +295 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
const execFile = promisify(execFileCb);
|
|
9
|
+
|
|
10
|
+
const EXT = "voice-transcribe";
|
|
11
|
+
const DEFAULT_MODEL = "mike249/whisper-tiny-he-2";
|
|
12
|
+
const DEFAULT_PROVIDER = "local";
|
|
13
|
+
const DEFAULT_LOCAL_ENGINE = "transformers";
|
|
14
|
+
/** Classic HF Inference API (serverless). */
|
|
15
|
+
const HF_INFERENCE_BASE = "https://api-inference.huggingface.co/models";
|
|
16
|
+
|
|
17
|
+
const SCRIPT_PATH = path.join(
|
|
18
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
19
|
+
"scripts",
|
|
20
|
+
"transcribe.py",
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
type Attachment = { path: string; type: string; mimeType: string };
|
|
24
|
+
|
|
25
|
+
function parseTranscriptionJson(raw: string): string {
|
|
26
|
+
try {
|
|
27
|
+
const data = JSON.parse(raw) as unknown;
|
|
28
|
+
if (typeof data === "object" && data !== null && "text" in data) {
|
|
29
|
+
const t = (data as { text: unknown }).text;
|
|
30
|
+
if (typeof t === "string") return t.trim();
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(data) && data.length > 0) {
|
|
33
|
+
const first = data[0] as { text?: string };
|
|
34
|
+
if (typeof first?.text === "string") return first.text.trim();
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
/* ignore */
|
|
38
|
+
}
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function transcribeWithApi(
|
|
43
|
+
filePath: string,
|
|
44
|
+
mimeType: string,
|
|
45
|
+
modelId: string,
|
|
46
|
+
token: string,
|
|
47
|
+
): Promise<string> {
|
|
48
|
+
const body = await readFile(filePath);
|
|
49
|
+
const url = `${HF_INFERENCE_BASE}/${encodeURIComponent(modelId)}`;
|
|
50
|
+
const res = await fetch(url, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: {
|
|
53
|
+
Authorization: `Bearer ${token}`,
|
|
54
|
+
"Content-Type":
|
|
55
|
+
mimeType.split(";")[0].trim() || "application/octet-stream",
|
|
56
|
+
},
|
|
57
|
+
body,
|
|
58
|
+
});
|
|
59
|
+
const raw = await res.text();
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
throw new Error(`HF inference ${res.status}: ${raw.slice(0, 240)}`);
|
|
62
|
+
}
|
|
63
|
+
return parseTranscriptionJson(raw);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseLastJsonLine(stdout: string): {
|
|
67
|
+
text?: string;
|
|
68
|
+
error?: string;
|
|
69
|
+
} {
|
|
70
|
+
const lines = stdout.trim().split(/\r?\n/).filter(Boolean);
|
|
71
|
+
const line = lines[lines.length - 1] ?? "";
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(line) as { text?: string; error?: string };
|
|
74
|
+
} catch {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function execFileStdout(e: unknown): string {
|
|
80
|
+
if (!e || typeof e !== "object" || !("stdout" in e)) return "";
|
|
81
|
+
const v = (e as { stdout?: Buffer | string }).stdout;
|
|
82
|
+
return Buffer.isBuffer(v) ? v.toString("utf8") : String(v ?? "");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Best-effort parse of `execFile` failures (non-zero exit, timeout kill, etc.). */
|
|
86
|
+
function describeLocalTranscribeExecError(
|
|
87
|
+
e: unknown,
|
|
88
|
+
timeoutMs: number,
|
|
89
|
+
): { message: string; meta: Record<string, unknown> } {
|
|
90
|
+
const fallback = e instanceof Error ? e.message : String(e);
|
|
91
|
+
if (!e || typeof e !== "object") {
|
|
92
|
+
return { message: fallback, meta: {} };
|
|
93
|
+
}
|
|
94
|
+
const o = e as Record<string, unknown>;
|
|
95
|
+
const stderr = Buffer.isBuffer(o.stderr)
|
|
96
|
+
? o.stderr.toString("utf8")
|
|
97
|
+
: String(o.stderr ?? "");
|
|
98
|
+
const stdout = Buffer.isBuffer(o.stdout)
|
|
99
|
+
? o.stdout.toString("utf8")
|
|
100
|
+
: String(o.stdout ?? "");
|
|
101
|
+
const stderrTail = stderr.trim().slice(-1200);
|
|
102
|
+
const stdoutTail = stdout.trim().slice(-600);
|
|
103
|
+
const code =
|
|
104
|
+
typeof o.code === "number"
|
|
105
|
+
? o.code
|
|
106
|
+
: typeof o.code === "string"
|
|
107
|
+
? o.code
|
|
108
|
+
: undefined;
|
|
109
|
+
const killed = o.killed === true;
|
|
110
|
+
const signal = typeof o.signal === "string" ? o.signal : undefined;
|
|
111
|
+
const errno = e as NodeJS.ErrnoException;
|
|
112
|
+
const errnoCode = typeof errno.code === "string" ? errno.code : undefined;
|
|
113
|
+
const msgLower = fallback.toLowerCase();
|
|
114
|
+
|
|
115
|
+
const looksTimedOut =
|
|
116
|
+
killed ||
|
|
117
|
+
errnoCode === "ETIMEDOUT" ||
|
|
118
|
+
msgLower.includes("etimedout") ||
|
|
119
|
+
msgLower.includes("timed out");
|
|
120
|
+
|
|
121
|
+
if (looksTimedOut) {
|
|
122
|
+
return {
|
|
123
|
+
message: `Local transcribe exceeded timeout (${timeoutMs}ms). First run often downloads the HF model — raise MERCURY_VOICE_TRANSCRIBE_TIMEOUT_MS, warm-cache the model, or use provider=api.`,
|
|
124
|
+
meta: {
|
|
125
|
+
reason: "timeout",
|
|
126
|
+
timeoutMs,
|
|
127
|
+
signal,
|
|
128
|
+
stderrTail: stderrTail || undefined,
|
|
129
|
+
stdoutTail: stdoutTail || undefined,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
message: fallback,
|
|
136
|
+
meta: {
|
|
137
|
+
reason: "exec_failed",
|
|
138
|
+
exitCode: typeof code === "number" ? code : undefined,
|
|
139
|
+
exitCodeString: typeof code === "string" ? code : undefined,
|
|
140
|
+
signal,
|
|
141
|
+
stderrTail: stderrTail || undefined,
|
|
142
|
+
stdoutTail: stdoutTail || undefined,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function transcribeWithLocal(
|
|
148
|
+
scriptPath: string,
|
|
149
|
+
audioPath: string,
|
|
150
|
+
modelId: string,
|
|
151
|
+
localEngine: string,
|
|
152
|
+
pythonBin: string,
|
|
153
|
+
timeoutMs: number,
|
|
154
|
+
): Promise<string> {
|
|
155
|
+
const opts = {
|
|
156
|
+
timeout: timeoutMs,
|
|
157
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
158
|
+
windowsHide: true,
|
|
159
|
+
env: {
|
|
160
|
+
...process.env,
|
|
161
|
+
PYTHONUNBUFFERED: "1",
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
try {
|
|
165
|
+
const { stdout } = await execFile(
|
|
166
|
+
pythonBin,
|
|
167
|
+
[
|
|
168
|
+
scriptPath,
|
|
169
|
+
"--audio",
|
|
170
|
+
audioPath,
|
|
171
|
+
"--model",
|
|
172
|
+
modelId,
|
|
173
|
+
"--local-engine",
|
|
174
|
+
localEngine,
|
|
175
|
+
],
|
|
176
|
+
opts,
|
|
177
|
+
);
|
|
178
|
+
const data = parseLastJsonLine(stdout);
|
|
179
|
+
if (data.error) throw new Error(data.error);
|
|
180
|
+
return (data.text ?? "").trim();
|
|
181
|
+
} catch (e) {
|
|
182
|
+
const fromStdout = parseLastJsonLine(execFileStdout(e));
|
|
183
|
+
if (fromStdout.error) throw new Error(fromStdout.error);
|
|
184
|
+
if (fromStdout.text !== undefined && !fromStdout.error) {
|
|
185
|
+
return (fromStdout.text ?? "").trim();
|
|
186
|
+
}
|
|
187
|
+
throw e;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function resolveHostPath(workspace: string, attPath: string): string {
|
|
192
|
+
return path.isAbsolute(attPath) ? attPath : path.join(workspace, attPath);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function defaultPython(): string {
|
|
196
|
+
const fromEnv = process.env.MERCURY_VOICE_PYTHON?.trim();
|
|
197
|
+
if (fromEnv) return fromEnv;
|
|
198
|
+
return process.platform === "win32" ? "python" : "python3";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function localTimeoutMs(): number {
|
|
202
|
+
const raw = process.env.MERCURY_VOICE_TRANSCRIBE_TIMEOUT_MS?.trim();
|
|
203
|
+
if (raw) {
|
|
204
|
+
const n = Number.parseInt(raw, 10);
|
|
205
|
+
if (!Number.isNaN(n) && n > 0) return n;
|
|
206
|
+
}
|
|
207
|
+
return 300_000;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export default function (mercury: {
|
|
211
|
+
permission(opts: { defaultRoles: string[] }): void;
|
|
212
|
+
env(def: { from: string }): void;
|
|
213
|
+
config(
|
|
214
|
+
key: string,
|
|
215
|
+
def: {
|
|
216
|
+
description: string;
|
|
217
|
+
default: string;
|
|
218
|
+
validate?: (v: string) => boolean;
|
|
219
|
+
},
|
|
220
|
+
): void;
|
|
221
|
+
skill(relativePath: string): void;
|
|
222
|
+
on(
|
|
223
|
+
event: "before_container",
|
|
224
|
+
handler: (
|
|
225
|
+
event: {
|
|
226
|
+
spaceId: string;
|
|
227
|
+
prompt: string;
|
|
228
|
+
callerId: string;
|
|
229
|
+
workspace: string;
|
|
230
|
+
containerWorkspace: string;
|
|
231
|
+
attachments?: Attachment[];
|
|
232
|
+
},
|
|
233
|
+
ctx: {
|
|
234
|
+
db: { getSpaceConfig: (spaceId: string, key: string) => string | null };
|
|
235
|
+
log: {
|
|
236
|
+
info: (msg: string, extra?: unknown) => void;
|
|
237
|
+
warn: (msg: string, extra?: unknown) => void;
|
|
238
|
+
error: (msg: string, extra?: unknown) => void;
|
|
239
|
+
};
|
|
240
|
+
hasCallerPermission: (
|
|
241
|
+
spaceId: string,
|
|
242
|
+
callerId: string,
|
|
243
|
+
permission: string,
|
|
244
|
+
) => boolean;
|
|
245
|
+
},
|
|
246
|
+
) => Promise<{ promptAppend?: string } | undefined>,
|
|
247
|
+
): void;
|
|
248
|
+
}) {
|
|
249
|
+
mercury.permission({ defaultRoles: ["admin", "member"] });
|
|
250
|
+
mercury.env({ from: "MERCURY_HF_TOKEN" });
|
|
251
|
+
mercury.config("provider", {
|
|
252
|
+
description:
|
|
253
|
+
'"local" = Python+transformers on Mercury host (see skill); "api" = Hugging Face Inference API (needs MERCURY_HF_TOKEN)',
|
|
254
|
+
default: DEFAULT_PROVIDER,
|
|
255
|
+
validate: (v) => v === "local" || v === "api",
|
|
256
|
+
});
|
|
257
|
+
mercury.config("model", {
|
|
258
|
+
description:
|
|
259
|
+
"Hugging Face model id (e.g. mike249/whisper-tiny-he-2 for local Hebrew; openai/whisper-large-v3 for api)",
|
|
260
|
+
default: DEFAULT_MODEL,
|
|
261
|
+
});
|
|
262
|
+
mercury.config("local_engine", {
|
|
263
|
+
description:
|
|
264
|
+
'local ASR only: "transformers" (default) or "faster_whisper" (CTranslate2 / Hugging Face CT2 repos, e.g. ivrit-ai/*-ct2)',
|
|
265
|
+
default: DEFAULT_LOCAL_ENGINE,
|
|
266
|
+
validate: (v) => v === "transformers" || v === "faster_whisper",
|
|
267
|
+
});
|
|
268
|
+
mercury.skill("./skill");
|
|
269
|
+
|
|
270
|
+
mercury.on("before_container", async (event, ctx) => {
|
|
271
|
+
if (!ctx.hasCallerPermission(event.spaceId, event.callerId, EXT)) {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const atts = event.attachments?.filter(
|
|
276
|
+
(a) => a.type === "voice" || a.type === "audio",
|
|
277
|
+
);
|
|
278
|
+
if (!atts?.length) return undefined;
|
|
279
|
+
|
|
280
|
+
const provider =
|
|
281
|
+
ctx.db.getSpaceConfig(event.spaceId, `${EXT}.provider`)?.trim() ||
|
|
282
|
+
DEFAULT_PROVIDER;
|
|
283
|
+
const model =
|
|
284
|
+
ctx.db.getSpaceConfig(event.spaceId, `${EXT}.model`)?.trim() ||
|
|
285
|
+
DEFAULT_MODEL;
|
|
286
|
+
const rawLocalEngine = ctx.db
|
|
287
|
+
.getSpaceConfig(event.spaceId, `${EXT}.local_engine`)
|
|
288
|
+
?.trim();
|
|
289
|
+
const localEngine =
|
|
290
|
+
rawLocalEngine === "faster_whisper" || rawLocalEngine === "transformers"
|
|
291
|
+
? rawLocalEngine
|
|
292
|
+
: DEFAULT_LOCAL_ENGINE;
|
|
293
|
+
|
|
294
|
+
let token: string | undefined;
|
|
295
|
+
if (provider === "api") {
|
|
296
|
+
token = process.env.MERCURY_HF_TOKEN?.trim();
|
|
297
|
+
if (!token) {
|
|
298
|
+
ctx.log.warn(
|
|
299
|
+
"MERCURY_HF_TOKEN not set; skipping voice transcription (api provider)",
|
|
300
|
+
{ extension: EXT },
|
|
301
|
+
);
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
} else if (provider === "local") {
|
|
305
|
+
if (!existsSync(SCRIPT_PATH)) {
|
|
306
|
+
ctx.log.error("Local transcribe script missing", {
|
|
307
|
+
extension: EXT,
|
|
308
|
+
scriptPath: SCRIPT_PATH,
|
|
309
|
+
});
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const lines: string[] = [];
|
|
315
|
+
const timeoutMs = localTimeoutMs();
|
|
316
|
+
const pythonBin = defaultPython();
|
|
317
|
+
|
|
318
|
+
for (const att of atts) {
|
|
319
|
+
const hostPath = resolveHostPath(event.workspace, att.path);
|
|
320
|
+
try {
|
|
321
|
+
let text: string;
|
|
322
|
+
if (provider === "api") {
|
|
323
|
+
text = await transcribeWithApi(
|
|
324
|
+
hostPath,
|
|
325
|
+
att.mimeType,
|
|
326
|
+
model,
|
|
327
|
+
token as string,
|
|
328
|
+
);
|
|
329
|
+
} else {
|
|
330
|
+
ctx.log.info("Voice transcription starting (local)", {
|
|
331
|
+
extension: EXT,
|
|
332
|
+
path: hostPath,
|
|
333
|
+
model,
|
|
334
|
+
localEngine,
|
|
335
|
+
timeoutMs,
|
|
336
|
+
pythonBin,
|
|
337
|
+
});
|
|
338
|
+
text = await transcribeWithLocal(
|
|
339
|
+
SCRIPT_PATH,
|
|
340
|
+
hostPath,
|
|
341
|
+
model,
|
|
342
|
+
localEngine,
|
|
343
|
+
pythonBin,
|
|
344
|
+
timeoutMs,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
if (text) lines.push(text);
|
|
348
|
+
} catch (e) {
|
|
349
|
+
if (provider === "local") {
|
|
350
|
+
const { message, meta } = describeLocalTranscribeExecError(
|
|
351
|
+
e,
|
|
352
|
+
timeoutMs,
|
|
353
|
+
);
|
|
354
|
+
ctx.log.error("Voice transcription failed", {
|
|
355
|
+
extension: EXT,
|
|
356
|
+
path: hostPath,
|
|
357
|
+
provider,
|
|
358
|
+
model,
|
|
359
|
+
localEngine,
|
|
360
|
+
error: message,
|
|
361
|
+
...meta,
|
|
362
|
+
});
|
|
363
|
+
} else {
|
|
364
|
+
ctx.log.error("Voice transcription failed", {
|
|
365
|
+
extension: EXT,
|
|
366
|
+
path: hostPath,
|
|
367
|
+
provider,
|
|
368
|
+
model,
|
|
369
|
+
error: e instanceof Error ? e.message : String(e),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (lines.length === 0) return undefined;
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
promptAppend: `[Voice transcript]\n${lines.join("\n")}`,
|
|
379
|
+
};
|
|
380
|
+
});
|
|
381
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Local transcription (voice-transcribe.provider=local). Install on the Mercury host:
|
|
2
|
+
# pip install -r requirements.txt
|
|
3
|
+
transformers>=4.40.0
|
|
4
|
+
torch>=2.0.0
|
|
5
|
+
# Optional local engine when voice-transcribe.local_engine=faster_whisper (CTranslate2 models on Hub).
|
|
6
|
+
faster-whisper>=1.0.0
|
|
7
|
+
# Ships a platform ffmpeg binary when the `ffmpeg` command is missing (Telegram .ogg / Opus).
|
|
8
|
+
imageio-ffmpeg>=0.4.9
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Transcribe a single audio file; print one JSON line to stdout: {\"text\":\"...\"} or {\"error\":\"...\"}."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _ensure_ffmpeg_on_path() -> None:
|
|
15
|
+
"""
|
|
16
|
+
Transformers and faster-whisper decode via ffmpeg on PATH.
|
|
17
|
+
imageio-ffmpeg ships a versioned .exe (e.g. ffmpeg-win-x86_64-v7.1.exe). On Windows,
|
|
18
|
+
Popen([...]) without shell=True will not run a .bat shim — it needs a real `ffmpeg.exe`
|
|
19
|
+
on PATH. We hardlink (or copy) the vendored binary to `<temp>/mercury-voice-ffmpeg/ffmpeg.exe`.
|
|
20
|
+
"""
|
|
21
|
+
if shutil.which("ffmpeg"):
|
|
22
|
+
return
|
|
23
|
+
try:
|
|
24
|
+
import imageio_ffmpeg
|
|
25
|
+
except ImportError:
|
|
26
|
+
return
|
|
27
|
+
src = imageio_ffmpeg.get_ffmpeg_exe()
|
|
28
|
+
if not src or not os.path.isfile(src):
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
shim_root = os.path.join(tempfile.gettempdir(), "mercury-voice-ffmpeg")
|
|
32
|
+
os.makedirs(shim_root, exist_ok=True)
|
|
33
|
+
dst = os.path.join(shim_root, "ffmpeg.exe" if sys.platform == "win32" else "ffmpeg")
|
|
34
|
+
|
|
35
|
+
if not os.path.exists(dst):
|
|
36
|
+
try:
|
|
37
|
+
os.link(src, dst)
|
|
38
|
+
except OSError:
|
|
39
|
+
try:
|
|
40
|
+
shutil.copy2(src, dst)
|
|
41
|
+
except OSError:
|
|
42
|
+
return
|
|
43
|
+
elif sys.platform != "win32":
|
|
44
|
+
try:
|
|
45
|
+
os.chmod(dst, 0o755)
|
|
46
|
+
except OSError:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
os.environ["PATH"] = f"{shim_root}{os.pathsep}{os.environ.get('PATH', '')}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _asr_device_torch():
|
|
53
|
+
"""Resolve torch device for Transformers. Env: MERCURY_VOICE_ASR_DEVICE=cpu|cuda|auto (default auto)."""
|
|
54
|
+
import torch
|
|
55
|
+
|
|
56
|
+
raw = (os.environ.get("MERCURY_VOICE_ASR_DEVICE") or "").strip().lower()
|
|
57
|
+
if not raw or raw == "auto":
|
|
58
|
+
# Windows often reports CUDA but inference fails (drivers, OOM); CPU is the reliable default.
|
|
59
|
+
if sys.platform == "win32":
|
|
60
|
+
raw = "cpu"
|
|
61
|
+
else:
|
|
62
|
+
raw = "cuda" if torch.cuda.is_available() else "cpu"
|
|
63
|
+
if raw in ("cpu", "-1"):
|
|
64
|
+
return torch.device("cpu")
|
|
65
|
+
if raw in ("cuda", "gpu", "cuda:0", "0"):
|
|
66
|
+
if torch.cuda.is_available():
|
|
67
|
+
return torch.device("cuda:0")
|
|
68
|
+
return torch.device("cpu")
|
|
69
|
+
return torch.device("cpu")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _cuda_available() -> bool:
|
|
73
|
+
try:
|
|
74
|
+
import torch
|
|
75
|
+
|
|
76
|
+
return bool(torch.cuda.is_available())
|
|
77
|
+
except ImportError:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _fw_device_str() -> str:
|
|
82
|
+
"""faster-whisper device string: cpu | cuda. Same MERCURY_VOICE_ASR_DEVICE semantics as Transformers."""
|
|
83
|
+
raw = (os.environ.get("MERCURY_VOICE_ASR_DEVICE") or "").strip().lower()
|
|
84
|
+
if not raw or raw == "auto":
|
|
85
|
+
if sys.platform == "win32":
|
|
86
|
+
return "cpu"
|
|
87
|
+
return "cuda" if _cuda_available() else "cpu"
|
|
88
|
+
if raw in ("cpu", "-1"):
|
|
89
|
+
return "cpu"
|
|
90
|
+
if raw in ("cuda", "gpu", "cuda:0", "0"):
|
|
91
|
+
return "cuda" if _cuda_available() else "cpu"
|
|
92
|
+
return "cpu"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _fw_compute_type(device: str) -> str:
|
|
96
|
+
override = (os.environ.get("MERCURY_VOICE_FW_COMPUTE_TYPE") or "").strip()
|
|
97
|
+
if override:
|
|
98
|
+
return override
|
|
99
|
+
return "int8" if device == "cuda" else "float32"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _fw_language() -> str | None:
|
|
103
|
+
lang = (os.environ.get("MERCURY_VOICE_LANGUAGE") or "").strip()
|
|
104
|
+
return lang if lang else None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _run_transformers(audio_path: str, model_id: str) -> str:
|
|
108
|
+
try:
|
|
109
|
+
from transformers import pipeline
|
|
110
|
+
except ImportError as e:
|
|
111
|
+
raise ImportError(f"transformers import failed: {e}") from e
|
|
112
|
+
|
|
113
|
+
_ensure_ffmpeg_on_path()
|
|
114
|
+
device = _asr_device_torch()
|
|
115
|
+
pipe = pipeline(
|
|
116
|
+
"automatic-speech-recognition",
|
|
117
|
+
model=model_id,
|
|
118
|
+
device=device,
|
|
119
|
+
)
|
|
120
|
+
result = pipe(audio_path)
|
|
121
|
+
if isinstance(result, dict):
|
|
122
|
+
return (result.get("text") or "").strip()
|
|
123
|
+
return str(result).strip()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _run_faster_whisper(audio_path: str, model_id: str) -> str:
|
|
127
|
+
try:
|
|
128
|
+
from faster_whisper import WhisperModel
|
|
129
|
+
except ImportError as e:
|
|
130
|
+
raise ImportError(
|
|
131
|
+
"faster-whisper is required when local_engine=faster_whisper. "
|
|
132
|
+
"Install: pip install faster-whisper"
|
|
133
|
+
) from e
|
|
134
|
+
|
|
135
|
+
_ensure_ffmpeg_on_path()
|
|
136
|
+
device = _fw_device_str()
|
|
137
|
+
compute_type = _fw_compute_type(device)
|
|
138
|
+
lang = _fw_language()
|
|
139
|
+
|
|
140
|
+
model = WhisperModel(model_id, device=device, compute_type=compute_type)
|
|
141
|
+
kwargs: dict = {}
|
|
142
|
+
if lang:
|
|
143
|
+
kwargs["language"] = lang
|
|
144
|
+
segments, _info = model.transcribe(audio_path, **kwargs)
|
|
145
|
+
parts: list[str] = []
|
|
146
|
+
for seg in segments:
|
|
147
|
+
parts.append(seg.text)
|
|
148
|
+
return "".join(parts).strip()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main() -> None:
|
|
152
|
+
ap = argparse.ArgumentParser(description="ASR for Mercury voice-transcribe (local provider)")
|
|
153
|
+
ap.add_argument("--audio", required=True, help="Path to audio file on disk")
|
|
154
|
+
ap.add_argument("--model", required=True, help="Hugging Face model id")
|
|
155
|
+
ap.add_argument(
|
|
156
|
+
"--local-engine",
|
|
157
|
+
choices=("transformers", "faster_whisper"),
|
|
158
|
+
default="transformers",
|
|
159
|
+
help="Local ASR backend (default: transformers)",
|
|
160
|
+
)
|
|
161
|
+
args = ap.parse_args()
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
if args.local_engine == "faster_whisper":
|
|
165
|
+
text = _run_faster_whisper(args.audio, args.model)
|
|
166
|
+
else:
|
|
167
|
+
text = _run_transformers(args.audio, args.model)
|
|
168
|
+
except ImportError as e:
|
|
169
|
+
print(json.dumps({"error": str(e)}))
|
|
170
|
+
sys.exit(2)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
print(json.dumps({"error": str(e)}))
|
|
173
|
+
sys.exit(1)
|
|
174
|
+
|
|
175
|
+
print(json.dumps({"text": text}))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
main()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: voice-transcribe
|
|
3
|
+
description: Voice notes are transcribed to text before the agent runs (local Python or Hugging Face Inference API).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Voice transcription
|
|
7
|
+
|
|
8
|
+
When a user sends a **voice note** or **audio** attachment, Mercury runs the `voice-transcribe` extension **before** the container starts. The transcript is appended to the user message as a `[Voice transcript]` block. You receive normal text — you do not need to read or play audio files for intent.
|
|
9
|
+
|
|
10
|
+
## Configuration (per space)
|
|
11
|
+
|
|
12
|
+
| Key | Purpose |
|
|
13
|
+
|-----|---------|
|
|
14
|
+
| `voice-transcribe.provider` | `local` (default) or `api` |
|
|
15
|
+
| `voice-transcribe.model` | Hugging Face model id (see below) |
|
|
16
|
+
| `voice-transcribe.local_engine` | Local only: `transformers` (default) or `faster_whisper` (CTranslate2 Hub repos) |
|
|
17
|
+
|
|
18
|
+
### Local provider (`local`)
|
|
19
|
+
|
|
20
|
+
Runs `scripts/transcribe.py` on the **Mercury host**.
|
|
21
|
+
|
|
22
|
+
**Transformers (default)** — `voice-transcribe.local_engine=transformers` uses [Transformers](https://huggingface.co/docs/transformers) `pipeline("automatic-speech-recognition", model=...)`. Use standard PyTorch Whisper / compatible Hub ids (e.g. [mike249/whisper-tiny-he-2](https://huggingface.co/mike249/whisper-tiny-he-2)).
|
|
23
|
+
|
|
24
|
+
**Faster-Whisper** — set `voice-transcribe.local_engine=faster_whisper` and [faster-whisper](https://github.com/SYSTRAN/faster-whisper) loads **CTranslate2** checkpoints from the Hub. Set `voice-transcribe.model` to a **CT2** repo (not plain PyTorch Whisper weights). Examples for Hebrew: [ivrit-ai/faster-whisper-v2-d4](https://huggingface.co/ivrit-ai/faster-whisper-v2-d4), [ivrit-ai/whisper-large-v3-turbo-ct2](https://huggingface.co/ivrit-ai/whisper-large-v3-turbo-ct2).
|
|
25
|
+
|
|
26
|
+
1. Install on the same machine that runs Mercury:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install -r /path/to/.mercury/extensions/voice-transcribe/requirements.txt
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or: `pip install "transformers>=4.40.0" "torch>=2.0.0" "faster-whisper>=1.0.0" "imageio-ffmpeg>=0.4.9"`
|
|
33
|
+
|
|
34
|
+
**Telegram voice** is usually `.ogg` (Opus). Both backends shell out to an executable named `ffmpeg`; `transcribe.py` uses `imageio-ffmpeg`’s binary and exposes it as `ffmpeg` / `ffmpeg.exe` on `PATH` (hardlink or copy under `%TEMP%\\mercury-voice-ffmpeg` on Windows). Installing system ffmpeg on `PATH` also works (e.g. `winget install Gyan.FFmpeg`).
|
|
35
|
+
|
|
36
|
+
2. Optional host `.env`:
|
|
37
|
+
- `MERCURY_VOICE_PYTHON` — Python executable (default: `python` on Windows, `python3` elsewhere)
|
|
38
|
+
- `MERCURY_VOICE_TRANSCRIBE_TIMEOUT_MS` — subprocess timeout in ms (default `300000`)
|
|
39
|
+
- `MERCURY_VOICE_ASR_DEVICE` — `cpu`, `cuda`, or `auto`. Used by **both** local engines. On Windows the default is **CPU** (CUDA often looks available but fails at inference). Set `cuda` if you have a working GPU stack.
|
|
40
|
+
- `MERCURY_VOICE_FW_COMPUTE_TYPE` — **Faster-Whisper only**: e.g. `int8`, `float16`, `float32`. If unset: `int8` on CUDA, `float32` on CPU.
|
|
41
|
+
- `MERCURY_VOICE_LANGUAGE` — **Faster-Whisper only**: ISO code passed to `transcribe(..., language=...)` (e.g. `he`). If unset, language is auto-detected.
|
|
42
|
+
|
|
43
|
+
Hub messages like *Xet Storage… hf_xet* are warnings only, not the cause of failures.
|
|
44
|
+
|
|
45
|
+
First run downloads the model into the Hugging Face cache (size depends on the model). **Each voice note spawns a new Python process**, so the first transcription after Mercury starts can be slow while the model loads (no in-process reuse yet).
|
|
46
|
+
|
|
47
|
+
### API provider (`api`)
|
|
48
|
+
|
|
49
|
+
POSTs audio to `https://api-inference.huggingface.co/models/<model>`. Requires `MERCURY_HF_TOKEN` on the host. Pick a model that has an [Inference Provider](https://huggingface.co/docs/api-inference) on its Hub page (e.g. `openai/whisper-large-v3`). Hebrew-tuned models such as [mike249/whisper-tiny-he-2](https://huggingface.co/mike249/whisper-tiny-he-2) often have **no** hosted provider — use **`local`** for those. CT2 / Faster-Whisper Hub repos are for the **local** engine only, not this API.
|
|
50
|
+
|
|
51
|
+
## RBAC
|
|
52
|
+
|
|
53
|
+
Only callers with the `voice-transcribe` permission (default: admin + member) get transcription. Others keep the raw message without an appended transcript.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { MercuryExtensionAPI } from "../../../src/extensions/types.js";
|
|
2
|
+
|
|
3
|
+
export default function (mercury: MercuryExtensionAPI) {
|
|
4
|
+
// pinchtab CLI is already declared by the pinchtab extension — do NOT re-declare here.
|
|
5
|
+
mercury.permission({ defaultRoles: ["admin", "member"] });
|
|
6
|
+
mercury.skill("./skill");
|
|
7
|
+
|
|
8
|
+
// The pinchtab extension injects "always use Brave Search, never use Google" into the
|
|
9
|
+
// system prompt. Structured real-world searches (flights, hotels, etc.) legitimately
|
|
10
|
+
// need Google Travel products and other specialist sites. Append an explicit exception
|
|
11
|
+
// so the agent follows the web-search skill rather than defaulting to Brave.
|
|
12
|
+
mercury.on("before_container", async () => {
|
|
13
|
+
return {
|
|
14
|
+
systemPrompt:
|
|
15
|
+
"Exception to the Brave Search rule: for structured real-world searches " +
|
|
16
|
+
"(flights, hotels, car rentals, cars for purchase, apartments/rentals), " +
|
|
17
|
+
"follow the web-search skill — use the specific sites it specifies " +
|
|
18
|
+
"(e.g. Google Flights, Google Hotels, AutoScout24, Airbnb). " +
|
|
19
|
+
"Do NOT route these requests to Brave Search.",
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
}
|