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

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.
Files changed (31) hide show
  1. package/dist/claude.js +1 -1
  2. package/dist/cli.js +14 -5
  3. package/dist/memory.js +2 -2
  4. package/dist/voice.js +39 -0
  5. package/package.json +1 -1
  6. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  7. package/runtime/marketplace/plugin/MEMORY.md +2 -2
  8. package/runtime/node_modules/@metro-labs/daemon/package.json +2 -0
  9. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedule-detail.ts +2 -2
  10. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/claude/memory-routine.ts +25 -16
  12. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +8 -4
  13. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +2 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/voice/api.ts +14 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/voice/audio.ts +153 -0
  17. package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +95 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +154 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +54 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/voice/peer.ts +84 -0
  21. package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +102 -0
  22. package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +54 -0
  23. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +91 -0
  24. package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +97 -0
  25. package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +234 -0
  26. package/runtime/node_modules/@metro-labs/xmtp/src/actions-conv.ts +2 -1
  27. package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +11 -1
  28. package/runtime/node_modules/@metro-labs/xmtp/src/codecs.ts +21 -3
  29. package/runtime/node_modules/@metro-labs/xmtp/src/emit-payloads.ts +2 -1
  30. package/runtime/runtime.json +1 -1
  31. package/runtime/stations.json +2 -0
