bloby-bot 0.54.10 → 0.54.11
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/package.json
CHANGED
|
@@ -44,11 +44,13 @@ const CLIENT_INFO = { name: 'bloby', title: 'Bloby', version: '1' };
|
|
|
44
44
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
45
45
|
const VALID_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']);
|
|
46
46
|
/**
|
|
47
|
-
* Per-turn watchdog. `turn/completed` is a NON-guaranteed notification — if
|
|
48
|
-
* app-server stalls mid-turn without exiting, the RPC `exit` handler never
|
|
49
|
-
* and `busy` stays true forever (live: wedges the dashboard + defers
|
|
50
|
-
* restarts; one-shot: pins the WhatsApp/scheduler slot since bot:done
|
|
51
|
-
* arrives).
|
|
47
|
+
* Per-turn IDLE watchdog. `turn/completed` is a NON-guaranteed notification — if
|
|
48
|
+
* the app-server stalls mid-turn without exiting, the RPC `exit` handler never
|
|
49
|
+
* fires and `busy` stays true forever (live: wedges the dashboard + defers
|
|
50
|
+
* backend restarts; one-shot: pins the WhatsApp/scheduler slot since bot:done
|
|
51
|
+
* never arrives). This is an IDLE timeout, reset on every notification for the
|
|
52
|
+
* conversation — a legitimately long turn (deep reasoning, many tool calls)
|
|
53
|
+
* keeps emitting events and is never killed; only true silence trips recovery.
|
|
52
54
|
*/
|
|
53
55
|
const TURN_WATCHDOG_MS = 5 * 60_000;
|
|
54
56
|
|
|
@@ -172,8 +174,8 @@ class CodexRpc {
|
|
|
172
174
|
private closed = false;
|
|
173
175
|
private stderrBuf = '';
|
|
174
176
|
|
|
175
|
-
start(): void {
|
|
176
|
-
this.proc = spawn(resolveCodexBin(), ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
177
|
+
start(extraArgs: string[] = []): void {
|
|
178
|
+
this.proc = spawn(resolveCodexBin(), ['app-server', ...extraArgs], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
177
179
|
const rl = readline.createInterface({ input: this.proc.stdout });
|
|
178
180
|
rl.on('line', (line) => this.onLine(line));
|
|
179
181
|
|
|
@@ -246,14 +248,30 @@ class CodexRpc {
|
|
|
246
248
|
}
|
|
247
249
|
|
|
248
250
|
private handleServerRequest(msg: { id: number; method: string; params?: any }): void {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
251
|
+
// Responses are OBJECTS, not bare strings: CommandExecution/FileChange approval
|
|
252
|
+
// responses are `{ decision }` (CommandExecutionApprovalDecision / FileChangeApprovalDecision),
|
|
253
|
+
// and the legacy v1 aliases take `{ decision }` with the ReviewDecision enum.
|
|
254
|
+
// (None of these fire under approvalPolicy:'never' + danger-full-access, but reply
|
|
255
|
+
// correctly so an edge-case request can't stall the turn with a malformed reply.)
|
|
256
|
+
switch (msg.method) {
|
|
257
|
+
case 'item/commandExecution/requestApproval':
|
|
258
|
+
case 'item/fileChange/requestApproval':
|
|
259
|
+
log.info(`[codex-rpc] auto-accepting ${msg.method}`);
|
|
260
|
+
this.respond(msg.id, { decision: 'acceptForSession' });
|
|
261
|
+
return;
|
|
262
|
+
case 'execCommandApproval':
|
|
263
|
+
case 'applyPatchApproval':
|
|
264
|
+
log.info(`[codex-rpc] auto-accepting (legacy) ${msg.method}`);
|
|
265
|
+
this.respond(msg.id, { decision: 'approved_for_session' });
|
|
266
|
+
return;
|
|
267
|
+
// account/chatgptAuthTokens/refresh is only used by client-managed-token
|
|
268
|
+
// clients; Bloby authenticates via chatgpt OAuth and the app-server refreshes
|
|
269
|
+
// ~/.codex/auth.json itself, so this server-request never fires for us. Decline
|
|
270
|
+
// cleanly (a stale-credential edge would surface as a normal turn error instead).
|
|
271
|
+
default:
|
|
272
|
+
log.warn(`[codex-rpc] unhandled server request ${msg.method} — replying -32601`);
|
|
273
|
+
this.respondError(msg.id, -32601, `Method ${msg.method} not implemented by Bloby client`);
|
|
254
274
|
}
|
|
255
|
-
log.warn(`[codex-rpc] unhandled server request ${msg.method} — replying with error`);
|
|
256
|
-
this.respondError(msg.id, -32601, `Method ${msg.method} not implemented by Bloby client`);
|
|
257
275
|
}
|
|
258
276
|
|
|
259
277
|
private respond(id: number, result: any): void {
|
|
@@ -304,8 +322,16 @@ class CodexRpc {
|
|
|
304
322
|
p.reject(new Error('RPC connection closed'));
|
|
305
323
|
}
|
|
306
324
|
this.pending.clear();
|
|
307
|
-
|
|
308
|
-
try {
|
|
325
|
+
const proc = this.proc;
|
|
326
|
+
try { proc?.stdin.end(); } catch {}
|
|
327
|
+
try { proc?.kill('SIGTERM'); } catch {}
|
|
328
|
+
// Escalate to SIGKILL if the app-server ignores SIGTERM (no true leak today
|
|
329
|
+
// since SIGTERM reaps it, but a SIGTERM-ignoring build would otherwise survive).
|
|
330
|
+
if (proc) {
|
|
331
|
+
const t = setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 2000);
|
|
332
|
+
if (typeof t.unref === 'function') t.unref();
|
|
333
|
+
proc.once('exit', () => clearTimeout(t));
|
|
334
|
+
}
|
|
309
335
|
this.proc = null;
|
|
310
336
|
}
|
|
311
337
|
}
|
|
@@ -320,6 +346,9 @@ interface CodexConversation {
|
|
|
320
346
|
onMessage: OnAgentMessage;
|
|
321
347
|
/** Currently in-flight turn id (set on `turn/started`, cleared on `turn/completed`). */
|
|
322
348
|
currentTurnId: string | null;
|
|
349
|
+
/** itemId of the agentMessage currently streaming — used to insert a paragraph
|
|
350
|
+
* break when a turn emits multiple separate agentMessage items. */
|
|
351
|
+
currentMsgItemId: string | null;
|
|
323
352
|
/** Streaming text accumulator for the current turn's agentMessage items. */
|
|
324
353
|
fullText: string;
|
|
325
354
|
/** Tools/items used during the current turn, for the bot:turn-complete payload. */
|
|
@@ -462,10 +491,14 @@ async function steerOrQueue(conv: CodexConversation, content: string, savedFiles
|
|
|
462
491
|
|
|
463
492
|
function handleNotification(conv: CodexConversation, n: { method: string; params?: any }): void {
|
|
464
493
|
const p = n.params || {};
|
|
494
|
+
// Any notification for this conv proves the app-server is alive and working —
|
|
495
|
+
// reset the idle watchdog so a long-but-active turn isn't torn down.
|
|
496
|
+
if (conv.turnWatchdog) armTurnWatchdog(conv);
|
|
465
497
|
switch (n.method) {
|
|
466
498
|
case 'turn/started': {
|
|
467
499
|
conv.currentTurnId = p.turn?.id || null;
|
|
468
500
|
conv.fullText = '';
|
|
501
|
+
conv.currentMsgItemId = null;
|
|
469
502
|
conv.usedFileTools = false;
|
|
470
503
|
break;
|
|
471
504
|
}
|
|
@@ -473,6 +506,13 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
|
|
|
473
506
|
case 'item/agentMessage/delta': {
|
|
474
507
|
const delta: string = p.delta || '';
|
|
475
508
|
if (!delta) break;
|
|
509
|
+
// A turn can emit multiple agentMessage items (commentary then final_answer).
|
|
510
|
+
// On a new itemId, insert a paragraph break so they don't run together (mirrors claude.ts).
|
|
511
|
+
if (p.itemId && conv.currentMsgItemId && p.itemId !== conv.currentMsgItemId && conv.fullText && !conv.fullText.endsWith('\n')) {
|
|
512
|
+
conv.fullText += '\n\n';
|
|
513
|
+
conv.onMessage('bot:token', { conversationId: conv.id, token: '\n\n' });
|
|
514
|
+
}
|
|
515
|
+
if (p.itemId) conv.currentMsgItemId = p.itemId;
|
|
476
516
|
conv.fullText += delta;
|
|
477
517
|
conv.onMessage('bot:token', { conversationId: conv.id, token: delta });
|
|
478
518
|
break;
|
|
@@ -512,6 +552,17 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
|
|
|
512
552
|
input: { query: item.query || '' },
|
|
513
553
|
});
|
|
514
554
|
break;
|
|
555
|
+
case 'collabAgentToolCall':
|
|
556
|
+
// Codex's collaborating sub-agents (rarely enabled) → Bloby's sub-agent UX.
|
|
557
|
+
if (item.tool === 'spawnAgent') {
|
|
558
|
+
conv.onMessage('bot:task-created', {
|
|
559
|
+
conversationId: conv.id,
|
|
560
|
+
taskId: item.id,
|
|
561
|
+
description: item.prompt || 'sub-agent',
|
|
562
|
+
type: 'collab',
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
break;
|
|
515
566
|
// userMessage / agentMessage / reasoning — no tool-style event.
|
|
516
567
|
}
|
|
517
568
|
break;
|
|
@@ -528,6 +579,14 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
|
|
|
528
579
|
conv.onMessage('bot:token', { conversationId: conv.id, token: text });
|
|
529
580
|
}
|
|
530
581
|
}
|
|
582
|
+
if (item.type === 'collabAgentToolCall' && item.tool === 'spawnAgent') {
|
|
583
|
+
conv.onMessage('bot:task-done', {
|
|
584
|
+
conversationId: conv.id,
|
|
585
|
+
taskId: item.id,
|
|
586
|
+
status: item.status,
|
|
587
|
+
summary: item.prompt || '',
|
|
588
|
+
});
|
|
589
|
+
}
|
|
531
590
|
break;
|
|
532
591
|
}
|
|
533
592
|
|
|
@@ -598,8 +657,17 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
|
|
|
598
657
|
break;
|
|
599
658
|
}
|
|
600
659
|
|
|
601
|
-
|
|
602
|
-
|
|
660
|
+
case 'mcpServer/startupStatus/updated': {
|
|
661
|
+
// Surface MCP servers (from MCP.json → -c overrides) that fail to start,
|
|
662
|
+
// so a misconfigured server is visible instead of silently absent.
|
|
663
|
+
if (p.status === 'failed' || p.status === 'cancelled') {
|
|
664
|
+
log.warn(`[codex] MCP server "${p.name}" ${p.status}${p.error ? `: ${p.error}` : ''}`);
|
|
665
|
+
}
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// thread/started, thread/status/changed, remoteControl/status/changed —
|
|
670
|
+
// informational, no-op for the dashboard.
|
|
603
671
|
}
|
|
604
672
|
}
|
|
605
673
|
|
|
@@ -631,7 +699,7 @@ async function spawnAndInitialize(
|
|
|
631
699
|
|
|
632
700
|
const { id: modelId, effort } = parseModelString(model);
|
|
633
701
|
const rpc = new CodexRpc();
|
|
634
|
-
rpc.start();
|
|
702
|
+
rpc.start(buildMcpConfigArgs());
|
|
635
703
|
|
|
636
704
|
const conv: CodexConversation = {
|
|
637
705
|
id: conversationId,
|
|
@@ -640,6 +708,7 @@ async function spawnAndInitialize(
|
|
|
640
708
|
effort,
|
|
641
709
|
onMessage,
|
|
642
710
|
currentTurnId: null,
|
|
711
|
+
currentMsgItemId: null,
|
|
643
712
|
fullText: '',
|
|
644
713
|
usedFileTools: false,
|
|
645
714
|
pendingInputs: [],
|
|
@@ -703,27 +772,109 @@ async function spawnAndInitialize(
|
|
|
703
772
|
}
|
|
704
773
|
|
|
705
774
|
const SKILLS_DIR = path.join(WORKSPACE_DIR, 'skills');
|
|
775
|
+
// Codex discovers "repo"-scope skills under `<cwd>/.codex/skills` (verified
|
|
776
|
+
// against 0.135.0 — a bare `<cwd>/skills` is NOT scanned, and `skills/list`
|
|
777
|
+
// has no extra-root param). Bloby keeps the canonical skills in
|
|
778
|
+
// `workspace/skills/<name>`, so we mirror each one into `.codex/skills/<name>`
|
|
779
|
+
// as a symlink — single source of truth, discoverable by codex's native router.
|
|
780
|
+
// (Each SKILL.md needs YAML frontmatter or codex rejects it — see SKILL_FORMAT_MIGRATION.md.)
|
|
781
|
+
const CODEX_SKILLS_ROOT = path.join(WORKSPACE_DIR, '.codex', 'skills');
|
|
782
|
+
|
|
783
|
+
/** Mirror workspace/skills/<name> → workspace/.codex/skills/<name> as symlinks (idempotent). */
|
|
784
|
+
function syncCodexSkillRoot(): void {
|
|
785
|
+
let names: string[] = [];
|
|
786
|
+
try {
|
|
787
|
+
names = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
|
|
788
|
+
.filter((e) => e.isDirectory() || e.isSymbolicLink())
|
|
789
|
+
.map((e) => e.name);
|
|
790
|
+
} catch {
|
|
791
|
+
return; // no skills dir — nothing to mirror
|
|
792
|
+
}
|
|
793
|
+
try { fs.mkdirSync(CODEX_SKILLS_ROOT, { recursive: true }); } catch {}
|
|
794
|
+
for (const name of names) {
|
|
795
|
+
const target = path.join(SKILLS_DIR, name);
|
|
796
|
+
const link = path.join(CODEX_SKILLS_ROOT, name);
|
|
797
|
+
try {
|
|
798
|
+
const cur = fs.existsSync(link) ? fs.realpathSync(link) : null;
|
|
799
|
+
if (cur === fs.realpathSync(target)) continue; // already correct
|
|
800
|
+
try { fs.rmSync(link, { recursive: true, force: true }); } catch {}
|
|
801
|
+
fs.symlinkSync(target, link, 'dir');
|
|
802
|
+
} catch (err: any) {
|
|
803
|
+
log.warn(`[codex] could not mirror skill "${name}" into .codex/skills: ${err.message}`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
706
807
|
|
|
707
808
|
function primeWorkspaceSkills(rpc: CodexRpc): void {
|
|
809
|
+
syncCodexSkillRoot();
|
|
708
810
|
rpc.request('skills/list', {
|
|
709
811
|
cwds: [WORKSPACE_DIR],
|
|
710
812
|
forceReload: true,
|
|
711
|
-
perCwdExtraUserRoots: [{
|
|
712
|
-
cwd: WORKSPACE_DIR,
|
|
713
|
-
extraUserRoots: [SKILLS_DIR],
|
|
714
|
-
}],
|
|
715
813
|
}).then((result: any) => {
|
|
716
814
|
const entry = result?.data?.[0];
|
|
717
815
|
const all = entry?.skills ?? [];
|
|
718
|
-
const
|
|
816
|
+
const repo = all.filter((s: any) => s.scope === 'repo');
|
|
719
817
|
const errors = entry?.errors ?? [];
|
|
720
|
-
log.ok(`[codex] skills primed: ${
|
|
818
|
+
log.ok(`[codex] skills primed: ${repo.length} workspace (repo), ${all.length - repo.length} user/system${errors.length ? `, ${errors.length} rejected` : ''}`);
|
|
721
819
|
for (const err of errors) log.warn(`[codex] skill load error: ${err.path} — ${err.message}`);
|
|
722
820
|
}).catch((err: any) => {
|
|
723
821
|
log.warn(`[codex] skills/list failed: ${err.message}`);
|
|
724
822
|
});
|
|
725
823
|
}
|
|
726
824
|
|
|
825
|
+
/* ── MCP wiring ────────────────────────────────────────────────────────── */
|
|
826
|
+
|
|
827
|
+
const MCP_CONFIG_FILE = path.join(WORKSPACE_DIR, 'MCP.json');
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Load MCP servers from workspace/MCP.json (the same file the Claude harness
|
|
831
|
+
* reads). Accepts the canonical unwrapped map `{ name: { command, args, env } }`,
|
|
832
|
+
* a `{ mcpServers: {...} }` wrapper, or a legacy array of single-key maps.
|
|
833
|
+
* Returns {} when absent/invalid — so this is a no-op until the user populates MCP.json.
|
|
834
|
+
*/
|
|
835
|
+
function loadMcpServersForCodex(): Record<string, any> {
|
|
836
|
+
try {
|
|
837
|
+
const raw = JSON.parse(fs.readFileSync(MCP_CONFIG_FILE, 'utf-8'));
|
|
838
|
+
let map: any = raw;
|
|
839
|
+
if (raw && typeof raw === 'object' && raw.mcpServers && typeof raw.mcpServers === 'object') map = raw.mcpServers;
|
|
840
|
+
else if (Array.isArray(raw)) map = Object.assign({}, ...raw);
|
|
841
|
+
if (map && typeof map === 'object' && !Array.isArray(map)) return map;
|
|
842
|
+
} catch {}
|
|
843
|
+
return {};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Serialize a JS value as a TOML literal for a `-c key=value` override. */
|
|
847
|
+
function toToml(v: any): string {
|
|
848
|
+
if (Array.isArray(v)) return `[${v.map(toToml).join(',')}]`;
|
|
849
|
+
if (v && typeof v === 'object') return `{${Object.entries(v).map(([k, val]) => `${JSON.stringify(k)}=${toToml(val)}`).join(',')}}`;
|
|
850
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
851
|
+
return JSON.stringify(String(v)); // TOML basic string — JSON escaping is compatible
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Translate MCP.json into `codex app-server -c mcp_servers.<name>.<field>=<toml>`
|
|
856
|
+
* spawn flags. Codex sources MCP from its own config layer rather than a per-query
|
|
857
|
+
* param (verified against 0.135.0: a `-c mcp_servers.X.command=...` override shows
|
|
858
|
+
* up in both mcpServerStatus/list and config/read). Only the stdio fields Bloby
|
|
859
|
+
* uses (command/args/env) are translated; names must be TOML-bare-key safe.
|
|
860
|
+
*/
|
|
861
|
+
function buildMcpConfigArgs(): string[] {
|
|
862
|
+
const servers = loadMcpServersForCodex();
|
|
863
|
+
const args: string[] = [];
|
|
864
|
+
let wired = 0;
|
|
865
|
+
for (const [name, cfg] of Object.entries(servers)) {
|
|
866
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name)) { log.warn(`[codex] skipping MCP server "${name}" — name not TOML-bare-key safe`); continue; }
|
|
867
|
+
const c: any = cfg || {};
|
|
868
|
+
if (!c.command) { log.warn(`[codex] skipping MCP server "${name}" — no command`); continue; }
|
|
869
|
+
args.push('-c', `mcp_servers.${name}.command=${toToml(c.command)}`);
|
|
870
|
+
if (Array.isArray(c.args) && c.args.length) args.push('-c', `mcp_servers.${name}.args=${toToml(c.args)}`);
|
|
871
|
+
if (c.env && typeof c.env === 'object' && Object.keys(c.env).length) args.push('-c', `mcp_servers.${name}.env=${toToml(c.env)}`);
|
|
872
|
+
wired++;
|
|
873
|
+
}
|
|
874
|
+
if (wired) log.info(`[codex] wiring ${wired} MCP server(s) from MCP.json via -c overrides`);
|
|
875
|
+
return args;
|
|
876
|
+
}
|
|
877
|
+
|
|
727
878
|
/* ── Harness implementation ────────────────────────────────────────────── */
|
|
728
879
|
|
|
729
880
|
export function hasConversation(conversationId: string): boolean {
|
|
@@ -856,7 +1007,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
|
|
|
856
1007
|
const timeout = Math.min(Math.max(req.timeout || 120_000, 5_000), 300_000);
|
|
857
1008
|
|
|
858
1009
|
const rpc = new CodexRpc();
|
|
859
|
-
rpc.start();
|
|
1010
|
+
rpc.start(buildMcpConfigArgs());
|
|
860
1011
|
|
|
861
1012
|
let fullText = '';
|
|
862
1013
|
const usedTools = new Set<string>();
|
package/supervisor/index.ts
CHANGED
|
@@ -1856,6 +1856,28 @@ mint();
|
|
|
1856
1856
|
};
|
|
1857
1857
|
}
|
|
1858
1858
|
|
|
1859
|
+
// Same for Codex OAuth: the app-server reads ~/.codex/auth.json at spawn, so a
|
|
1860
|
+
// running subprocess only adopts a new identity on re-spawn. End live conversations
|
|
1861
|
+
// after a successful exchange so the next message cold-starts with the fresh token.
|
|
1862
|
+
// (Wraps only the one-shot /exchange; the device-code /status route latches
|
|
1863
|
+
// success on every poll and must not re-fire teardown — handled at re-spawn instead.)
|
|
1864
|
+
if (req.method === 'POST' && req.url === '/api/auth/codex/exchange') {
|
|
1865
|
+
const origEnd = res.end.bind(res);
|
|
1866
|
+
(res as any).end = function (this: typeof res, ...args: any[]) {
|
|
1867
|
+
try {
|
|
1868
|
+
const body = typeof args[0] === 'string' ? args[0] : args[0]?.toString();
|
|
1869
|
+
if (body) {
|
|
1870
|
+
const json = JSON.parse(body);
|
|
1871
|
+
if (json.success) {
|
|
1872
|
+
log.info('[orchestrator] Codex re-auth succeeded — restarting conversations with fresh token');
|
|
1873
|
+
endAllConversations();
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
} catch {}
|
|
1877
|
+
return origEnd(...args);
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1859
1881
|
workerApp(req, res);
|
|
1860
1882
|
return;
|
|
1861
1883
|
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mac
|
|
3
|
+
description: "Morphy native macOS companion. Activates on the [Mac] tag. Output is a concise spoken reply (TTS); optionally accompany it with a <notch_html>…</notch_html> visual card rendered inside the MacBook notch (383×147pt, transparent over black). Frequent snippets are cached in workspace/skills/mac/frequentSnippets/ for instant re-use."
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# Mac (Morphy notch)
|
|
2
7
|
|
|
3
8
|
## What This Is
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plaud
|
|
3
|
+
description: "Plaud Note integration. Pairs the user's Plaud account (email OTP or paste-token for Google/Apple identities), pulls recordings into workspace/files/audio/plaud/, and routes transcription through either the Bloby Marketplace audio-to-text service (pay-per-minute) or the human's own provider (Groq / OpenAI Whisper / Mistral Voxtral / local)."
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# Plaud
|
|
2
7
|
|
|
3
8
|
## What This Is
|