@stage-labs/metro 0.1.0-beta.306 → 0.1.0-beta.308
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/dist/agent.js +23 -28
- package/dist/child.js +33 -0
- package/dist/claude-env.js +104 -0
- package/dist/cli.js +7 -21
- package/dist/lock.js +62 -0
- package/dist/memory.js +12 -27
- package/dist/root-release-io.js +1 -2
- package/dist/root-release.js +1 -1
- package/dist/route.js +0 -1
- package/hooks/guard.mjs +1 -5
- package/hooks/session-start.mjs +1 -1
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +13 -38
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +21 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -8
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +6 -18
- package/runtime/node_modules/@metro-labs/daemon/src/claude/github-skills.ts +3 -7
- package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +6 -5
- package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +1 -23
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +32 -112
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +14 -61
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +10 -42
- package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/agent-key.ts +9 -12
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/leftovers.ts +59 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +1 -13
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +15 -35
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +5 -15
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +0 -11
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +3 -7
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +12 -24
- package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -4
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +9 -92
- package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +1 -4
- package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +0 -4
- package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +1 -14
- package/runtime/node_modules/@metro-labs/http/src/agent-signature.ts +5 -20
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +0 -1
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/dist/background.js +0 -141
- package/dist/claude.js +0 -204
- package/dist/mcp-config.js +0 -27
- package/dist/native-recovery-scan.js +0 -246
- package/dist/native-recovery-state.js +0 -154
- package/dist/native-recovery.js +0 -164
- package/dist/onboarding.js +0 -44
- package/dist/voice.js +0 -56
- package/runtime/node_modules/@metro-labs/daemon/src/claude/channel-check.ts +0 -56
- package/runtime/node_modules/@metro-labs/daemon/src/claude/pinned.ts +0 -16
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +0 -121
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +0 -45
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +0 -57
- package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +0 -76
- package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +0 -38
- package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +0 -146
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +0 -54
- package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +0 -264
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { approvalId } from '@metro-labs/core/ids';
|
|
2
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
-
import { forgetPromptsOf, holdPrompt, type Behavior } from '../approvals/pending.js';
|
|
4
|
-
import { promptBody } from '../mcp/permission-prompt.js';
|
|
5
|
-
import type { ToolAsk } from './brain.js';
|
|
6
|
-
|
|
7
|
-
export class CallApprovals {
|
|
8
|
-
private closed = false;
|
|
9
|
-
|
|
10
|
-
constructor(
|
|
11
|
-
private readonly line: string,
|
|
12
|
-
private readonly post: (text: string) => Promise<void>,
|
|
13
|
-
) {}
|
|
14
|
-
|
|
15
|
-
ask(ask: ToolAsk, answer: (behavior: Behavior) => void): string | null {
|
|
16
|
-
if (this.closed) return null;
|
|
17
|
-
const id = approvalId();
|
|
18
|
-
const preview = JSON.stringify(ask.input);
|
|
19
|
-
holdPrompt(
|
|
20
|
-
{ requestId: id, tool: ask.tool, description: ask.description, preview, line: this.line, at: Date.now() },
|
|
21
|
-
this,
|
|
22
|
-
(behavior) => {
|
|
23
|
-
answer(behavior);
|
|
24
|
-
return Promise.resolve();
|
|
25
|
-
},
|
|
26
|
-
this.post,
|
|
27
|
-
);
|
|
28
|
-
this.post(promptBody({ request_id: id, tool_name: ask.tool, description: ask.description, input_preview: preview })).catch((err: unknown) => {
|
|
29
|
-
log.warn({ err: errMsg(err) }, 'voice: could not ask for an approval in the chat of the call');
|
|
30
|
-
});
|
|
31
|
-
return id;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
close(): void {
|
|
35
|
-
this.closed = true;
|
|
36
|
-
forgetPromptsOf(this);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
-
import { drainLines } from '@metro-labs/core/trains/protocol';
|
|
5
|
-
import { asAgent } from '../agent-user/user.js';
|
|
6
|
-
import { metroCli } from '../claude/session.js';
|
|
7
|
-
|
|
8
|
-
const CLOSE_GRACE_MS = 10_000;
|
|
9
|
-
const DENIED = 'The owner did not approve this.';
|
|
10
|
-
|
|
11
|
-
export interface ToolAsk {
|
|
12
|
-
requestId: string;
|
|
13
|
-
tool: string;
|
|
14
|
-
input: Record<string, unknown>;
|
|
15
|
-
description: string;
|
|
16
|
-
fromWorker: boolean;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface Thinking {
|
|
20
|
-
turnStarted(): void;
|
|
21
|
-
text(delta: string): void;
|
|
22
|
-
tool(name: string): void;
|
|
23
|
-
turnEnded(): void;
|
|
24
|
-
asked(ask: ToolAsk): void;
|
|
25
|
-
exited(reason: string): void;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const record = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
|
|
29
|
-
const str = (value: unknown): string => (typeof value === 'string' ? value : '');
|
|
30
|
-
|
|
31
|
-
export function toolAsk(msg: Record<string, unknown>): ToolAsk | null {
|
|
32
|
-
const request = record(msg.request);
|
|
33
|
-
const requestId = str(msg.request_id);
|
|
34
|
-
if (request.subtype !== 'can_use_tool' || requestId === '' || str(request.tool_name) === '') return null;
|
|
35
|
-
return {
|
|
36
|
-
requestId,
|
|
37
|
-
tool: str(request.tool_name),
|
|
38
|
-
input: record(request.input),
|
|
39
|
-
description: str(request.description) || str(request.decision_reason),
|
|
40
|
-
fromWorker: str(request.agent_id) !== '',
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function streamEvent(msg: Record<string, unknown>, mind: Thinking): void {
|
|
45
|
-
if (msg.parent_tool_use_id !== null && msg.parent_tool_use_id !== undefined) return;
|
|
46
|
-
const event = record(msg.event);
|
|
47
|
-
const delta = record(event.delta);
|
|
48
|
-
if (event.type === 'content_block_delta' && delta.type === 'text_delta' && typeof delta.text === 'string') mind.text(delta.text);
|
|
49
|
-
const block = record(event.content_block);
|
|
50
|
-
if (event.type === 'content_block_start' && block.type === 'tool_use') mind.tool(typeof block.name === 'string' ? block.name : 'tool');
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function startBrain(model: string): ChildProcessWithoutNullStreams {
|
|
54
|
-
const [command = 'metro', ...args] = metroCli(['voice', '--model', model]);
|
|
55
|
-
const [file, argv] = asAgent(command, args);
|
|
56
|
-
return spawn(file, argv, { stdio: ['pipe', 'pipe', 'pipe'], cwd: '/' });
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export class Brain {
|
|
60
|
-
private readonly child: ChildProcessWithoutNullStreams;
|
|
61
|
-
private out = '';
|
|
62
|
-
private gone = false;
|
|
63
|
-
private closing = false;
|
|
64
|
-
private killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
65
|
-
|
|
66
|
-
constructor(model: string, private readonly mind: Thinking, launch = startBrain) {
|
|
67
|
-
this.child = launch(model);
|
|
68
|
-
this.child.stdout.setEncoding('utf8');
|
|
69
|
-
this.child.stdout.on('data', (chunk: string) => {
|
|
70
|
-
this.out = drainLines('voice-brain', this.out + chunk, (line) => {
|
|
71
|
-
this.read(line);
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
this.child.stderr.setEncoding('utf8');
|
|
75
|
-
this.child.stderr.on('data', (chunk: string) => {
|
|
76
|
-
log.debug({ text: chunk.slice(0, 500) }, 'voice: brain stderr');
|
|
77
|
-
});
|
|
78
|
-
this.child.stdin.on('error', (err) => {
|
|
79
|
-
log.debug({ err: errMsg(err) }, 'voice: brain stdin closed');
|
|
80
|
-
});
|
|
81
|
-
this.child.on('error', (err) => {
|
|
82
|
-
this.exit(`could not start: ${errMsg(err)}`);
|
|
83
|
-
});
|
|
84
|
-
this.child.on('exit', (code, signal) => {
|
|
85
|
-
this.exit(`exited (${String(code ?? signal)})`);
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
tell(text: string, now = false): void {
|
|
90
|
-
const message = { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null, session_id: '' };
|
|
91
|
-
this.write(now ? { ...message, priority: 'now' } : message);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
answer(ask: ToolAsk, behavior: 'allow' | 'deny'): void {
|
|
95
|
-
const response = behavior === 'allow' ? { behavior, updatedInput: ask.input } : { behavior, message: DENIED };
|
|
96
|
-
this.write({ type: 'control_response', response: { subtype: 'success', request_id: ask.requestId, response } });
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
close(): void {
|
|
100
|
-
if (this.gone || this.closing) return;
|
|
101
|
-
this.closing = true;
|
|
102
|
-
this.child.stdin.end();
|
|
103
|
-
this.child.kill('SIGTERM');
|
|
104
|
-
this.killTimer = setTimeout(() => {
|
|
105
|
-
if (!this.gone) this.child.kill('SIGKILL');
|
|
106
|
-
}, CLOSE_GRACE_MS);
|
|
107
|
-
this.killTimer.unref();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
private write(message: Record<string, unknown>): void {
|
|
111
|
-
if (!this.gone && !this.closing && this.child.stdin.writable) this.child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
private read(line: string): void {
|
|
115
|
-
if (this.closing || this.gone) return;
|
|
116
|
-
let parsed: unknown;
|
|
117
|
-
try {
|
|
118
|
-
parsed = JSON.parse(line);
|
|
119
|
-
} catch {
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
const msg = record(parsed);
|
|
123
|
-
if (msg.type === 'system' && msg.subtype === 'init') this.mind.turnStarted();
|
|
124
|
-
else if (msg.type === 'stream_event') streamEvent(msg, this.mind);
|
|
125
|
-
else if (msg.type === 'result') this.mind.turnEnded();
|
|
126
|
-
else if (msg.type === 'control_request') this.control(msg);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
private control(msg: Record<string, unknown>): void {
|
|
130
|
-
const ask = toolAsk(msg);
|
|
131
|
-
if (ask !== null) {
|
|
132
|
-
this.mind.asked(ask);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
const subtype = str(record(msg.request).subtype);
|
|
136
|
-
log.warn({ subtype }, 'voice: the agent session asked for something the call does not handle');
|
|
137
|
-
this.write({ type: 'control_response', response: { subtype: 'error', request_id: str(msg.request_id), error: `not supported on a voice call: ${subtype}` } });
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
private exit(reason: string): void {
|
|
141
|
-
if (this.gone) return;
|
|
142
|
-
this.gone = true;
|
|
143
|
-
clearTimeout(this.killTimer);
|
|
144
|
-
if (!this.closing) this.mind.exited(reason);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
const FIRST_CLAUSE_MIN = 24;
|
|
2
|
-
const LONGEST = 220;
|
|
3
|
-
const SENTENCE_END = /[.!?…]+["')\]]*\s+|\n+/g;
|
|
4
|
-
const CLAUSE_END = /[,;:]\s+/g;
|
|
5
|
-
|
|
6
|
-
function speakable(text: string): string {
|
|
7
|
-
return text
|
|
8
|
-
.replace(/https?:\/\/\S+/g, 'the link')
|
|
9
|
-
.replace(/`{1,3}[^`]*`{1,3}/g, (code) => code.replace(/`/g, ''))
|
|
10
|
-
.replace(/^\s*(?:#+|[-*•]|\d+\.)\s+/gm, '')
|
|
11
|
-
.replace(/[*_~#|<>[\]{}]/g, '')
|
|
12
|
-
.replace(/\s+/g, ' ')
|
|
13
|
-
.trim();
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function lastEnd(text: string, pattern: RegExp): number {
|
|
17
|
-
let end = -1;
|
|
18
|
-
for (const match of text.matchAll(pattern)) end = match.index + match[0].length;
|
|
19
|
-
return end;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function cutAt(text: string, first: boolean): number {
|
|
23
|
-
const sentence = lastEnd(text, SENTENCE_END);
|
|
24
|
-
if (sentence > 0) return sentence;
|
|
25
|
-
if (first && text.length >= FIRST_CLAUSE_MIN) {
|
|
26
|
-
const clause = lastEnd(text, CLAUSE_END);
|
|
27
|
-
if (clause > 0) return clause;
|
|
28
|
-
}
|
|
29
|
-
if (text.length < LONGEST) return -1;
|
|
30
|
-
const space = text.lastIndexOf(' ');
|
|
31
|
-
return space > 0 ? space + 1 : text.length;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export class Chunker {
|
|
35
|
-
private buffer = '';
|
|
36
|
-
private first = true;
|
|
37
|
-
|
|
38
|
-
add(delta: string): string[] {
|
|
39
|
-
this.buffer += delta;
|
|
40
|
-
const at = cutAt(this.buffer, this.first);
|
|
41
|
-
if (at < 0) return [];
|
|
42
|
-
const ready = speakable(this.buffer.slice(0, at));
|
|
43
|
-
this.buffer = this.buffer.slice(at);
|
|
44
|
-
if (ready === '') return [];
|
|
45
|
-
this.first = false;
|
|
46
|
-
return [ready];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
flush(): string[] {
|
|
50
|
-
const rest = speakable(this.buffer);
|
|
51
|
-
this.buffer = '';
|
|
52
|
-
return rest === '' ? [] : [rest];
|
|
53
|
-
}
|
|
54
|
-
}
|
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
import { log } from '@metro-labs/core/log';
|
|
2
|
-
import { downsample3, OpusIn, Player, type Playback } from './audio.js';
|
|
3
|
-
import type { CallApprovals } from './approvals.js';
|
|
4
|
-
import { Brain, type Thinking, type ToolAsk } from './brain.js';
|
|
5
|
-
import { Scribe, VAD_SILENCE_SECS } from './scribe.js';
|
|
6
|
-
import { Utterance } from './speech.js';
|
|
7
|
-
import { Chunker } from './speakable.js';
|
|
8
|
-
import { brainModel, languageOf, voiceIdOf, type VoiceConfig } from './store.js';
|
|
9
|
-
|
|
10
|
-
const BARGE_MIN_WORDS = 2;
|
|
11
|
-
|
|
12
|
-
interface Reply {
|
|
13
|
-
utterance: Utterance;
|
|
14
|
-
said: string;
|
|
15
|
-
heard: number;
|
|
16
|
-
chunker: Chunker;
|
|
17
|
-
askedAt: number | null;
|
|
18
|
-
saidAt: number | null;
|
|
19
|
-
firstText: number | null;
|
|
20
|
-
firstAudio: number | null;
|
|
21
|
-
firstPlayed: number | null;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const toolName = (tool: string): string => tool.split('__').pop() ?? tool;
|
|
25
|
-
|
|
26
|
-
const approvalNote = (tool: string, id: string): string =>
|
|
27
|
-
`[The owner was asked in the chat of this call to approve ${toolName(tool)}; they answer "yes ${id}" or "no ${id}" there. Unless you already said so, tell the caller in one short sentence that it waits for their answer in the chat.]`;
|
|
28
|
-
|
|
29
|
-
const wordsIn = (text: string): number => text.split(/\s+/).filter((word) => /\p{L}/u.test(word)).length;
|
|
30
|
-
|
|
31
|
-
const since = (start: number | null, at: number | null): number | null =>
|
|
32
|
-
start === null || at === null ? null : Math.round(at - start);
|
|
33
|
-
|
|
34
|
-
function heardNote(reply: Reply | null): string | null {
|
|
35
|
-
if (reply === null || reply.said.trim() === '' || reply.heard >= reply.said.trim().length - 2) return null;
|
|
36
|
-
const part = reply.said.slice(0, reply.heard).trim();
|
|
37
|
-
return part === ''
|
|
38
|
-
? '[The caller interrupted you before hearing your reply.]'
|
|
39
|
-
: `[The caller interrupted you. They heard only: "${part}". The rest of your reply was not heard.]`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export class Talk implements Thinking {
|
|
43
|
-
private readonly brain: Brain;
|
|
44
|
-
private readonly scribe: Scribe;
|
|
45
|
-
private readonly opus = new OpusIn();
|
|
46
|
-
private readonly player: Player;
|
|
47
|
-
private spare: Utterance | null = null;
|
|
48
|
-
private reply: Reply | null = null;
|
|
49
|
-
private cut: string | null = null;
|
|
50
|
-
private muted = false;
|
|
51
|
-
private askedAt: number | null = null;
|
|
52
|
-
private saidAt: number | null = null;
|
|
53
|
-
private lastHeardAt: number | null = null;
|
|
54
|
-
private readonly delays: number[] = [];
|
|
55
|
-
private live = false;
|
|
56
|
-
private finished = false;
|
|
57
|
-
private spoke = false;
|
|
58
|
-
|
|
59
|
-
constructor(
|
|
60
|
-
private readonly cfg: VoiceConfig,
|
|
61
|
-
out: Playback['send'],
|
|
62
|
-
private readonly ended: (reason: string) => void,
|
|
63
|
-
private readonly approvals: CallApprovals,
|
|
64
|
-
) {
|
|
65
|
-
const playback: Playback = {
|
|
66
|
-
send: out,
|
|
67
|
-
played: (chars) => {
|
|
68
|
-
this.played(chars);
|
|
69
|
-
},
|
|
70
|
-
};
|
|
71
|
-
this.player = new Player(playback);
|
|
72
|
-
this.player.whenFirstPlayed(() => {
|
|
73
|
-
this.firstPlayed();
|
|
74
|
-
});
|
|
75
|
-
this.player.whenFailed((reason) => {
|
|
76
|
-
if (!this.finished) this.ended(`audio playback failed: ${reason}`);
|
|
77
|
-
});
|
|
78
|
-
this.brain = new Brain(brainModel(cfg), this);
|
|
79
|
-
this.scribe = new Scribe(cfg.apiKey, languageOf(cfg), {
|
|
80
|
-
partial: (text) => {
|
|
81
|
-
this.partial(text);
|
|
82
|
-
},
|
|
83
|
-
committed: (text) => {
|
|
84
|
-
this.committed(text);
|
|
85
|
-
},
|
|
86
|
-
failed: (reason) => {
|
|
87
|
-
this.ended(reason);
|
|
88
|
-
},
|
|
89
|
-
});
|
|
90
|
-
this.spare = this.utterance();
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
prime(context: string): void {
|
|
94
|
-
if (!this.finished) this.brain.tell(context);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
connect(): void {
|
|
98
|
-
if (this.finished || this.live) return;
|
|
99
|
-
this.live = true;
|
|
100
|
-
this.spare ??= this.utterance();
|
|
101
|
-
this.player.start();
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
hear(packet: Buffer): void {
|
|
105
|
-
if (this.finished) return;
|
|
106
|
-
const pcm = this.opus.decode(packet);
|
|
107
|
-
if (pcm !== null) this.scribe.send(downsample3(pcm));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
finish(): void {
|
|
111
|
-
if (this.finished) return;
|
|
112
|
-
this.finished = true;
|
|
113
|
-
this.live = false;
|
|
114
|
-
this.approvals.close();
|
|
115
|
-
this.player.stop();
|
|
116
|
-
this.scribe.close();
|
|
117
|
-
this.reply?.utterance.abort();
|
|
118
|
-
this.spare?.abort();
|
|
119
|
-
this.opus.close();
|
|
120
|
-
this.brain.close();
|
|
121
|
-
this.logReplyTime();
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
turnStarted(): void {
|
|
125
|
-
if (this.finished) return;
|
|
126
|
-
this.muted = false;
|
|
127
|
-
this.reply = {
|
|
128
|
-
utterance: this.takeUtterance(),
|
|
129
|
-
said: '',
|
|
130
|
-
heard: 0,
|
|
131
|
-
chunker: new Chunker(),
|
|
132
|
-
askedAt: this.askedAt,
|
|
133
|
-
saidAt: this.saidAt,
|
|
134
|
-
firstText: null,
|
|
135
|
-
firstAudio: null,
|
|
136
|
-
firstPlayed: null,
|
|
137
|
-
};
|
|
138
|
-
this.askedAt = null;
|
|
139
|
-
this.saidAt = null;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
text(delta: string): void {
|
|
143
|
-
const reply = this.reply;
|
|
144
|
-
if (this.finished || this.muted || reply === null) return;
|
|
145
|
-
reply.firstText ??= performance.now();
|
|
146
|
-
this.speak(reply, reply.chunker.add(delta));
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
tool(name: string): void {
|
|
150
|
-
if (this.finished) return;
|
|
151
|
-
log.info({ tool: name }, 'voice: the agent uses a tool during the call');
|
|
152
|
-
if (this.reply !== null && !this.muted) this.speak(this.reply, this.reply.chunker.flush());
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
turnEnded(): void {
|
|
156
|
-
const reply = this.reply;
|
|
157
|
-
if (this.finished || reply === null || this.muted) return;
|
|
158
|
-
this.speak(reply, reply.chunker.flush());
|
|
159
|
-
reply.utterance.end();
|
|
160
|
-
log.info(
|
|
161
|
-
{ firstText: since(reply.askedAt, reply.firstText), firstAudio: since(reply.askedAt, reply.firstAudio), vadSilenceMs: VAD_SILENCE_SECS * 1000 },
|
|
162
|
-
'voice: reply timing in ms from the caller’s words being committed',
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
asked(ask: ToolAsk): void {
|
|
167
|
-
const id = this.finished ? null : this.approvals.ask(ask, (behavior) => {
|
|
168
|
-
this.brain.answer(ask, behavior);
|
|
169
|
-
});
|
|
170
|
-
if (id === null) {
|
|
171
|
-
this.brain.answer(ask, 'deny');
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
log.info({ tool: ask.tool, worker: ask.fromWorker }, 'voice: a tool waits for the owner’s approval in the chat of the call');
|
|
175
|
-
this.brain.tell(approvalNote(ask.tool, id));
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
exited(reason: string): void {
|
|
179
|
-
if (this.finished) return;
|
|
180
|
-
log.warn({ reason }, 'voice: the agent session for the call ended');
|
|
181
|
-
this.ended(`agent session ${reason}`);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
private takeUtterance(): Utterance {
|
|
185
|
-
const ready = this.spare?.usable === true ? this.spare : this.utterance();
|
|
186
|
-
this.spare = this.live ? this.utterance() : null;
|
|
187
|
-
if (ready === this.spare) this.spare = null;
|
|
188
|
-
const reply = (): Reply | null => this.reply;
|
|
189
|
-
ready.attach({
|
|
190
|
-
audio: (pcm, chars) => {
|
|
191
|
-
const current = reply();
|
|
192
|
-
if (this.finished || this.muted || current?.utterance !== ready) return;
|
|
193
|
-
this.spoke = true;
|
|
194
|
-
current.firstAudio ??= performance.now();
|
|
195
|
-
this.player.push(pcm, chars);
|
|
196
|
-
},
|
|
197
|
-
done: () => {
|
|
198
|
-
if (!this.finished && !this.muted && this.reply?.utterance === ready) this.player.finish();
|
|
199
|
-
},
|
|
200
|
-
});
|
|
201
|
-
return ready;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
private utterance(): Utterance {
|
|
205
|
-
return new Utterance(this.cfg.apiKey, voiceIdOf(this.cfg), (reason) => {
|
|
206
|
-
log.warn({ reason }, 'voice: text to speech gave no audio');
|
|
207
|
-
if (!this.spoke && !this.finished) this.ended(`ElevenLabs could not speak (${reason})`);
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
private speak(reply: Reply, parts: string[]): void {
|
|
212
|
-
for (const part of parts) {
|
|
213
|
-
reply.said += `${part} `;
|
|
214
|
-
reply.utterance.say(part);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
private played(chars: number): void {
|
|
219
|
-
if (this.reply !== null) this.reply.heard += chars;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
private logReplyTime(): void {
|
|
223
|
-
if (this.delays.length === 0) return;
|
|
224
|
-
const sorted = [...this.delays].sort((a, b) => a - b);
|
|
225
|
-
log.info({ middleMs: Math.round(sorted[Math.floor(sorted.length / 2)] ?? 0), replies: sorted.length }, 'voice: reply time of the call, from the caller’s last word heard to the first reply audio sent');
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
private firstPlayed(): void {
|
|
229
|
-
const reply = this.reply;
|
|
230
|
-
if (reply?.firstPlayed !== null || reply.saidAt === null) return;
|
|
231
|
-
reply.firstPlayed = performance.now();
|
|
232
|
-
this.delays.push(reply.firstPlayed - reply.saidAt);
|
|
233
|
-
log.info({ fromLastWord: since(reply.saidAt, reply.firstPlayed), fromCommit: since(reply.askedAt, reply.firstPlayed) }, 'voice: first reply audio sent to the caller, in ms');
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
private interrupts(text: string): boolean {
|
|
237
|
-
return !this.muted && this.player.busy && wordsIn(text) >= BARGE_MIN_WORDS;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
private cutOff(): void {
|
|
241
|
-
this.cut = heardNote(this.reply);
|
|
242
|
-
this.reply?.utterance.abort();
|
|
243
|
-
this.player.clear();
|
|
244
|
-
this.muted = true;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
private partial(text: string): void {
|
|
248
|
-
if (this.finished) return;
|
|
249
|
-
if (text !== '') this.lastHeardAt = performance.now();
|
|
250
|
-
if (this.interrupts(text)) this.cutOff();
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
private committed(text: string): void {
|
|
254
|
-
if (this.finished) return;
|
|
255
|
-
if (this.interrupts(text)) this.cutOff();
|
|
256
|
-
const note = this.cut;
|
|
257
|
-
const now = this.muted;
|
|
258
|
-
this.cut = null;
|
|
259
|
-
this.askedAt = performance.now();
|
|
260
|
-
this.saidAt = this.lastHeardAt ?? this.askedAt;
|
|
261
|
-
this.lastHeardAt = null;
|
|
262
|
-
this.brain.tell(note === null ? text : `${note}\n${text}`, now);
|
|
263
|
-
}
|
|
264
|
-
}
|