@stage-labs/metro 0.1.0-beta.237 → 0.1.0-beta.238

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.237",
3
+ "version": "0.1.0-beta.238",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.237",
3
+ "version": "0.1.0-beta.238",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -111,6 +111,7 @@ export class Call {
111
111
  },
112
112
  });
113
113
  const sdp = await this.peer.answer(offer);
114
+ if (this.over) return;
114
115
  await this.signal({ kind: 'answer', callId: this.start.callId, from: this.selfPeer, to, sdp });
115
116
  }
116
117
 
@@ -147,8 +148,9 @@ export class Call {
147
148
  this.peer?.close();
148
149
  const notes = await this.talk.finish();
149
150
  const minutes = Math.max(1, Math.round((Date.now() - this.startedAt) / 60_000));
150
- if (notes !== null) await trainCall('send', { line: this.start.line, text: `Call notes (${String(minutes)} min):\n${notes}` });
151
- else if (!reason.startsWith('the caller') && reason !== 'no offer arrived')
152
- await trainCall('send', { line: this.start.line, text: `The voice call ended: ${reason}.` });
151
+ const timing = this.talk.replyTime === null ? '' : `\n${this.talk.replyTime}`;
152
+ const failure = reason.startsWith('the caller') || reason === 'no offer arrived' ? null : `The voice call ended: ${reason}.`;
153
+ const text = [failure, notes === null ? null : `Call notes (${String(minutes)} min):\n${notes}${timing}`].filter((part) => part !== null).join('\n\n');
154
+ if (text !== '') await trainCall('send', { line: this.start.line, text });
153
155
  }
154
156
  }
@@ -26,14 +26,19 @@ const charsOf = (msg: Record<string, unknown>): number => {
26
26
  return isRecord(alignment) && Array.isArray(alignment.chars) ? alignment.chars.length : 0;
27
27
  };
28
28
 