@@ -0,0 +1,234 @@
1
+ import { log } from '@metro-labs/core/log';
2
+ import { downsample3, OpusIn, Player, type Playback } from './audio.js';
3
+ import { Brain, type Thinking } from './brain.js';
4
+ import { Scribe, VAD_SILENCE_SECS } from './scribe.js';
5
+ import { Utterance } from './speech.js';
6
+ import { Chunker } from './speakable.js';
7
+ import { brainModel, voiceIdOf, type VoiceConfig } from './store.js';
8
+
9
+ const BARGE_MIN_CHARS = 4;
10
+ const NOTES_WAIT_MS = 60_000;
11
+ const NOTES_PROMPT =
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.]';
13
+
14
+ interface Reply {
15
+ utterance: Utterance;
16
+ said: string;
17
+ heard: number;
18
+ chunker: Chunker;
19
+ askedAt: number | null;
20
+ firstText: number | null;
21
+ firstAudio: number | null;
22
+ }
23
+
24
+ const since = (start: number | null, at: number | null): number | null =>
25
+ start === null || at === null ? null : Math.round(at - start);
26
+
27
+ function heardNote(reply: Reply | null): string | null {
28
+ if (reply === null || reply.said.trim() === '' || reply.heard >= reply.said.trim().length - 2) return null;
29
+ const part = reply.said.slice(0, reply.heard).trim();
30
+ return part === ''
31
+ ? '[The caller interrupted you before hearing your reply.]'
32
+ : `[The caller interrupted you. They heard only: "${part}". The rest of your reply was not heard.]`;
33
+ }
34
+
35
+ export class Talk implements Thinking {
36
+ private readonly brain: Brain;
37
+ private readonly scribe: Scribe;
38
+ private readonly opus = new OpusIn();
39
+ private readonly player: Player;
40
+ private spare: Utterance | null = null;
41
+ private reply: Reply | null = null;
42
+ private cut: string | null = null;
43
+ private muted = false;
44
+ private busy = false;
45
+ private askedAt: number | null = null;
46
+ private notes: ((text: string) => void) | null = null;
47
+ private notesStarted = false;
48
+ private live = false;
49
+ private finished = false;
50
+ private heardCaller = false;
51
+
52
+ constructor(
53
+ private readonly cfg: VoiceConfig,
54
+ out: (opus: Buffer, timestamp: number, marker: boolean) => void,
55
+ private readonly ended: (reason: string) => void,
56
+ ) {
57
+ const playback: Playback = {
58
+ send: out,
59
+ played: (chars) => {
60
+ this.played(chars);
61
+ },
62
+ };
63
+ this.player = new Player(playback);
64
+ this.player.whenFirstPlayed(() => {
65
+ this.firstPlayed();
66
+ });
67
+ this.brain = new Brain(brainModel(cfg), this);
68
+ this.scribe = new Scribe(cfg.apiKey, {
69
+ partial: (text) => {
70
+ this.partial(text);
71
+ },
72
+ committed: (text) => {
73
+ this.committed(text);
74
+ },
75
+ failed: (reason) => {
76
+ this.ended(reason);
77
+ },
78
+ });
79
+ this.spare = new Utterance(cfg.apiKey, voiceIdOf(cfg));
80
+ }
81
+
82
+ prime(context: string): void {
83
+ this.busy = true;
84
+ this.brain.tell(context);
85
+ }
86
+
87
+ connect(): void {
88
+ this.live = true;
89
+ this.spare ??= new Utterance(this.cfg.apiKey, voiceIdOf(this.cfg));
90
+ this.player.start();
91
+ }
92
+
93
+ hear(packet: Buffer): void {
94
+ if (this.finished) return;
95
+ const pcm = this.opus.decode(packet);
96
+ if (pcm !== null) this.scribe.send(downsample3(pcm));
97
+ }
98
+
99
+ async finish(): Promise<string | null> {
100
+ if (this.finished) return null;
101
+ this.finished = true;
102
+ this.live = false;
103
+ this.player.stop();
104
+ this.scribe.close();
105
+ this.reply?.utterance.abort();
106
+ this.spare?.abort();
107
+ this.opus.close();
108
+ const notes = this.heardCaller && this.brain.alive ? await this.callNotes() : null;
109
+ this.brain.close();
110
+ return notes;
111
+ }
112
+
113
+ turnStarted(): void {
114
+ this.muted = false;
115
+ this.busy = true;
116
+ if (this.notes !== null) this.notesStarted = true;
117
+ 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 };
119
+ this.askedAt = null;
120
+ }
121
+
122
+ text(delta: string): void {
123
+ const reply = this.reply;
124
+ if (this.muted || reply === null || this.notes !== null) return;
125
+ reply.firstText ??= performance.now();
126
+ this.speak(reply, reply.chunker.add(delta));
127
+ }
128
+
129
+ tool(name: string): void {
130
+ log.info({ tool: name }, 'voice: the agent uses a tool during the call');
131
+ if (this.reply !== null && !this.muted) this.speak(this.reply, this.reply.chunker.flush());
132
+ }
133
+
134
+ turnEnded(text: string): void {
135
+ this.busy = false;
136
+ const notes = this.notes;
137
+ if (notes !== null) {
138
+ if (!this.notesStarted) return;
139
+ this.notes = null;
140
+ notes(text);
141
+ return;
142
+ }
143
+ const reply = this.reply;
144
+ if (reply === null || this.muted) return;
145
+ this.speak(reply, reply.chunker.flush());
146
+ reply.utterance.end();
147
+ log.info(
148
+ { firstText: since(reply.askedAt, reply.firstText), firstAudio: since(reply.askedAt, reply.firstAudio), vadSilenceMs: VAD_SILENCE_SECS * 1000 },
149
+ 'voice: reply timing in ms from the caller’s words being committed',
150
+ );
151
+ }
152
+
153
+ exited(reason: string): void {
154
+ log.warn({ reason }, 'voice: the agent session for the call ended');
155
+ this.notes?.('');
156
+ this.notes = null;
157
+ this.ended(`agent session ${reason}`);
158
+ }
159
+
160
+ 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;
163
+ if (ready === this.spare) this.spare = null;
164
+ const reply = (): Reply | null => this.reply;
165
+ ready.attach({
166
+ audio: (pcm, chars) => {
167
+ const current = reply();
168
+ if (current?.utterance !== ready) return;
169
+ current.firstAudio ??= performance.now();
170
+ this.player.push(pcm, chars);
171
+ },
172
+ done: () => {
173
+ this.player.finish();
174
+ },
175
+ });
176
+ return ready;
177
+ }
178
+
179
+ private speak(reply: Reply, parts: string[]): void {
180
+ for (const part of parts) {
181
+ reply.said += `${part} `;
182
+ reply.utterance.say(part);
183
+ }
184
+ }
185
+
186
+ private played(chars: number): void {
187
+ if (this.reply !== null) this.reply.heard += chars;
188
+ }
189
+
190
+ private firstPlayed(): void {
191
+ 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');
193
+ }
194
+
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();
201
+ this.player.clear();
202
+ this.muted = true;
203
+ }
204
+
205
+ private committed(text: string): void {
206
+ this.heardCaller = true;
207
+ const note = this.cut;
208
+ const now = this.busy || note !== null;
209
+ this.cut = null;
210
+ if (now && !this.muted) {
211
+ this.reply?.utterance.abort();
212
+ this.player.clear();
213
+ this.muted = true;
214
+ }
215
+ this.askedAt = performance.now();
216
+ this.busy = true;
217
+ this.brain.tell(note === null ? text : `${note}\n${text}`, now);
218
+ }
219
+
220
+ private callNotes(): Promise<string | null> {
221
+ return new Promise((resolve) => {
222
+ const timer = setTimeout(() => {
223
+ this.notes = null;
224
+ resolve(null);
225
+ }, NOTES_WAIT_MS);
226
+ this.notesStarted = false;
227
+ this.notes = (text) => {
228
+ clearTimeout(timer);
229
+ resolve(text.trim() === '' ? null : text.trim());
230
+ };
231
+ this.brain.tell(NOTES_PROMPT, this.busy);
232
+ });
233
+ }
234
+ }
@@ -15,6 +15,7 @@ import {
15
15
  } from './group.js';
