@stage-labs/metro 0.1.0-beta.307 → 0.1.0-beta.309
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/agent-user/view.ts +3 -9
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +14 -12
- 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 +39 -118
- 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/gateway/bedrock.ts +1 -6
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +53 -29
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-store.ts +55 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +40 -62
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +82 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +47 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +18 -19
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +37 -77
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +86 -220
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +80 -283
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-import.ts +125 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +0 -17
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-v2.ts +79 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +0 -79
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/org-models.ts +231 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +4 -44
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/text.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +37 -57
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +0 -13
- 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/routes/local-mode.ts +3 -4
- 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/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/gateway/codex-auth.ts +0 -219
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +0 -119
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-auth.ts +0 -160
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-client.ts +0 -46
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-setup.ts +0 -150
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +0 -139
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +0 -236
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +0 -208
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +0 -157
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +0 -83
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-login.ts +0 -192
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter-signin.ts +0 -64
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/pkce.ts +0 -36
- 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
|
@@ -2,7 +2,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
2
2
|
import {
|
|
3
3
|
BodyTooLargeError,
|
|
4
4
|
readBody as readBodyBuffer,
|
|
5
|
-
VOICE_MCP_PATH,
|
|
6
5
|
} from '../routes/http.js';
|
|
7
6
|
import {
|
|
8
7
|
authenticate,
|
|
@@ -97,9 +96,7 @@ async function serveGet(
|
|
|
97
96
|
if (served) session.replayMissed();
|
|
98
97
|
}
|
|
99
98
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
let activeSlots: SessionSlot[] = [];
|
|
99
|
+
let activeSlot: SessionSlot | undefined;
|
|
103
100
|
let stopToolNotices: (() => void) | undefined;
|
|
104
101
|
|
|
105
102
|
const MODEL_NOTICE = 'notifications/metro/model';
|
|
@@ -118,13 +115,12 @@ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Pr
|
|
|
118
115
|
setLiveEvents: (on: boolean) => void;
|
|
119
116
|
switchModel: (model: string | null) => boolean;
|
|
120
117
|
}> {
|
|
121
|
-
await
|
|
118
|
+
await activeSlot?.close();
|
|
122
119
|
const slot = new SessionSlot(options.liveEvents);
|
|
123
|
-
|
|
124
|
-
activeSlots = [slot, voiceSlot];
|
|
120
|
+
activeSlot = slot;
|
|
125
121
|
stopToolNotices?.();
|
|
126
122
|
stopToolNotices = onConnectorToolsChanged(() => {
|
|
127
|
-
|
|
123
|
+
slot.current?.toolsChanged();
|
|
128
124
|
});
|
|
129
125
|
connectCallBridge(() => slot.current);
|
|
130
126
|
connectServerApprovals(() => slot.current);
|
|
@@ -142,13 +138,7 @@ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Pr
|
|
|
142
138
|
const parsed = await readOrReject(req, res);
|
|
143
139
|
if (!parsed.ok) return;
|
|
144
140
|
await runWithIdentity(identity, async () => {
|
|
145
|
-
const session = await resolveSession(
|
|
146
|
-
isVoicePath(req) ? voiceSlot : slot,
|
|
147
|
-
req,
|
|
148
|
-
parsed.body,
|
|
149
|
-
identity,
|
|
150
|
-
res,
|
|
151
|
-
);
|
|
141
|
+
const session = await resolveSession(slot, req, parsed.body, identity, res);
|
|
152
142
|
if (!session) return;
|
|
153
143
|
if (isStandaloneGet(req)) {
|
|
154
144
|
await serveGet(session, req, res);
|
|
@@ -19,14 +19,3 @@ export const MCP_INSTRUCTIONS =
|
|
|
19
19
|
'prompts are relayed to the chat; the owner answers "yes <id>"/"no <id>". Connectors ' +
|
|
20
20
|
'(third-party tools like Notion, n8n, Gmail) are reached only through `tool_search` then ' +
|
|
21
21
|
'`tool_call`, from a worker.';
|
|
22
|
-
|
|
23
|
-
export const VOICE_MCP_INSTRUCTIONS =
|
|
24
|
-
"Metro's chat tools, opened for a live voice call. The caller hears what you say, so do not " +
|
|
25
|
-
'answer them with `send` or `reply` and do not post the call in a chat; post, react or act on ' +
|
|
26
|
-
'a channel only when the caller asks for it, once. `read` with a `line` (passed verbatim) ' +
|
|
27
|
-
'shows that chat; `group_info` and `list_members` show who is in it; `get_profile` with a ' +
|
|
28
|
-
'`from` names a sender. The owner may block a tool on an account or make it need approval ' +
|
|
29
|
-
'(`list_accounts` shows each `policy`). A blocked call errors. A call needing approval must ' +
|
|
30
|
-
'run in a background worker: on the main thread it is refused, so delegate that exact call ' +
|
|
31
|
-
'and keep talking; the worker waits while the owner is asked in the chat of the call, where ' +
|
|
32
|
-
'they answer "yes <id>"/"no <id>". Connectors are reached through `tool_search` then `tool_call`.';
|
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { currentBusSeq } from '@metro-labs/core/events';
|
|
3
3
|
import { errMsg } from '@metro-labs/core/log';
|
|
4
4
|
import { newReplayLedger } from '../channels/relay.js';
|
|
5
|
-
import { McpSession, channelLog
|
|
5
|
+
import { McpSession, channelLog } from './session.js';
|
|
6
6
|
import { allowedAgents, type RequestIdentity } from './request-identity.js';
|
|
7
7
|
import { diagnosticTag, type SessionDiagnostic, type SlotDiagnostic } from './diagnostics.js';
|
|
8
8
|
|
|
@@ -25,10 +25,7 @@ export class SessionSlot {
|
|
|
25
25
|
private changing: Promise<void> = Promise.resolve();
|
|
26
26
|
private readonly replacements: SlotReplacement[] = [];
|
|
27
27
|
|
|
28
|
-
constructor(
|
|
29
|
-
live = true,
|
|
30
|
-
private readonly kind: SessionKind = 'chat',
|
|
31
|
-
) {
|
|
28
|
+
constructor(live = true) {
|
|
32
29
|
this.live = live;
|
|
33
30
|
}
|
|
34
31
|
|
|
@@ -111,7 +108,6 @@ export class SessionSlot {
|
|
|
111
108
|
if (this.ledger.startAt < 0) this.ledger.startAt = currentBusSeq();
|
|
112
109
|
const id = adoptId ?? randomUUID();
|
|
113
110
|
const session = await McpSession.open({
|
|
114
|
-
kind: this.kind,
|
|
115
111
|
id,
|
|
116
112
|
scope: allowedAgents(identity),
|
|
117
113
|
adopted: adoptId !== undefined,
|
|
@@ -129,7 +125,7 @@ export class SessionSlot {
|
|
|
129
125
|
this.session = session;
|
|
130
126
|
this.recordReplacement(session, adoptId, previous);
|
|
131
127
|
if (this.started && this.live) session.startChannel();
|
|
132
|
-
channelLog('session: opened', 'id', id, '
|
|
128
|
+
channelLog('session: opened', 'id', id, 'adopted', adoptId !== undefined);
|
|
133
129
|
return session;
|
|
134
130
|
}
|
|
135
131
|
|
|
@@ -8,7 +8,7 @@ import { errMsg } from '@metro-labs/core/log';
|
|
|
8
8
|
import { allowlistForLine, mayApprove, senderPermitted } from '../agents/map.js';
|
|
9
9
|
import { accountStationNames, stationByName } from '../stations/registry.js';
|
|
10
10
|
import { eventInScope } from '../agents/scope.js';
|
|
11
|
-
import { MCP_INSTRUCTIONS
|
|
11
|
+
import { MCP_INSTRUCTIONS } from './instructions.js';
|
|
12
12
|
import { BoundedEventStore } from './event-store.js';
|
|
13
13
|
import { registerPermissionRelay } from './permission-relay.js';
|
|
14
14
|
import { registerToolHandlers, toolSchemaSignature } from './tool-dispatch.js';
|
|
@@ -55,12 +55,7 @@ function makeTransport(
|
|
|
55
55
|
return t;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
export type SessionKind = 'chat' | 'voice';
|
|
59
|
-
|
|
60
|
-
const CHANNEL_CAPABILITIES = { experimental: { 'claude/channel': {}, 'claude/channel/permission': {} } };
|
|
61
|
-
|
|
62
58
|
export interface SessionInit {
|
|
63
|
-
kind?: SessionKind;
|
|
64
59
|
id: string;
|
|
65
60
|
scope: Set<string>;
|
|
66
61
|
adopted: boolean;
|
|
@@ -84,7 +79,6 @@ export class McpSession {
|
|
|
84
79
|
private issuedSchema: string | undefined;
|
|
85
80
|
private announcing = false;
|
|
86
81
|
private readonly onClosed: (session: McpSession) => void;
|
|
87
|
-
private readonly kind: SessionKind;
|
|
88
82
|
private readonly openedAt = new Date().toISOString();
|
|
89
83
|
private clientInitializedAt: string | null = null;
|
|
90
84
|
private lastStreamAttachedAt: string | null = null;
|
|
@@ -92,20 +86,15 @@ export class McpSession {
|
|
|
92
86
|
|
|
93
87
|
private constructor(init: SessionInit) {
|
|
94
88
|
this.id = init.id;
|
|
95
|
-
this.kind = init.kind ?? 'chat';
|
|
96
89
|
this.scope = init.scope;
|
|
97
90
|
this.live = init.live;
|
|
98
91
|
this.issuedSchema = init.adopted ? undefined : toolSchemaSignature();
|
|
99
92
|
this.onClosed = init.onClosed;
|
|
100
93
|
this.eventStore = new BoundedEventStore();
|
|
101
94
|
this.transport = makeTransport(init.id, this.eventStore, init.adopted);
|
|
102
|
-
const voice = init.kind === 'voice';
|
|
103
95
|
this.server = new Server(
|
|
104
96
|
{ name: 'metro', version: '0.1.0' },
|
|
105
|
-
{
|
|
106
|
-
capabilities: { ...(voice ? {} : CHANNEL_CAPABILITIES), tools: { listChanged: true } },
|
|
107
|
-
instructions: voice ? VOICE_MCP_INSTRUCTIONS : MCP_INSTRUCTIONS,
|
|
108
|
-
},
|
|
97
|
+
{ capabilities: { tools: { listChanged: true } }, instructions: MCP_INSTRUCTIONS },
|
|
109
98
|
);
|
|
110
99
|
this.server.oninitialized = (): void => {
|
|
111
100
|
this.clientInitializedAt = new Date().toISOString();
|
|
@@ -130,16 +119,15 @@ export class McpSession {
|
|
|
130
119
|
approves,
|
|
131
120
|
noteDirect,
|
|
132
121
|
answerPermission,
|
|
133
|
-
callMeta: (event) =>
|
|
122
|
+
callMeta: (event) => sharedCalls.bindChat(event, this.scope),
|
|
123
|
+
});
|
|
124
|
+
registerPermissionRelay({
|
|
125
|
+
mcp: this.server,
|
|
126
|
+
relay: this.relay,
|
|
127
|
+
inScope: (line) => this.inScope(line),
|
|
128
|
+
live: this.live,
|
|
129
|
+
log: channelLog,
|
|
134
130
|
});
|
|
135
|
-
if (!voice)
|
|
136
|
-
registerPermissionRelay({
|
|
137
|
-
mcp: this.server,
|
|
138
|
-
relay: this.relay,
|
|
139
|
-
inScope: (line) => this.inScope(line),
|
|
140
|
-
live: this.live,
|
|
141
|
-
log: channelLog,
|
|
142
|
-
});
|
|
143
131
|
this.channel = new ChannelRelay({
|
|
144
132
|
relay: this.relay,
|
|
145
133
|
log: channelLog,
|
|
@@ -214,7 +202,7 @@ export class McpSession {
|
|
|
214
202
|
}
|
|
215
203
|
|
|
216
204
|
private announceCallState(): void {
|
|
217
|
-
if (
|
|
205
|
+
if (!this.streamAttached || this.server.getClientVersion()?.name !== 'metro-sdk-runner') return;
|
|
218
206
|
const active = sharedCalls.snapshot();
|
|
219
207
|
const route = active !== null && this.scope.has(active.agentId) && eventInScope(this.scope, active.line) ? active : null;
|
|
220
208
|
this.server.notification({ method: CALL_STATE, params: { route, ...(route === null ? {} : { meta: { line: route.line } }) } }).catch((err: unknown) => {
|
|
@@ -264,7 +252,7 @@ export class McpSession {
|
|
|
264
252
|
async close(): Promise<void> {
|
|
265
253
|
if (this.closed) return;
|
|
266
254
|
this.closed = true;
|
|
267
|
-
|
|
255
|
+
sharedCalls.revoke();
|
|
268
256
|
this.onClosed(this);
|
|
269
257
|
forgetPromptsOf(this.server);
|
|
270
258
|
this.dropStream();
|
|
@@ -6,9 +6,6 @@ import { bearerSession, clientId, jwksUrl, SigningKeys, workosBase, type KeyStor
|
|
|
6
6
|
import { ensureSecureDir, writeSecure } from '@metro-labs/core/secure-fs';
|
|
7
7
|
|
|
8
8
|
const KEYS_FILE = '.jwks';
|
|
9
|
-
const OPERATOR_USER = 'user_01M2TN3K6NRVX675M617GR4WVE';
|
|
10
|
-
|
|
11
|
-
export const isOperatorUser = (user: string): boolean => user === OPERATOR_USER;
|
|
12
9
|
|
|
13
10
|
export function jwksStore(dir: string): KeyStore {
|
|
14
11
|
const path = join(dir, KEYS_FILE);
|
|
@@ -28,7 +25,7 @@ export function bearerSessionsFor(owner: () => string | null, keys: SigningKeys)
|
|
|
28
25
|
const held = owner();
|
|
29
26
|
if (held === null || session.organization === null || session.organization !== held)
|
|
30
27
|
throw new ApiError('this machine belongs to another organization', 403);
|
|
31
|
-
return { subject: held, userId: session.userId, sessionId: session.sessionId, role: session.role === 'admin' ? 'admin' : 'member'
|
|
28
|
+
return { subject: held, userId: session.userId, sessionId: session.sessionId, role: session.role === 'admin' ? 'admin' : 'member' };
|
|
32
29
|
};
|
|
33
30
|
}
|
|
34
31
|
|
|
@@ -38,7 +38,6 @@ import { applyMcpCors, handleMcpPreflight } from '@metro-labs/http/cors';
|
|
|
38
38
|
import { METRO_VERSION } from '@metro-labs/core/version';
|
|
39
39
|
|
|
40
40
|
const LRU_CAP = 2_000;
|
|
41
|
-
export const VOICE_MCP_PATH = '/mcp/voice';
|
|
42
41
|
|
|
43
42
|
function dedupKey(
|
|
44
43
|
e: Pick<MetroEvent, 'station' | 'line' | 'messageId'>,
|
|
@@ -214,7 +213,7 @@ export async function startWebhookServer(
|
|
|
214
213
|
|
|
215
214
|
function isMcpPath(req: IncomingMessage): boolean {
|
|
216
215
|
const path = (req.url ?? '').split('?')[0];
|
|
217
|
-
return path === '/' || path === '/mcp' || path ===
|
|
216
|
+
return path === '/' || path === '/mcp' || path === MCP_STATUS_PATH;
|
|
218
217
|
}
|
|
219
218
|
|
|
220
219
|
function flatHeaders(req: IncomingMessage): Record<string, string> {
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
localOwner,
|
|
24
24
|
readLocalAgentFile,
|
|
25
25
|
} from '../agents/file-admin.js';
|
|
26
|
-
import {
|
|
26
|
+
import { effectiveSetup } from '../gateway/effective.js';
|
|
27
27
|
import type { StationName } from '@metro-labs/core/station-names';
|
|
28
28
|
|
|
29
29
|
export interface LocalModeDeps {
|
|
@@ -36,7 +36,6 @@ export interface LocalModeDeps {
|
|
|
36
36
|
toolGroups?: AgentApiDeps['toolGroups'];
|
|
37
37
|
prepareAccount: AccountApiDeps['prepareAccount'];
|
|
38
38
|
liveEvents?: (on: boolean) => void;
|
|
39
|
-
switchModel?: (model: string | null) => boolean;
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
function attachSessions(deps: LocalModeDeps): AttachSessions {
|
|
@@ -142,8 +141,8 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
142
141
|
controlApi: { restart: deps.restart, stop: deps.stop },
|
|
143
142
|
ownerApi: { setOwner: setLocalOwner },
|
|
144
143
|
machineApi: {},
|
|
145
|
-
modelApi: {
|
|
146
|
-
gateway: {
|
|
144
|
+
modelApi: {},
|
|
145
|
+
gateway: { setup: () => effectiveSetup() },
|
|
147
146
|
terminalApi: {},
|
|
148
147
|
mode: localModeInfo,
|
|
149
148
|
};
|
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import type { CallRoute } from '@metro-labs/core/call';
|
|
3
3
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
4
|
import { forwardTrainCall } from '../stations/train-call.js';
|
|
6
|
-
import { CallApprovals } from './approvals.js';
|
|
7
5
|
import type { Playback } from './audio.js';
|
|
8
6
|
import { Peer, type PeerEvents } from './peer.js';
|
|
9
7
|
import { SharedTalk } from './shared-talk.js';
|
|
10
|
-
import { endSharedCall, hearSharedCall, openSharedCall,
|
|
11
|
-
import { Talk } from './talk.js';
|
|
8
|
+
import { endSharedCall, hearSharedCall, openSharedCall, type SharedCallTransport } from './shared.js';
|
|
12
9
|
import type { VoiceConfig } from './store.js';
|
|
13
10
|
|
|
14
11
|
const OFFER_WAIT_MS = 45_000;
|
|
15
12
|
const LONGEST_CALL_MS = 60 * 60_000;
|
|
16
|
-
const CONTEXT_MESSAGES = 12;
|
|
17
|
-
const CONTEXT_CHARS = 400;
|
|
18
13
|
const LEAVE_RETRY_MS = 3_000;
|
|
19
14
|
const SIGNAL_WAIT_MS = 2_000;
|
|
20
15
|
|
|
@@ -22,24 +17,18 @@ export interface CallStart {
|
|
|
22
17
|
agentId: string;
|
|
23
18
|
sourceId: string;
|
|
24
19
|
line: string;
|
|
25
|
-
lineName: string;
|
|
26
|
-
direct: boolean;
|
|
27
20
|
from: string;
|
|
28
|
-
callerName: string;
|
|
29
21
|
callId: string;
|
|
30
22
|
callerPeer: string;
|
|
31
23
|
}
|
|
32
24
|
|
|
33
|
-
type CallAudio = Pick<
|
|
25
|
+
type CallAudio = Pick<SharedTalk, 'connect' | 'hear' | 'finish'> & SharedCallTransport;
|
|
34
26
|
type CallPeer = Pick<Peer, 'answer' | 'sendOpus' | 'close'>;
|
|
35
|
-
type ContextStart = Omit<CallStart, 'agentId' | 'sourceId'>;
|
|
36
27
|
|
|
37
28
|
export interface CallDependencies {
|
|
38
29
|
train: typeof trainCall;
|
|
39
30
|
peer(events: PeerEvents): CallPeer;
|
|
40
|
-
|
|
41
|
-
shared(cfg: VoiceConfig, out: Playback['send'], ended: (reason: string) => void, heard: (text: string, sourceId: string) => void): CallAudio & SharedCallTransport;
|
|
42
|
-
selected: typeof sharedCallsSelected;
|
|
31
|
+
shared(cfg: VoiceConfig, out: Playback['send'], ended: (reason: string) => void, heard: (text: string, sourceId: string) => void): CallAudio;
|
|
43
32
|
open: typeof openSharedCall;
|
|
44
33
|
heard: typeof hearSharedCall;
|
|
45
34
|
ended: typeof endSharedCall;
|
|
@@ -47,8 +36,6 @@ export interface CallDependencies {
|
|
|
47
36
|
leaveRetryMs: number;
|
|
48
37
|
}
|
|
49
38
|
|
|
50
|
-
const str = (value: unknown): string => (typeof value === 'string' ? value : '');
|
|
51
|
-
|
|
52
39
|
export async function trainCall(action: string, args: Record<string, unknown>): Promise<unknown> {
|
|
53
40
|
const answer = await forwardTrainCall('xmtp', action, args);
|
|
54
41
|
if (answer.error !== undefined) throw new Error(answer.error);
|
|
@@ -58,9 +45,7 @@ export async function trainCall(action: string, args: Record<string, unknown>):
|
|
|
58
45
|
const dependencies: CallDependencies = {
|
|
59
46
|
train: trainCall,
|
|
60
47
|
peer: (events) => new Peer(events),
|
|
61
|
-
cli: (cfg, out, ended, approvals) => new Talk(cfg, out, ended, approvals),
|
|
62
48
|
shared: (cfg, out, ended, heard) => new SharedTalk(cfg, out, ended, heard),
|
|
63
|
-
selected: sharedCallsSelected,
|
|
64
49
|
open: openSharedCall,
|
|
65
50
|
heard: hearSharedCall,
|
|
66
51
|
ended: endSharedCall,
|
|
@@ -68,51 +53,11 @@ const dependencies: CallDependencies = {
|
|
|
68
53
|
leaveRetryMs: LEAVE_RETRY_MS,
|
|
69
54
|
};
|
|
70
55
|
|
|
71
|
-
function speaker(row: Record<string, unknown>, start: ContextStart): string {
|
|
72
|
-
if (row.self === true) return 'you';
|
|
73
|
-
return row.from === start.from ? start.callerName : `another member (${str(row.from)})`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function contextLine(row: unknown, start: ContextStart): string | null {
|
|
77
|
-
if (!isRecord(row) || typeof row.text !== 'string' || row.text.startsWith('[call')) return null;
|
|
78
|
-
const who = speaker(row, start);
|
|
79
|
-
return `${str(row.ts).slice(11, 16)} ${who}: ${row.text.replace(/\s+/g, ' ').slice(0, CONTEXT_CHARS)}`;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function recentChat(start: CallStart, train: typeof trainCall): Promise<string> {
|
|
83
|
-
try {
|
|
84
|
-
const result = await train('read', { line: start.line, limit: CONTEXT_MESSAGES });
|
|
85
|
-
const rows = isRecord(result) && Array.isArray(result.messages) ? result.messages : [];
|
|
86
|
-
return rows.map((row) => contextLine(row, start)).filter((l): l is string => l !== null).join('\n');
|
|
87
|
-
} catch (err) {
|
|
88
|
-
log.warn({ err: errMsg(err) }, 'voice: could not read the chat before the call');
|
|
89
|
-
return '';
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function where(start: ContextStart): string {
|
|
94
|
-
if (start.lineName !== '') return `the group chat "${start.lineName}"`;
|
|
95
|
-
return start.direct ? `your direct chat with ${start.callerName}` : 'a group chat with no name';
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function opening(start: ContextStart, chat: string): string {
|
|
99
|
-
const recent = chat === '' ? 'No earlier messages were found there.' : `Recent messages there before the call, oldest first (times in UTC):\n${chat}`;
|
|
100
|
-
return [
|
|
101
|
-
`[A voice call with ${start.callerName} is starting on Stage, in ${where(start)}.`,
|
|
102
|
-
`Line of this chat: ${start.line} (pass it verbatim to the metro tools, e.g. read, to look at or act on this chat).`,
|
|
103
|
-
`Caller: ${start.callerName} (${start.from}).`,
|
|
104
|
-
recent,
|
|
105
|
-
`Greet ${start.callerName} in a few words.]`,
|
|
106
|
-
].join('\n');
|
|
107
|
-
}
|
|
108
|
-
|
|
109
56
|
export class Call {
|
|
110
57
|
readonly route: CallRoute;
|
|
111
58
|
private readonly selfPeer = randomUUID();
|
|
112
59
|
private readonly startedAt = Date.now();
|
|
113
60
|
private readonly talk: CallAudio;
|
|
114
|
-
private readonly shared: SharedCallTransport | null;
|
|
115
|
-
private readonly prime: ((context: string) => void) | null;
|
|
116
61
|
private readonly deps: CallDependencies;
|
|
117
62
|
private peer: CallPeer | null = null;
|
|
118
63
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -136,24 +81,9 @@ export class Call {
|
|
|
136
81
|
const ended = (reason: string): void => {
|
|
137
82
|
this.hangUp(reason);
|
|
138
83
|
};
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
});
|
|
143
|
-
this.talk = shared;
|
|
144
|
-
this.shared = shared;
|
|
145
|
-
this.prime = null;
|
|
146
|
-
} else {
|
|
147
|
-
const approvals = new CallApprovals(start.line, async (text) => {
|
|
148
|
-
await this.deps.train('send', { line: start.line, text });
|
|
149
|
-
});
|
|
150
|
-
const cli = this.deps.cli(cfg, out, ended, approvals);
|
|
151
|
-
this.talk = cli;
|
|
152
|
-
this.shared = null;
|
|
153
|
-
this.prime = (context) => {
|
|
154
|
-
cli.prime(context);
|
|
155
|
-
};
|
|
156
|
-
}
|
|
84
|
+
this.talk = this.deps.shared(cfg, out, ended, (text, sourceId) => {
|
|
85
|
+
if (!this.over) this.deps.heard(this.route, text, sourceId);
|
|
86
|
+
});
|
|
157
87
|
this.longest = setTimeout(() => {
|
|
158
88
|
this.hangUp('the call reached one hour');
|
|
159
89
|
}, LONGEST_CALL_MS);
|
|
@@ -163,15 +93,7 @@ export class Call {
|
|
|
163
93
|
if (this.over || this.begun) return;
|
|
164
94
|
this.begun = true;
|
|
165
95
|
log.info({ line: this.start.line, callId: this.start.callId }, 'voice: answering a call');
|
|
166
|
-
if (this.shared
|
|
167
|
-
if (!this.deps.open(this.route, this.start.sourceId, this.shared)) throw new Error('the shared conversation is unavailable');
|
|
168
|
-
} else {
|
|
169
|
-
recentChat(this.start, this.deps.train).then((chat) => {
|
|
170
|
-
if (!this.over) this.prime?.(opening(this.start, chat));
|
|
171
|
-
}).catch((err: unknown) => {
|
|
172
|
-
log.warn({ err: errMsg(err) }, 'voice: could not start the agent on the call');
|
|
173
|
-
});
|
|
174
|
-
}
|
|
96
|
+
if (!this.deps.open(this.route, this.start.sourceId, this.talk)) throw new Error('the shared conversation is unavailable');
|
|
175
97
|
await this.signal({ kind: 'join', callId: this.start.callId, from: this.selfPeer });
|
|
176
98
|
if (this.over) {
|
|
177
99
|
await this.sendLeave();
|
|
@@ -235,14 +157,9 @@ export class Call {
|
|
|
235
157
|
log.info({ reason, seconds: Math.round((Date.now() - this.startedAt) / 1000) }, 'voice: call over');
|
|
236
158
|
this.talk.finish();
|
|
237
159
|
this.peer?.close();
|
|
238
|
-
|
|
160
|
+
this.deps.ended(this.route);
|
|
239
161
|
this.onOver();
|
|
240
|
-
this.left = this.sendLeave()
|
|
241
|
-
if (this.shared !== null || reason.startsWith('the caller') || reason === 'no offer arrived') return;
|
|
242
|
-
await this.deps.train('send', { line: this.start.line, text: `The voice call ended: ${reason}.` });
|
|
243
|
-
}).catch((err: unknown) => {
|
|
244
|
-
log.warn({ err: errMsg(err) }, 'voice: wrapping up the call failed');
|
|
245
|
-
});
|
|
162
|
+
this.left = this.sendLeave();
|
|
246
163
|
}
|
|
247
164
|
|
|
248
165
|
private async sendLeave(): Promise<void> {
|
|
@@ -26,7 +26,6 @@ const str = (value: unknown): string => (typeof value === 'string' ? value : '')
|
|
|
26
26
|
|
|
27
27
|
const contentTypeOf = (env: TrainEvent): string => str(record(env.payload).contentType);
|
|
28
28
|
const sourceIdOf = (env: TrainEvent): string => str(env.message_id) || str(env.id) || randomUUID();
|
|
29
|
-
const callerNameOf = (env: TrainEvent): string => str(env.from_display_name) || str(env.from_name) || 'the caller';
|
|
30
29
|
|
|
31
30
|
export const isCallEvent = (env: TrainEvent): boolean => CALL_TYPES.has(contentTypeOf(env));
|
|
32
31
|
|
|
@@ -74,9 +73,7 @@ function invite(env: TrainEvent, body: Record<string, unknown>, create: MakeCall
|
|
|
74
73
|
invites.add(key);
|
|
75
74
|
if (invites.size > INVITES_MAX) invites.delete(invites.values().next().value ?? '');
|
|
76
75
|
current?.hangUp('the caller started a new call');
|
|
77
|
-
const
|
|
78
|
-
const sourceId = sourceIdOf(env);
|
|
79
|
-
const start = { agentId, sourceId, line, lineName: str(env.line_name), direct: env.is_private === true, from, callerName, callId, callerPeer };
|
|
76
|
+
const start = { agentId, sourceId: sourceIdOf(env), line, from, callId, callerPeer };
|
|
80
77
|
const call = create(start, readVoice(), () => {
|
|
81
78
|
if (current === call) current = null;
|
|
82
79
|
});
|
|
@@ -7,7 +7,7 @@ export const elevenLabs = (): string => process.env.METRO_ELEVENLABS_URL ?? 'wss
|
|
|
7
7
|
const STT_RATE = 16_000;
|
|
8
8
|
const CHUNK_SAMPLES = STT_RATE / 10;
|
|
9
9
|
const HELD_SAMPLES = STT_RATE * 2;
|
|
10
|
-
|
|
10
|
+
const VAD_SILENCE_SECS = 0.5;
|
|
11
11
|
|
|
12
12
|
export interface Heard {
|
|
13
13
|
partial(text: string): void;
|
|
@@ -4,7 +4,6 @@ import { str } from '@metro-labs/core/str';
|
|
|
4
4
|
import { log } from '@metro-labs/core/log';
|
|
5
5
|
import { agentIdForLine, allowlistForLine, lineReceives, mayApprove, senderPermitted, accountFromLine } from '../agents/map.js';
|
|
6
6
|
import { eventInScope } from '../agents/scope.js';
|
|
7
|
-
import { harnessRunner } from '../claude/runner.js';
|
|
8
7
|
import { policyFor, type Access } from '../policy/policy.js';
|
|
9
8
|
|
|
10
9
|
export interface SharedSpeechAction {
|
|
@@ -194,7 +193,6 @@ export class SharedCalls {
|
|
|
194
193
|
}
|
|
195
194
|
|
|
196
195
|
export const sharedCalls = new SharedCalls();
|
|
197
|
-
export const sharedCallsSelected = (): boolean => harnessRunner() === 'sdk';
|
|
198
196
|
export const openSharedCall = (route: CallRoute, sourceId: string, transport: SharedCallTransport): boolean => sharedCalls.open(route, sourceId, transport);
|
|
199
197
|
export const hearSharedCall = (route: CallRoute, text: string, sourceId: string): void => { sharedCalls.heard(route, text, sourceId); };
|
|
200
198
|
export const endSharedCall = (route: CallRoute): void => { sharedCalls.end(route); };
|
|
@@ -61,10 +61,6 @@ export class Utterance {
|
|
|
61
61
|
});
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
get usable(): boolean {
|
|
65
|
-
return !this.closed && this.listener === null && this.ws.readyState <= WebSocket.OPEN;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
64
|
attach(listener: Voiced): void {
|
|
69
65
|
if (!this.aborted && !this.finished) this.listener = listener;
|
|
70
66
|
}
|
|
@@ -3,7 +3,6 @@ import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
|
3
3
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
5
|
import { agentsDir } from '../agents/files.js';
|
|
6
|
-
import { readModelConfig, type ModelConfig } from '../gateway/model-config.js';
|
|
7
6
|
|
|
8
7
|
const VOICE_PROVIDERS = ['elevenlabs'] as const;
|
|
9
8
|
type VoiceProvider = (typeof VOICE_PROVIDERS)[number];
|
|
@@ -12,7 +11,6 @@ export interface VoiceConfig {
|
|
|
12
11
|
provider: VoiceProvider;
|
|
13
12
|
apiKey: string;
|
|
14
13
|
voiceId: string;
|
|
15
|
-
model: string;
|
|
16
14
|
language: string;
|
|
17
15
|
enabled: boolean;
|
|
18
16
|
}
|
|
@@ -22,7 +20,6 @@ const AUTO_LANGUAGE = 'auto';
|
|
|
22
20
|
const LANGUAGE_RE = /^(?:[a-z]{2,3}|auto)$/;
|
|
23
21
|
|
|
24
22
|
const DEFAULT_VOICE_ID = 'EXAVITQu4vr4xnSDxMaL';
|
|
25
|
-
const DEFAULT_VOICE_MODEL = 'anthropic:claude-sonnet-5-5';
|
|
26
23
|
|
|
27
24
|
const VOICE_FILE = 'voice.json';
|
|
28
25
|
const MAX_FIELD = 512;
|
|
@@ -38,7 +35,6 @@ export function parseVoice(raw: unknown): VoiceConfig {
|
|
|
38
35
|
provider: isProvider(r.provider) ? r.provider : 'elevenlabs',
|
|
39
36
|
apiKey: text(r.apiKey),
|
|
40
37
|
voiceId: text(r.voiceId),
|
|
41
|
-
model: text(r.model),
|
|
42
38
|
language: LANGUAGE_RE.test(text(r.language)) ? text(r.language) : '',
|
|
43
39
|
enabled: r.enabled !== false,
|
|
44
40
|
};
|
|
@@ -52,13 +48,6 @@ export function writeVoice(cfg: VoiceConfig, dir = agentsDir()): void {
|
|
|
52
48
|
}
|
|
53
49
|
|
|
54
50
|
export const voiceIdOf = (cfg: VoiceConfig): string => (cfg.voiceId === '' ? DEFAULT_VOICE_ID : cfg.voiceId);
|
|
55
|
-
const voiceModelOf = (cfg: VoiceConfig): string => (cfg.model === '' ? DEFAULT_VOICE_MODEL : cfg.model);
|
|
56
|
-
export function brainModel(cfg: VoiceConfig, models: ModelConfig = readModelConfig()): string {
|
|
57
|
-
const model = voiceModelOf(cfg);
|
|
58
|
-
const anthropic = /^anthropic:(.+)$/.exec(model)?.[1];
|
|
59
|
-
if (anthropic === undefined) return model;
|
|
60
|
-
return models.connections.some((c) => c.provider === 'anthropic') ? model : anthropic;
|
|
61
|
-
}
|
|
62
51
|
|
|
63
52
|
export function languageOf(cfg: VoiceConfig): string | null {
|
|
64
53
|
const language = cfg.language === '' ? DEFAULT_LANGUAGE : cfg.language;
|
|
@@ -73,10 +62,9 @@ export function publicVoice(cfg: VoiceConfig): Record<string, unknown> {
|
|
|
73
62
|
providers: VOICE_PROVIDERS,
|
|
74
63
|
hasKey: cfg.apiKey !== '',
|
|
75
64
|
voiceId: cfg.voiceId,
|
|
76
|
-
model: cfg.model,
|
|
77
65
|
language: cfg.language,
|
|
78
66
|
enabled: cfg.enabled,
|
|
79
|
-
defaults: { voiceId: DEFAULT_VOICE_ID,
|
|
67
|
+
defaults: { voiceId: DEFAULT_VOICE_ID, language: DEFAULT_LANGUAGE },
|
|
80
68
|
};
|
|
81
69
|
}
|
|
82
70
|
|
|
@@ -109,7 +97,6 @@ export function patchVoice(cfg: VoiceConfig, body: unknown): VoiceConfig {
|
|
|
109
97
|
provider: isProvider(body.provider) ? body.provider : cfg.provider,
|
|
110
98
|
apiKey: keyOf(body, cfg.apiKey),
|
|
111
99
|
voiceId: field(body, 'voiceId', cfg.voiceId),
|
|
112
|
-
model: field(body, 'model', cfg.model),
|
|
113
100
|
language: languageField(body, cfg.language),
|
|
114
101
|
enabled: typeof body.enabled === 'boolean' ? body.enabled : cfg.enabled,
|
|
115
102
|
};
|
package/runtime/runtime.json
CHANGED