29
+ const MAX_REASON = 300;
30
+
29
31
  export class Utterance {
30
32
  private readonly ws: WebSocket;
31
33
  private readonly outbox: string[] = [];
32
34
  private listener: Voiced | null = null;
33
35
  private carry = 0;
34
36
  private closed = false;
37
+ private aborted = false;
38
+ private voiced = false;
39
+ private refusal: string | null = null;
35
40
 
36
- constructor(apiKey: string, voiceId: string) {
41
+ constructor(apiKey: string, voiceId: string, failed: (reason: string) => void) {
37
42
  this.ws = new WebSocket(ttsUrl(voiceId), { headers: { 'xi-api-key': apiKey } });
38
43
  this.ws.on('open', () => {
39
44
  this.ws.send(JSON.stringify({ text: ' ' }));
@@ -43,12 +48,15 @@ export class Utterance {
43
48
  this.onMessage(wsText(data));
44
49
  });
45
50
  this.ws.on('error', (err) => {
51
+ this.refusal ??= errMsg(err);
46
52
  log.warn({ err: errMsg(err) }, 'voice: text to speech connection error');
47
53
  });
48
- this.ws.on('close', () => {
54
+ this.ws.on('close', (code, why) => {
49
55
  this.closed = true;
50
56
  this.listener?.done();
51
57
  this.listener = null;
58
+ const reason = this.refusal ?? (code === 1000 ? null : (why.toString() || `closed (${String(code)})`));
59
+ if (!this.aborted && !this.voiced && reason !== null) failed(reason.slice(0, MAX_REASON));
52
60
  });
53
61
  }
54
62
 
@@ -69,6 +77,7 @@ export class Utterance {
69
77
  }
70
78
 
71
79
  abort(): void {
80
+ this.aborted = true;
72
81
  this.listener = null;
73
82
  this.ws.terminate();
74
83
  }
@@ -82,10 +91,14 @@ export class Utterance {
82
91
  private onMessage(raw: string): void {
83
92
  const msg = parsed(raw);
84
93
  if (typeof msg.audio === 'string' && msg.audio !== '') {
94
+ this.voiced = true;
85
95
  const pcm = pcmOf(Buffer.from(msg.audio, 'base64'));
86
96
  const up = upsample2(pcm, this.carry);
87
97
  this.carry = pcm.at(-1) ?? this.carry;
88
98
  this.listener?.audio(up, charsOf(msg));
89
- } else if (msg.isFinal !== true) log.warn({ msg: raw.slice(0, 300) }, 'voice: text to speech said');
99
+ } else if (msg.isFinal !== true) {
100
+ if (typeof msg.message === 'string' && msg.message !== '') this.refusal ??= msg.message;
101
+ log.warn({ msg: raw.slice(0, MAX_REASON) }, 'voice: text to speech said');
102
+ }
90
103
  }
91
104
  }
@@ -6,7 +6,7 @@ import { Utterance } from './speech.js';
6
6
  import { Chunker } from './speakable.js';
7
7
  import { brainModel, voiceIdOf, type VoiceConfig } from './store.js';
8
8
 
9
- const BARGE_MIN_CHARS = 4;
9
+ const BARGE_MIN_WORDS = 2;
10
10
  const NOTES_WAIT_MS = 60_000;
11
11
  const NOTES_PROMPT =
12
12
  '[The call has ended. Write short call notes for the chat: what was discussed, what was decided and what you will follow up on. Plain text, at most 6 short lines, no title and no greeting. Reply with the notes only.]';
@@ -17,10 +17,14 @@ interface Reply {
17
17
  heard: number;
18
18
  chunker: Chunker;
19
19
  askedAt: number | null;
20
+ saidAt: number | null;
20
21
  firstText: number | null;
21
22
  firstAudio: number | null;
23
+ firstPlayed: number | null;
22
24
  }
23
25
 
26
+ const wordsIn = (text: string): number => text.split(/\s+/).filter((word) => /\p{L}/u.test(word)).length;
27
+
24
28
  const since = (start: number | null, at: number | null): number | null =>
25
29
  start === null || at === null ? null : Math.round(at - start);
26
30
 
@@ -43,11 +47,15 @@ export class Talk implements Thinking {
43
47
  private muted = false;
44
48
  private busy = false;
45
49
  private askedAt: number | null = null;
50
+ private saidAt: number | null = null;
51
+ private lastHeardAt: number | null = null;
52
+ private readonly delays: number[] = [];
46
53
  private notes: ((text: string) => void) | null = null;
47
54
  private notesStarted = false;
48
55
  private live = false;
49
56
  private finished = false;
50
57
  private heardCaller = false;
58
+ private spoke = false;
51
59
 
52
60
  constructor(
53
61
  private readonly cfg: VoiceConfig,
@@ -76,7 +84,7 @@ export class Talk implements Thinking {
76
84
  this.ended(reason);
77
85
  },
78
86
  });
79
- this.spare = new Utterance(cfg.apiKey, voiceIdOf(cfg));
87
+ this.spare = this.utterance();
80
88
  }
81
89
 
82
90
  prime(context: string): void {
@@ -86,7 +94,7 @@ export class Talk implements Thinking {
86
94
 
87
95
  connect(): void {
88
96
  this.live = true;
89
- this.spare ??= new Utterance(this.cfg.apiKey, voiceIdOf(this.cfg));
97
+ this.spare ??= this.utterance();
90
98
  this.player.start();
91
99
  }
92
100
 
@@ -115,8 +123,19 @@ export class Talk implements Thinking {
115
123
  this.busy = true;
116
124
  if (this.notes !== null) this.notesStarted = true;
117
125
  if (this.notes !== null || this.finished) return;
118
- this.reply = { utterance: this.takeUtterance(), said: '', heard: 0, chunker: new Chunker(), askedAt: this.askedAt, firstText: null, firstAudio: null };
126
+ this.reply = {
127
+ utterance: this.takeUtterance(),
128
+ said: '',
129
+ heard: 0,
130
+ chunker: new Chunker(),
131
+ askedAt: this.askedAt,
132
+ saidAt: this.saidAt,
133
+ firstText: null,
134
+ firstAudio: null,
135
+ firstPlayed: null,
136
+ };
119
137
  this.askedAt = null;
138
+ this.saidAt = null;
120
139
  }
121
140
 
122
141
  text(delta: string): void {
@@ -158,14 +177,15 @@ export class Talk implements Thinking {
158
177
  }
159
178
 
160
179
  private takeUtterance(): Utterance {
161
- const ready = this.spare?.usable === true ? this.spare : new Utterance(this.cfg.apiKey, voiceIdOf(this.cfg));
162
- this.spare = this.live ? new Utterance(this.cfg.apiKey, voiceIdOf(this.cfg)) : null;
180
+ const ready = this.spare?.usable === true ? this.spare : this.utterance();
181
+ this.spare = this.live ? this.utterance() : null;
163
182
  if (ready === this.spare) this.spare = null;
164
183
  const reply = (): Reply | null => this.reply;
165
184
  ready.attach({
166
185
  audio: (pcm, chars) => {
167
186
  const current = reply();
168
187
  if (current?.utterance !== ready) return;
188
+ this.spoke = true;
169
189
  current.firstAudio ??= performance.now();
170
190
  this.player.push(pcm, chars);
171
191
  },
@@ -176,6 +196,13 @@ export class Talk implements Thinking {
176
196
  return ready;
177
197
  }
178
198
 
199
+ private utterance(): Utterance {
200
+ return new Utterance(this.cfg.apiKey, voiceIdOf(this.cfg), (reason) => {
201
+ log.warn({ reason }, 'voice: text to speech gave no audio');
202
+ if (!this.spoke && !this.finished) this.ended(`ElevenLabs could not speak (${reason})`);
203
+ });
204
+ }
205
+
179
206
  private speak(reply: Reply, parts: string[]): void {
180
207
  for (const part of parts) {
181
208
  reply.said += `${part} `;
@@ -187,32 +214,46 @@ export class Talk implements Thinking {
187
214
  if (this.reply !== null) this.reply.heard += chars;
188
215
  }
189
216
 
217
+ get replyTime(): string | null {
218
+ if (this.delays.length === 0) return null;
219
+ const sorted = [...this.delays].sort((a, b) => a - b);
220
+ const middle = sorted[Math.floor(sorted.length / 2)] ?? 0;
221
+ return `Reply time, from your last word heard to my first word sent: about ${(middle / 1000).toFixed(1)} s (middle of ${String(sorted.length)}).`;
222
+ }
223
+
190
224
  private firstPlayed(): void {
191
225
  const reply = this.reply;
192
- if (reply?.askedAt != null) log.info({ firstPlayed: since(reply.askedAt, performance.now()) }, 'voice: first reply audio sent to the caller');
226
+ if (reply?.firstPlayed !== null || reply.saidAt === null) return;
227
+ reply.firstPlayed = performance.now();
228
+ this.delays.push(reply.firstPlayed - reply.saidAt);
229
+ log.info({ fromLastWord: since(reply.saidAt, reply.firstPlayed), fromCommit: since(reply.askedAt, reply.firstPlayed) }, 'voice: first reply audio sent to the caller, in ms');
193
230
  }
194
231
 
195
- private partial(text: string): void {
196
- if (text.replace(/\s/g, '').length < BARGE_MIN_CHARS || this.muted) return;
197
- if (!this.player.busy && !this.busy) return;
198
- const reply = this.reply;
199
- this.cut = heardNote(reply);
200
- reply?.utterance.abort();
232
+ private interrupts(text: string): boolean {
233
+ return !this.muted && this.player.busy && wordsIn(text) >= BARGE_MIN_WORDS;
234
+ }
235
+
236
+ private cutOff(): void {
237
+ this.cut = heardNote(this.reply);
238
+ this.reply?.utterance.abort();
201
239
  this.player.clear();
202
240
  this.muted = true;
203
241
  }
204
242
 
243
+ private partial(text: string): void {
244
+ if (text !== '') this.lastHeardAt = performance.now();
245
+ if (this.interrupts(text)) this.cutOff();
246
+ }
247
+
205
248
  private committed(text: string): void {
206
249
  this.heardCaller = true;
250
+ if (this.interrupts(text)) this.cutOff();
207
251
  const note = this.cut;
208
- const now = this.busy || note !== null;
252
+ const now = this.muted;
209
253
  this.cut = null;
210
- if (now && !this.muted) {
211
- this.reply?.utterance.abort();
212
- this.player.clear();
213
- this.muted = true;
214
- }
215
254
  this.askedAt = performance.now();
255
+ this.saidAt = this.lastHeardAt ?? this.askedAt;
256
+ this.lastHeardAt = null;
216
257
  this.busy = true;
217
258
  this.brain.tell(note === null ? text : `${note}\n${text}`, now);
218
259
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.237"
2
+ "version": "0.1.0-beta.238"
3
3
  }