16
16
  import { updateChannelMeta } from './actions-meta.js';
17
17
  import { deletedByRequests, isDeleteRequest, superAdminCheck } from './delete-requests.js';
18
+ import { isCallSignal } from './codecs.js';
18
19
 
19
20
  type Args = Record<string, unknown>;
20
21
  type Handler = (id: string, args: Args) => Promise<void>;
@@ -63,7 +64,7 @@ async function read(id: string, args: Args): Promise<void> {
63
64
  await conv.sync().catch(() => undefined);
64
65
  const all = await conv.messages();
65
66
  const deleted = deletedByRequests(all, superAdminCheck(conv));
66
- const slice = upTo(all.filter((m) => !isDeleteRequest(m)), before).slice(-lim);
67
+ const slice = upTo(all.filter((m) => !isDeleteRequest(m) && !isCallSignal(m)), before).slice(-lim);
67
68
  const parsed = parseLine(line);
68
69
  if (!parsed)
69
70
  throw new TrainError('NOT_FOUND', `could not parse line ${line}`);
@@ -12,7 +12,7 @@ import {
12
12
  import { convOf, type Account } from './accounts.js';
13
13
  import { resolveMsgId } from './wire.js';
14
14
  import { emitOutbound } from './emit.js';
15
- import { FrameCodec, PollCodec, buildPollContent, encodeDeleteMessage } from './codecs.js';
15
+ import { CallSignalCodec, FrameCodec, PollCodec, buildPollContent, encodeDeleteMessage } from './codecs.js';
16
16
  import { buildFrameContent } from './frames.js';
17
17
  import { convHandlers } from './actions-conv.js';
18
18
  import { messagingAliases } from '@metro-labs/core/stations/messaging-normalize';
@@ -142,6 +142,15 @@ async function remove(id: string, args: Args): Promise<void> {
142
142
  respond(id, { result: { messageId: sentId } });
143
143
  }
144
144
 
145
+ async function callSignal(id: string, args: Args): Promise<void> {
146
+ const { line, signal } = args as { line: string; signal?: unknown };
147
+ if (typeof signal !== 'object' || signal === null) throw badArgs('callSignal requires a signal object');
148
+ const { conv } = await convOf(line);
149
+ if (!conv) throw noConv(line);
150
+ const messageId = await conv.send(new CallSignalCodec().encode(signal as Record<string, unknown>), { shouldPush: false });
151
+ respond(id, { result: { messageId } });
152
+ }
153
+
145
154
  async function sendAttachment(id: string, args: Args): Promise<void> {
146
155
  const { line, name, mime, dataB64 } = args as {
147
156
  line: string;
@@ -223,6 +232,7 @@ const handlers: Record<string, (id: string, args: Args) => Promise<void>> = {
223
232
  react,
224
233
  reply,
225
234
  delete: remove,
235
+ callSignal,
226
236
  sendAttachment,
227
237
  sendImage,
228
238
  ...convHandlers,
@@ -11,17 +11,19 @@ const dec = (e: EncodedContent): unknown =>
11
11
 
12
12
  const makeJsonCodec = <T>(
13
13
  contentType: ContentTypeId,
14
- fallbackFn: (c: T) => string,
14
+ fallbackFn: (c: T) => string | undefined,
15
+ push = true,
15
16
  ) =>
16
17
  class {
17
18
  get contentType() {
18
19
  return contentType;
19
20
  }
20
21
  encode(c: T): EncodedContent {
22
+ const fallback = fallbackFn(c);
21
23
  return {
22
24
  type: contentType,
23
25
  parameters: {},
24
- fallback: fallbackFn(c),
26
+ ...(fallback === undefined ? {} : { fallback }),
25
27
  content: enc(c),
26
28
  };
27
29
  }
@@ -32,7 +34,7 @@ const makeJsonCodec = <T>(
32
34
  return fallbackFn(c);
33
35
  }
34
36
  shouldPush() {
35
- return true;
37
+ return push;
36
38
  }
37
39
  };
38
40
 
@@ -235,6 +237,20 @@ export function encodeDeleteMessage(messageId: string): EncodedContent {
235
237
  };
236
238
  }
237
239
 
240
+ export const CALL_TYPES = new Set(['callInvite', 'callSignal']);
241
+ const callContentType = (typeId: string): ContentTypeId => ({
242
+ authorityId: 'stage.box',
243
+ typeId,
244
+ versionMajor: 1,
245
+ versionMinor: 0,
246
+ });
247
+ const CallInviteCodec = makeJsonCodec<{ video?: unknown }>(callContentType('callInvite'), (c) =>
248
+ c.video === true ? '📞 Video call' : '📞 Voice call',
249
+ );
250
+ export const isCallSignal = (m: { contentType?: ContentTypeId }): boolean =>
251
+ m.contentType?.authorityId === 'stage.box' && m.contentType.typeId === 'callSignal';
252
+ export const CallSignalCodec = makeJsonCodec<Record<string, unknown>>(callContentType('callSignal'), () => undefined, false);
253
+
238
254
  export const CODECS = (): ContentCodec[] => [
239
255
  new PollCodec(),
240
256
  new SignatureRequestCodec(),
@@ -242,4 +258,6 @@ export const CODECS = (): ContentCodec[] => [
242
258
  new FrameCodec(),
243
259
  new FrameActionCodec(),
244
260
  new DeleteRequestCodec(),
261
+ new CallInviteCodec(),
262
+ new CallSignalCodec(),
245
263
  ];
@@ -9,7 +9,7 @@ import {
9
9
  } from './attachments.js';
10
10
  import { reportAttachment } from '@metro-labs/core/stations/train-events';
11
11
  import { sentByMe } from './wire.js';
12
- import { frameActionText, frameFallback, type FrameContent } from './codecs.js';
12
+ import { CALL_TYPES, frameActionText, frameFallback, type FrameContent } from './codecs.js';
13
13
  import { asFrameAction } from './frames.js';
14
14
 
15
15
  export interface EnvelopeCtx {
@@ -336,6 +336,7 @@ export function typedEnvelope(
336
336
  if (typeId === 'reaction') return reactionPayload(base, c as Reaction);
337
337
  if (typeId === 'reply') return replyPayload(base, typeId, c as Reply);
338
338
  if (typeId === 'poll') return pollPayload(base, typeId, c);
339
+ if (typeId !== undefined && CALL_TYPES.has(typeId)) return { ...base, payload: { contentType: typeId, call: c } };
339
340
  return (
340
341
  attachmentEnvelope(base, typeId ?? '', c, ctx) ??
341
342
  refEnvelope(base, typeId, c) ??
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.235"
2
+ "version": "0.1.0-beta.237"
3
3
  }
@@ -1,8 +1,10 @@
1
1
  {
2
2
  "core": {
3
3
  "@modelcontextprotocol/sdk": "^1.12.0",
4
+ "opusscript": "^0.1.1",
4
5
  "pino": "^9.5.0",
5
6
  "pino-pretty": "^13.1.3",
7
+ "werift": "^0.24.4",
6
8
  "ws": "^8.18.0",
7
9
  "zod": "^3.23.8"
8
10
  },