@stage-labs/metro 0.1.0-beta.263 → 0.1.0-beta.265
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/cli.js +2 -2
- package/dist/runtime-install.js +22 -5
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/bin/policy.mjs +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/call.ts +81 -0
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
- package/runtime/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +35 -2
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +14 -11
- package/runtime/node_modules/@metro-labs/daemon/src/channels/pending.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-bridge.ts +45 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-permission.ts +25 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/event-store.ts +9 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +15 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +20 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-schema.ts +11 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-send.ts +15 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/stations/runtime-deps.ts +23 -7
- package/runtime/node_modules/@metro-labs/daemon/src/voice/audio.ts +50 -10
- package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +20 -9
- package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +125 -51
- package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +41 -15
- package/runtime/node_modules/@metro-labs/daemon/src/voice/peer.ts +9 -8
- package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +15 -3
- package/runtime/node_modules/@metro-labs/daemon/src/voice/shared-talk.ts +85 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +199 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech-queue.ts +173 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +40 -15
- package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +15 -7
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +32 -1
- package/runtime/node_modules/@metro-labs/whatsapp/src/app-state.ts +49 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client-history.ts +31 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +46 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +75 -77
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-events.ts +127 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-files.ts +39 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-identity.ts +58 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-message.ts +161 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-state.ts +344 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-types.ts +91 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history.ts +195 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/index.ts +1 -1
- package/runtime/node_modules/@metro-labs/whatsapp/src/members.ts +94 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/messages.ts +70 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/send-expiry.ts +65 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +4 -0
- package/runtime/patches/5bca2c07d5852d3f58453cff0448dd85da13d3665b61e3d758905b298d432d7c.patch +31 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/call.ts +81 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/sdk-runner/src/activity.ts +14 -2
- package/runtime/sdk-runner/src/app.ts +5 -4
- package/runtime/sdk-runner/src/approvals.ts +14 -5
- package/runtime/sdk-runner/src/calls.ts +133 -0
- package/runtime/sdk-runner/src/inbox.ts +58 -26
- package/runtime/sdk-runner/src/input-queue.ts +70 -0
- package/runtime/sdk-runner/src/input-timing.ts +68 -0
- package/runtime/sdk-runner/src/link.ts +27 -4
- package/runtime/sdk-runner/src/main.ts +0 -4
- package/runtime/sdk-runner/src/rules.ts +19 -5
- package/runtime/sdk-runner/src/runner.ts +64 -88
- package/runtime/sdk-runner/src/session-store.ts +14 -1
- package/runtime/sdk-runner/src/session-watch.ts +27 -20
- package/runtime/stations.json +3 -0
- package/runtime/sdk-runner/src/speech.ts +0 -79
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { errMsg } from '@metro-labs/core/log';
|
|
18
18
|
import { channelLog, type McpSession } from './session.js';
|
|
19
19
|
import { SessionSlot } from './session-slot.js';
|
|
20
|
+
import { connectCallBridge } from './call-bridge.js';
|
|
20
21
|
|
|
21
22
|
const isInitialize = (b: unknown): boolean =>
|
|
22
23
|
!!b &&
|
|
@@ -116,6 +117,7 @@ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Pr
|
|
|
116
117
|
const slot = new SessionSlot(options.liveEvents);
|
|
117
118
|
const voiceSlot = new SessionSlot(false, 'voice');
|
|
118
119
|
activeSlots = [slot, voiceSlot];
|
|
120
|
+
connectCallBridge(() => slot.current);
|
|
119
121
|
|
|
120
122
|
const httpHandler = async (
|
|
121
123
|
req: IncomingMessage,
|
|
@@ -45,13 +45,14 @@ function argText(value: unknown): string | undefined {
|
|
|
45
45
|
|
|
46
46
|
function fieldText(key: string, value: unknown): string | undefined {
|
|
47
47
|
if (key === 'attachments' && Array.isArray(value)) return `${String(value.length)} file(s)`;
|
|
48
|
-
return key === 'frame' || key === 'wallet' ? argText(value) : valueText(value);
|
|
48
|
+
return key === 'frame' || key === 'wallet' || key === 'speech' ? argText(value) : valueText(value);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function metroLines(tool: string, input: Record<string, unknown>): string[] {
|
|
52
52
|
const lines = [`Approval needed: ${tool}`];
|
|
53
53
|
const channel = channelOf(input);
|
|
54
54
|
if (channel !== undefined) lines.push(`Channel: ${channel}`);
|
|
55
|
+
if (tool === 'send' && input.speech !== undefined) lines.push('Delivery: voice call only, no chat post');
|
|
55
56
|
const keys = [...LEAD, ...Object.keys(input).filter((k) => !LEAD.includes(k))];
|
|
56
57
|
for (const key of keys) {
|
|
57
58
|
if (PLACE.has(key)) continue;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { InboundRelay } from '../channels/inbound.js';
|
|
4
|
-
import { holdPrompt, type Behavior } from '../approvals/pending.js';
|
|
4
|
+
import { cancelPrompt, holdPrompt, type Behavior } from '../approvals/pending.js';
|
|
5
5
|
import { approversForLine, lineReceives } from '../agents/map.js';
|
|
6
6
|
import { metroCall } from './ctx.js';
|
|
7
7
|
import { promptBody } from './permission-prompt.js';
|
|
8
|
+
import { permissionCall } from './call-permission.js';
|
|
8
9
|
|
|
9
10
|
const PermissionRequestSchema = z.object({
|
|
10
11
|
method: z.literal('notifications/claude/channel/permission_request'),
|
|
@@ -13,6 +14,7 @@ const PermissionRequestSchema = z.object({
|
|
|
13
14
|
tool_name: z.string(),
|
|
14
15
|
description: z.string(),
|
|
15
16
|
input_preview: z.string(),
|
|
17
|
+
call: z.unknown().optional(),
|
|
16
18
|
}),
|
|
17
19
|
});
|
|
18
20
|
|
|
@@ -32,12 +34,12 @@ export interface PermissionRelayDeps {
|
|
|
32
34
|
log: (...a: unknown[]) => void;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
function relayLine(deps: PermissionRelayDeps, requestId: string): string | undefined {
|
|
37
|
+
function relayLine(deps: PermissionRelayDeps, requestId: string, callLine?: string): string | undefined {
|
|
36
38
|
if (!deps.live()) {
|
|
37
39
|
deps.log('permission_request: live events are off, so no chat answer can arrive; held for the page only', requestId);
|
|
38
40
|
return undefined;
|
|
39
41
|
}
|
|
40
|
-
const line = deps.relay.knownLine;
|
|
42
|
+
const line = callLine ?? deps.relay.knownLine;
|
|
41
43
|
if (!line) {
|
|
42
44
|
deps.log('permission_request: no known line, held for the page only', requestId);
|
|
43
45
|
return undefined;
|
|
@@ -59,12 +61,20 @@ function relayLine(deps: PermissionRelayDeps, requestId: string): string | undef
|
|
|
59
61
|
|
|
60
62
|
export function registerPermissionRelay(deps: PermissionRelayDeps): void {
|
|
61
63
|
const { mcp, log } = deps;
|
|
64
|
+
mcp.setNotificationHandler(z.object({ method: z.literal('notifications/metro/permission_cancel'), params: z.object({ request_id: z.string() }) }), (notice) => {
|
|
65
|
+
cancelPrompt(notice.params.request_id, mcp);
|
|
66
|
+
});
|
|
62
67
|
const answer = (requestId: string) => async (behavior: Behavior): Promise<void> => {
|
|
63
68
|
await mcp.notification({ method: 'notifications/claude/channel/permission', params: { request_id: requestId, behavior } });
|
|
64
69
|
};
|
|
65
70
|
mcp.setNotificationHandler(PermissionRequestSchema as never, async (n: PermissionRequest) => {
|
|
66
71
|
const { params } = n;
|
|
67
|
-
const
|
|
72
|
+
const call = permissionCall(params, deps.inScope);
|
|
73
|
+
if (call === null) {
|
|
74
|
+
await answer(params.request_id)('deny');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const line = relayLine(deps, params.request_id, call?.route.line);
|
|
68
78
|
holdPrompt(
|
|
69
79
|
{
|
|
70
80
|
requestId: params.request_id,
|
|
@@ -73,6 +83,7 @@ export function registerPermissionRelay(deps: PermissionRelayDeps): void {
|
|
|
73
83
|
preview: params.input_preview,
|
|
74
84
|
line,
|
|
75
85
|
at: Date.now(),
|
|
86
|
+
...(call === undefined ? {} : { call }),
|
|
76
87
|
},
|
|
77
88
|
mcp,
|
|
78
89
|
answer(params.request_id),
|
|
@@ -13,9 +13,9 @@ type ChannelTarget = Extract<PolicyTarget, { kind: 'channel' }>;
|
|
|
13
13
|
|
|
14
14
|
export const UNGATED = new Set<string>(['list_accounts', 'create_upload']);
|
|
15
15
|
|
|
16
|
-
function fromLine(line: string, override
|
|
16
|
+
function fromLine(line: string, override = ''): ChannelTarget[] {
|
|
17
17
|
const acct = accountFromLine(line);
|
|
18
|
-
return acct === undefined ? [] : [{ kind: 'channel', station: acct.station, account: override
|
|
18
|
+
return acct === undefined ? [] : [{ kind: 'channel', station: acct.station, account: override || acct.accountId }];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function channelTargets(name: string, a: Record<string, unknown>): ChannelTarget[] {
|
|
@@ -26,7 +26,7 @@ function channelTargets(name: string, a: Record<string, unknown>): ChannelTarget
|
|
|
26
26
|
}
|
|
27
27
|
const account = str(a.account);
|
|
28
28
|
const line = str(a.line);
|
|
29
|
-
if (line) return fromLine(line,
|
|
29
|
+
if (line) return fromLine(line, name === 'list_members' ? undefined : account);
|
|
30
30
|
const station = stationForTool(name, a) ?? (account ? stationOfAccount(account) : undefined);
|
|
31
31
|
if (station === undefined) return [];
|
|
32
32
|
if (account) return [{ kind: 'channel', station, account }];
|
|
@@ -12,6 +12,8 @@ import { BoundedEventStore } from './event-store.js';
|
|
|
12
12
|
import { registerPermissionRelay } from './permission-relay.js';
|
|
13
13
|
import { registerToolHandlers, toolSchemaSignature } from './tool-dispatch.js';
|
|
14
14
|
import { web, type RawGetSink } from './raw-get-stream.js';
|
|
15
|
+
import { sharedCalls } from '../voice/shared.js';
|
|
16
|
+
import { CALL_STATE } from '@metro-labs/core/call';
|
|
15
17
|
|
|
16
18
|
export const channelLog = (...a: unknown[]): void => {
|
|
17
19
|
console.error('[metro-mcp]', ...a);
|
|
@@ -76,9 +78,11 @@ export class McpSession {
|
|
|
76
78
|
private issuedSchema: string | undefined;
|
|
77
79
|
private announcing = false;
|
|
78
80
|
private readonly onClosed: (session: McpSession) => void;
|
|
81
|
+
private readonly kind: SessionKind;
|
|
79
82
|
|
|
80
83
|
private constructor(init: SessionInit) {
|
|
81
84
|
this.id = init.id;
|
|
85
|
+
this.kind = init.kind ?? 'chat';
|
|
82
86
|
this.scope = init.scope;
|
|
83
87
|
this.live = init.live;
|
|
84
88
|
this.issuedSchema = init.adopted ? undefined : toolSchemaSignature();
|
|
@@ -112,6 +116,7 @@ export class McpSession {
|
|
|
112
116
|
senderAllowed,
|
|
113
117
|
approves,
|
|
114
118
|
answerPermission,
|
|
119
|
+
callMeta: (event) => voice ? () => ({}) : sharedCalls.bindChat(event, this.scope),
|
|
115
120
|
});
|
|
116
121
|
if (!voice)
|
|
117
122
|
registerPermissionRelay({
|
|
@@ -167,7 +172,20 @@ export class McpSession {
|
|
|
167
172
|
|
|
168
173
|
bindSink(sink: RawGetSink | undefined): void {
|
|
169
174
|
this.sink = sink;
|
|
170
|
-
if (sink !== undefined)
|
|
175
|
+
if (sink !== undefined) {
|
|
176
|
+
this.announceCallState();
|
|
177
|
+
this.announceToolSchema();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private announceCallState(): void {
|
|
182
|
+
if (this.kind !== 'chat' || !this.streamAttached || this.server.getClientVersion()?.name !== 'metro-sdk-runner') return;
|
|
183
|
+
const active = sharedCalls.snapshot();
|
|
184
|
+
const route = active !== null && this.scope.has(active.agentId) && eventInScope(this.scope, active.line) ? active : null;
|
|
185
|
+
this.server.notification({ method: CALL_STATE, params: { route, ...(route === null ? {} : { meta: { line: route.line } }) } }).catch((err: unknown) => {
|
|
186
|
+
channelLog('session: call state notification failed', errMsg(err));
|
|
187
|
+
if (route !== null) sharedCalls.end(route);
|
|
188
|
+
});
|
|
171
189
|
}
|
|
172
190
|
|
|
173
191
|
private get schemaNoticeDue(): boolean {
|
|
@@ -206,6 +224,7 @@ export class McpSession {
|
|
|
206
224
|
async close(): Promise<void> {
|
|
207
225
|
if (this.closed) return;
|
|
208
226
|
this.closed = true;
|
|
227
|
+
if (this.kind === 'chat') sharedCalls.revoke();
|
|
209
228
|
this.onClosed(this);
|
|
210
229
|
forgetPromptsOf(this.server);
|
|
211
230
|
this.dropStream();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const speechSchema = {
|
|
2
|
+
type: 'object',
|
|
3
|
+
description: 'Speak text only in the exact active Stage call, without posting it in chat. Copy callId, generation and sourceId from its authenticated call input or call_id, call_generation and call_source_id from the requesting channel message. Use only for a call reply or an explicit owner request to speak in that call. Normal chat and worker results stay silent. One action per source; retries do not repeat speech. Accepted or queued is not heard. Completed means audio transport drained, not proof the caller heard it.',
|
|
4
|
+
properties: {
|
|
5
|
+
callId: { type: 'string' },
|
|
6
|
+
generation: { type: 'string' },
|
|
7
|
+
sourceId: { type: 'string' },
|
|
8
|
+
},
|
|
9
|
+
required: ['callId', 'generation', 'sourceId'],
|
|
10
|
+
additionalProperties: false,
|
|
11
|
+
} as const;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { speechTarget } from '@metro-labs/core/call';
|
|
2
|
+
import type { ToolResult } from '@metro-labs/core/stations/types';
|
|
3
|
+
import { sharedCalls } from '../voice/shared.js';
|
|
4
|
+
import { allowedAgents, currentIdentity } from './request-identity.js';
|
|
5
|
+
import { errResult, okJson } from './ctx.js';
|
|
6
|
+
|
|
7
|
+
export function sendSpeech(line: string, args: Record<string, unknown>): ToolResult {
|
|
8
|
+
const target = speechTarget(args.speech);
|
|
9
|
+
if (target === null) return errResult('Call speech needs callId, generation and sourceId from its authenticated source.');
|
|
10
|
+
if (['reply_to', 'subject', 'attachments', 'frame', 'wallet', 'account'].some((key) => args[key] !== undefined))
|
|
11
|
+
return errResult('Call speech cannot include chat content, media or an account override. Nothing was sent.');
|
|
12
|
+
if (typeof args.text !== 'string') return errResult('Call speech needs text.');
|
|
13
|
+
const receipt = sharedCalls.speak(line, target, args.text, allowedAgents(currentIdentity()));
|
|
14
|
+
return okJson({ speech: receipt, delivery: 'Audio transport status only. No proof the caller heard it. No chat message was posted.' });
|
|
15
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDef } from './tool-def.js';
|
|
2
2
|
import { attachmentItem, CREATE_UPLOAD_TOOL } from './attachment-schema.js';
|
|
3
|
+
import { speechSchema } from './speech-schema.js';
|
|
3
4
|
|
|
4
5
|
const lineProp = {
|
|
5
6
|
type: 'string',
|
|
@@ -34,7 +35,7 @@ export const COMMON_TOOLS: ToolDef[] = [
|
|
|
34
35
|
group: 'write',
|
|
35
36
|
description:
|
|
36
37
|
'Send a message (and/or media) to a Metro conversation. Args: line, text?, reply_to?, ' +
|
|
37
|
-
'subject?, attachments?, frame?, wallet?. The station is derived from the line. Outlook and Gmail: send to ' +
|
|
38
|
+
'subject?, attachments?, frame?, wallet?, speech?. `speech` speaks text only in an authorized active call, never posts it in chat. The station is derived from the line. Outlook and Gmail: send to ' +
|
|
38
39
|
'metro://<outlook|gmail>/<account>/<email address> to start a new email (subject defaults to the ' +
|
|
39
40
|
'first line of text); the result names the new thread line. Each attachment names EXACTLY ONE ' +
|
|
40
41
|
'source, and the choice matters: `upload` (an `upload_id` from `create_upload`, the ' +
|
|
@@ -54,6 +55,7 @@ export const COMMON_TOOLS: ToolDef[] = [
|
|
|
54
55
|
type: 'string',
|
|
55
56
|
description: 'The message text (optional if sending only media).',
|
|
56
57
|
},
|
|
58
|
+
speech: speechSchema,
|
|
57
59
|
reply_to: {
|
|
58
60
|
type: 'string',
|
|
59
61
|
description: 'Optional message_id to quote/reply to.',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { log } from '@metro-labs/core/log';
|
|
5
5
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
@@ -11,6 +11,7 @@ const INSTALL_TIMEOUT_MS = 15 * 60_000;
|
|
|
11
11
|
export interface RuntimeManifest {
|
|
12
12
|
core: Record<string, string>;
|
|
13
13
|
stations: Record<string, Record<string, string>>;
|
|
14
|
+
patchedDependencies?: Record<string, string>;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface RuntimeStore {
|
|
@@ -30,6 +31,7 @@ export function readManifest(path: string): RuntimeManifest {
|
|
|
30
31
|
return {
|
|
31
32
|
core: ranges(raw.core),
|
|
32
33
|
stations: Object.fromEntries(Object.entries(stations).map(([name, deps]) => [name, ranges(deps)])),
|
|
34
|
+
...(raw.patchedDependencies === undefined ? {} : { patchedDependencies: ranges(raw.patchedDependencies) }),
|
|
33
35
|
};
|
|
34
36
|
}
|
|
35
37
|
|
|
@@ -46,8 +48,21 @@ export function dependenciesFor(manifest: RuntimeManifest, stations: Iterable<st
|
|
|
46
48
|
return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)));
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
function packageText(deps: Record<string, string>, patches: Record<string, string>): string {
|
|
52
|
+
const patchedDependencies = Object.fromEntries(
|
|
53
|
+
Object.entries(deps).flatMap(([name, version]) => {
|
|
54
|
+
const key = `${name}@${version}`;
|
|
55
|
+
const path = patches[key];
|
|
56
|
+
return path === undefined ? [] : [[key, path]];
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
return `${JSON.stringify({
|
|
60
|
+
name: 'metro-runtime',
|
|
61
|
+
private: true,
|
|
62
|
+
dependencies: deps,
|
|
63
|
+
...(Object.keys(patchedDependencies).length === 0 ? {} : { patchedDependencies }),
|
|
64
|
+
}, null, 2)}\n`;
|
|
65
|
+
}
|
|
51
66
|
|
|
52
67
|
function current(dir: string): string | null {
|
|
53
68
|
try {
|
|
@@ -58,9 +73,10 @@ function current(dir: string): string | null {
|
|
|
58
73
|
}
|
|
59
74
|
|
|
60
75
|
export function installRuntime(store: RuntimeStore, stations: Iterable<string>): boolean {
|
|
61
|
-
const wanted = packageText(dependenciesFor(store.manifest, stations));
|
|
62
|
-
const
|
|
63
|
-
if (
|
|
76
|
+
const wanted = packageText(dependenciesFor(store.manifest, stations), store.manifest.patchedDependencies ?? {});
|
|
77
|
+
const marker = join(store.dir, 'node_modules', '.metro-installed');
|
|
78
|
+
if (existsSync(marker) && current(store.dir) === wanted) return false;
|
|
79
|
+
rmSync(marker, { force: true });
|
|
64
80
|
mkdirSync(store.dir, { recursive: true });
|
|
65
81
|
writeFileSync(join(store.dir, 'package.json'), wanted);
|
|
66
82
|
log.info({ dir: store.dir }, 'runtime: installing the channel SDKs this machine needs');
|
|
@@ -71,7 +87,7 @@ export function installRuntime(store: RuntimeStore, stations: Iterable<string>):
|
|
|
71
87
|
});
|
|
72
88
|
if (run.error !== undefined || run.status !== 0)
|
|
73
89
|
throw new Error(`bun install failed in ${store.dir}: ${run.error?.message ?? `exit ${String(run.status)}`}`);
|
|
74
|
-
writeFileSync(
|
|
90
|
+
writeFileSync(marker, `${new Date().toISOString()}\n`);
|
|
75
91
|
return true;
|
|
76
92
|
}
|
|
77
93
|
|
|
@@ -5,6 +5,7 @@ const RATE = 48_000;
|
|
|
5
5
|
const FRAME = 960;
|
|
6
6
|
const FRAME_MS = 20;
|
|
7
7
|
const MAX_LATE_FRAMES = 5;
|
|
8
|
+
const MAX_QUEUED_FRAMES = 3_000;
|
|
8
9
|
|
|
9
10
|
interface Frame {
|
|
10
11
|
pcm: Int16Array;
|
|
@@ -55,7 +56,7 @@ export class OpusIn {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
export interface Playback {
|
|
58
|
-
send(opus: Buffer, timestamp: number, marker: boolean): void
|
|
59
|
+
send(opus: Buffer, timestamp: number, marker: boolean): void | Promise<void>;
|
|
59
60
|
played(chars: number): void;
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -70,11 +71,15 @@ export class Player {
|
|
|
70
71
|
private speaking = false;
|
|
71
72
|
private onIdle: (() => void) | null = null;
|
|
72
73
|
private onFirst: (() => void) | null = null;
|
|
74
|
+
private onFailed: ((reason: string) => void) | null = null;
|
|
75
|
+
private generation = 0;
|
|
76
|
+
private inFlight = 0;
|
|
77
|
+
private closed = false;
|
|
73
78
|
|
|
74
79
|
constructor(private readonly out: Playback) {}
|
|
75
80
|
|
|
76
81
|
get busy(): boolean {
|
|
77
|
-
return this.queue.length > 0 || this.pending.length > 0;
|
|
82
|
+
return this.queue.length > 0 || this.pending.length > 0 || this.inFlight > 0;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
whenFirstPlayed(fn: () => void): void {
|
|
@@ -85,7 +90,16 @@ export class Player {
|
|
|
85
90
|
this.onIdle = fn;
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
whenFailed(fn: (reason: string) => void): void {
|
|
94
|
+
this.onFailed = fn;
|
|
95
|
+
}
|
|
96
|
+
|
|
88
97
|
push(pcm: Int16Array, chars: number): void {
|
|
98
|
+
if (this.closed) return;
|
|
99
|
+
if (this.queue.length + this.inFlight + Math.ceil((this.pending.length + pcm.length) / FRAME) > MAX_QUEUED_FRAMES) {
|
|
100
|
+
this.failed('audio queue is full');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
89
103
|
const joined = new Int16Array(this.pending.length + pcm.length);
|
|
90
104
|
joined.set(this.pending);
|
|
91
105
|
joined.set(pcm, this.pending.length);
|
|
@@ -101,7 +115,7 @@ export class Player {
|
|
|
101
115
|
}
|
|
102
116
|
|
|
103
117
|
finish(): void {
|
|
104
|
-
if (this.pending.length === 0) return;
|
|
118
|
+
if (this.closed || this.pending.length === 0) return;
|
|
105
119
|
const pcm = new Int16Array(FRAME);
|
|
106
120
|
pcm.set(this.pending);
|
|
107
121
|
this.queue.push({ pcm, chars: this.pendingChars });
|
|
@@ -110,28 +124,36 @@ export class Player {
|
|
|
110
124
|
}
|
|
111
125
|
|
|
112
126
|
clear(): void {
|
|
127
|
+
this.generation += 1;
|
|
128
|
+
this.inFlight = 0;
|
|
129
|
+
this.speaking = false;
|
|
113
130
|
this.queue.length = 0;
|
|
114
131
|
this.pending = new Int16Array(0);
|
|
115
132
|
this.pendingChars = 0;
|
|
116
133
|
}
|
|
117
134
|
|
|
118
135
|
start(): void {
|
|
119
|
-
if (this.timer !== null) return;
|
|
136
|
+
if (this.closed || this.timer !== null) return;
|
|
120
137
|
this.started = performance.now();
|
|
121
138
|
this.sent = 0;
|
|
122
139
|
this.tick();
|
|
123
140
|
}
|
|
124
141
|
|
|
125
142
|
stop(): void {
|
|
143
|
+
if (this.closed) return;
|
|
144
|
+
this.closed = true;
|
|
126
145
|
if (this.timer !== null) clearTimeout(this.timer);
|
|
127
146
|
this.timer = null;
|
|
147
|
+
this.clear();
|
|
128
148
|
this.encoder.delete();
|
|
129
149
|
}
|
|
130
150
|
|
|
131
151
|
private tick(): void {
|
|
152
|
+
if (this.closed) return;
|
|
132
153
|
const due = Math.floor((performance.now() - this.started) / FRAME_MS) + 1;
|
|
133
154
|
if (due - this.sent > MAX_LATE_FRAMES) this.sent = due - 1;
|
|
134
|
-
while (this.sent < due) this.sendOne();
|
|
155
|
+
while (!this.closed && this.sent < due) this.sendOne();
|
|
156
|
+
if (this.closed) return;
|
|
135
157
|
const next = this.started + this.sent * FRAME_MS;
|
|
136
158
|
this.timer = setTimeout(() => {
|
|
137
159
|
this.tick();
|
|
@@ -143,11 +165,29 @@ export class Player {
|
|
|
143
165
|
const marker = frame !== undefined && !this.speaking;
|
|
144
166
|
this.speaking = frame !== undefined;
|
|
145
167
|
const pcm = frame?.pcm ?? new Int16Array(FRAME);
|
|
146
|
-
|
|
168
|
+
const timestamp = (this.sent * FRAME) >>> 0;
|
|
147
169
|
this.sent += 1;
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
170
|
+
const generation = this.generation;
|
|
171
|
+
if (frame !== undefined) this.inFlight += 1;
|
|
172
|
+
try {
|
|
173
|
+
const sent = this.out.send(this.encoder.encode(bytesOf(pcm), FRAME), timestamp, marker);
|
|
174
|
+
Promise.resolve(sent).then(() => {
|
|
175
|
+
if (this.closed || generation !== this.generation || frame === undefined) return;
|
|
176
|
+
this.inFlight -= 1;
|
|
177
|
+
if (marker) this.onFirst?.();
|
|
178
|
+
if (frame.chars > 0) this.out.played(frame.chars);
|
|
179
|
+
if (!this.busy) this.onIdle?.();
|
|
180
|
+
}).catch((err: unknown) => {
|
|
181
|
+
if (!this.closed && generation === this.generation) this.failed(errMsg(err));
|
|
182
|
+
});
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (!this.closed && generation === this.generation) this.failed(errMsg(err));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private failed(reason: string): void {
|
|
189
|
+
this.clear();
|
|
190
|
+
log.warn({ reason }, 'voice: audio playback failed');
|
|
191
|
+
this.onFailed?.(reason);
|
|
152
192
|
}
|
|
153
193
|
}
|
|
@@ -50,15 +50,21 @@ function streamEvent(msg: Record<string, unknown>, mind: Thinking): void {
|
|
|
50
50
|
if (event.type === 'content_block_start' && block.type === 'tool_use') mind.tool(typeof block.name === 'string' ? block.name : 'tool');
|
|
51
51
|
}
|
|
52
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
|
+
|
|
53
59
|
export class Brain {
|
|
54
60
|
private readonly child: ChildProcessWithoutNullStreams;
|
|
55
61
|
private out = '';
|
|
56
62
|
private gone = false;
|
|
63
|
+
private closing = false;
|
|
64
|
+
private killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
57
65
|
|
|
58
|
-
constructor(model: string, private readonly mind: Thinking) {
|
|
59
|
-
|
|
60
|
-
const [file, argv] = asAgent(command, args);
|
|
61
|
-
this.child = spawn(file, argv, { stdio: ['pipe', 'pipe', 'pipe'], cwd: '/' });
|
|
66
|
+
constructor(model: string, private readonly mind: Thinking, launch = startBrain) {
|
|
67
|
+
this.child = launch(model);
|
|
62
68
|
this.child.stdout.setEncoding('utf8');
|
|
63
69
|
this.child.stdout.on('data', (chunk: string) => {
|
|
64
70
|
this.out = drainLines('voice-brain', this.out + chunk, (line) => {
|
|
@@ -91,18 +97,22 @@ export class Brain {
|
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
close(): void {
|
|
94
|
-
if (this.gone) return;
|
|
100
|
+
if (this.gone || this.closing) return;
|
|
101
|
+
this.closing = true;
|
|
95
102
|
this.child.stdin.end();
|
|
96
|
-
|
|
103
|
+
this.child.kill('SIGTERM');
|
|
104
|
+
this.killTimer = setTimeout(() => {
|
|
97
105
|
if (!this.gone) this.child.kill('SIGKILL');
|
|
98
|
-
}, CLOSE_GRACE_MS)
|
|
106
|
+
}, CLOSE_GRACE_MS);
|
|
107
|
+
this.killTimer.unref();
|
|
99
108
|
}
|
|
100
109
|
|
|
101
110
|
private write(message: Record<string, unknown>): void {
|
|
102
|
-
if (!this.gone && this.child.stdin.writable) this.child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
111
|
+
if (!this.gone && !this.closing && this.child.stdin.writable) this.child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
103
112
|
}
|
|
104
113
|
|
|
105
114
|
private read(line: string): void {
|
|
115
|
+
if (this.closing || this.gone) return;
|
|
106
116
|
let parsed: unknown;
|
|
107
117
|
try {
|
|
108
118
|
parsed = JSON.parse(line);
|
|
@@ -130,6 +140,7 @@ export class Brain {
|
|
|
130
140
|
private exit(reason: string): void {
|
|
131
141
|
if (this.gone) return;
|
|
132
142
|
this.gone = true;
|
|
133
|
-
this.
|
|
143
|
+
clearTimeout(this.killTimer);
|
|
144
|
+
if (!this.closing) this.mind.exited(reason);
|
|
134
145
|
}
|
|
135
146
|
}
|