@stage-labs/metro 0.1.0-beta.306 → 0.1.0-beta.308
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.js +23 -28
- package/dist/child.js +33 -0
- package/dist/claude-env.js +104 -0
- package/dist/cli.js +7 -21
- package/dist/lock.js +62 -0
- package/dist/memory.js +12 -27
- package/dist/root-release-io.js +1 -2
- package/dist/root-release.js +1 -1
- package/dist/route.js +0 -1
- package/hooks/guard.mjs +1 -5
- package/hooks/session-start.mjs +1 -1
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +13 -38
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +21 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -8
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +6 -18
- package/runtime/node_modules/@metro-labs/daemon/src/claude/github-skills.ts +3 -7
- package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +6 -5
- package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +1 -23
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +32 -112
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +14 -61
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +10 -42
- package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/agent-key.ts +9 -12
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/leftovers.ts +59 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +1 -13
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +15 -35
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +5 -15
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +0 -11
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +3 -7
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +12 -24
- package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -4
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +9 -92
- package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +1 -4
- package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +0 -4
- package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +1 -14
- package/runtime/node_modules/@metro-labs/http/src/agent-signature.ts +5 -20
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +0 -1
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/dist/background.js +0 -141
- package/dist/claude.js +0 -204
- package/dist/mcp-config.js +0 -27
- package/dist/native-recovery-scan.js +0 -246
- package/dist/native-recovery-state.js +0 -154
- package/dist/native-recovery.js +0 -164
- package/dist/onboarding.js +0 -44
- package/dist/voice.js +0 -56
- package/runtime/node_modules/@metro-labs/daemon/src/claude/channel-check.ts +0 -56
- package/runtime/node_modules/@metro-labs/daemon/src/claude/pinned.ts +0 -16
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +0 -121
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +0 -45
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +0 -57
- package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +0 -76
- package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +0 -38
- package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +0 -146
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +0 -54
- package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +0 -264
|
@@ -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> {
|
|
@@ -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
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash, createPublicKey,
|
|
1
|
+
import { createHash, createPublicKey, randomBytes, sign, verify, type KeyObject } from 'node:crypto';
|
|
2
2
|
import { strongEd25519 } from './ed25519.js';
|
|
3
3
|
|
|
4
4
|
export const AGENT_SCHEME = 'MetroAgent';
|
|
@@ -25,40 +25,25 @@ export interface AgentProof {
|
|
|
25
25
|
signature: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
type Curve = 'Ed25519' | 'X25519';
|
|
29
|
-
|
|
30
28
|
const digest = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('base64url');
|
|
31
29
|
|
|
32
30
|
export const agentKeyId = (signingKey: string): string => digest(Buffer.from(signingKey, 'base64url'));
|
|
33
31
|
|
|
34
|
-
function publicKeyOf(raw: string
|
|
32
|
+
function publicKeyOf(raw: string): KeyObject | null {
|
|
35
33
|
if (!RAW_KEY_RE.test(raw) || Buffer.from(raw, 'base64url').toString('base64url') !== raw) return null;
|
|
36
34
|
try {
|
|
37
|
-
const key = createPublicKey({ key: { kty: 'OKP', crv:
|
|
38
|
-
return key.asymmetricKeyType ===
|
|
35
|
+
const key = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: raw }, format: 'jwk' });
|
|
36
|
+
return key.asymmetricKeyType === 'ed25519' ? key : null;
|
|
39
37
|
} catch {
|
|
40
38
|
return null;
|
|
41
39
|
}
|
|
42
40
|
}
|
|
43
41
|
|
|
44
42
|
export function signingKeyOf(raw: string): KeyObject | null {
|
|
45
|
-
const key = publicKeyOf(raw
|
|
43
|
+
const key = publicKeyOf(raw);
|
|
46
44
|
return key !== null && strongEd25519(Buffer.from(raw, 'base64url')) ? key : null;
|
|
47
45
|
}
|
|
48
46
|
|
|
49
|
-
function contributes(key: KeyObject): boolean {
|
|
50
|
-
try {
|
|
51
|
-
return diffieHellman({ privateKey: generateKeyPairSync('x25519').privateKey, publicKey: key }).some((byte) => byte !== 0);
|
|
52
|
-
} catch {
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function sealingKeyOf(raw: string): KeyObject | null {
|
|
58
|
-
const key = publicKeyOf(raw, 'X25519');
|
|
59
|
-
return key !== null && contributes(key) ? key : null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
47
|
export function rawPublicKey(key: KeyObject): string {
|
|
63
48
|
const { x } = key.export({ format: 'jwk' });
|
|
64
49
|
if (typeof x !== 'string') throw new Error('not an OKP public key');
|
package/runtime/runtime.json
CHANGED
package/dist/background.js
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { basename, join } from 'node:path';
|
|
5
|
-
export const AGENT_VIEW_OFF = 'CLAUDE_CODE_DISABLE_AGENT_VIEW';
|
|
6
|
-
const STOP_MS = 30_000;
|
|
7
|
-
const SETTLE_MS = 5_000;
|
|
8
|
-
const POLL_MS = 250;
|
|
9
|
-
const given = (value) => value !== undefined && value.trim() !== '' ? value.trim() : undefined;
|
|
10
|
-
export const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
11
|
-
export const keepInSession = (env) => given(env[AGENT_VIEW_OFF]) === undefined ? { ...env, [AGENT_VIEW_OFF]: '1' } : env;
|
|
12
|
-
export const claudeDir = (env = process.env) => given(env.CLAUDE_CONFIG_DIR) ?? join(given(env.HOME) ?? homedir(), '.claude');
|
|
13
|
-
export const projectDir = (dir, cwd) => join(dir, 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'));
|
|
14
|
-
function startTime(pid) {
|
|
15
|
-
try {
|
|
16
|
-
const stat = readFileSync(`/proc/${String(pid)}/stat`, 'utf8');
|
|
17
|
-
return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19] ?? null;
|
|
18
|
-
}
|
|
19
|
-
catch {
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
export function processAlive(pid, procStart) {
|
|
24
|
-
try {
|
|
25
|
-
process.kill(pid, 0);
|
|
26
|
-
}
|
|
27
|
-
catch (err) {
|
|
28
|
-
if (err.code !== 'EPERM')
|
|
29
|
-
return false;
|
|
30
|
-
}
|
|
31
|
-
const started = procStart === undefined ? null : startTime(pid);
|
|
32
|
-
return started === null || started === procStart;
|
|
33
|
-
}
|
|
34
|
-
function backgroundEntry(file, alive) {
|
|
35
|
-
try {
|
|
36
|
-
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
37
|
-
if (!isRecord(raw) || typeof raw.pid !== 'number' || typeof raw.sessionId !== 'string')
|
|
38
|
-
return null;
|
|
39
|
-
if (raw.kind === 'interactive' || typeof raw.kind !== 'string')
|
|
40
|
-
return null;
|
|
41
|
-
return alive(raw.pid, typeof raw.procStart === 'string' ? raw.procStart : undefined) ? raw.sessionId : null;
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
export function liveBackground(sessions, alive = processAlive) {
|
|
48
|
-
if (!existsSync(sessions))
|
|
49
|
-
return new Set();
|
|
50
|
-
const ids = readdirSync(sessions)
|
|
51
|
-
.filter((name) => name.endsWith('.json'))
|
|
52
|
-
.map((name) => backgroundEntry(join(sessions, name), alive));
|
|
53
|
-
return new Set(ids.filter((id) => id !== null));
|
|
54
|
-
}
|
|
55
|
-
function newestFirst(dir) {
|
|
56
|
-
if (!existsSync(dir))
|
|
57
|
-
return [];
|
|
58
|
-
return readdirSync(dir)
|
|
59
|
-
.filter((name) => name.endsWith('.jsonl'))
|
|
60
|
-
.map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs }))
|
|
61
|
-
.sort((a, b) => b.at - a.at)
|
|
62
|
-
.map((file) => file.path);
|
|
63
|
-
}
|
|
64
|
-
function continuedIn(text) {
|
|
65
|
-
const last = text.split('\n').findLast((line) => line.includes('"type":"continued-in"'));
|
|
66
|
-
if (last === undefined)
|
|
67
|
-
return null;
|
|
68
|
-
try {
|
|
69
|
-
const record = JSON.parse(last);
|
|
70
|
-
return isRecord(record) && typeof record.continuedInSessionId === 'string' ? record.continuedInSessionId : null;
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
const HAS_TURN = /"type":"(user|assistant)"/;
|
|
77
|
-
export function heldConversation(dir, live) {
|
|
78
|
-
if (live.size === 0)
|
|
79
|
-
return null;
|
|
80
|
-
for (const file of newestFirst(dir)) {
|
|
81
|
-
const text = readFileSync(file, 'utf8');
|
|
82
|
-
if (!HAS_TURN.test(text))
|
|
83
|
-
continue;
|
|
84
|
-
const moved = continuedIn(text);
|
|
85
|
-
if (moved !== null && live.has(moved))
|
|
86
|
-
return moved;
|
|
87
|
-
if (!live.has(basename(file, '.jsonl')))
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
function stopBackground(id, env = process.env) {
|
|
93
|
-
const run = spawnSync('claude', ['stop', id], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: STOP_MS });
|
|
94
|
-
if (run.error !== undefined)
|
|
95
|
-
return { ok: false, detail: run.error.message };
|
|
96
|
-
const said = `${run.stdout}${run.stderr}`.trim().split('\n').at(-1) ?? '';
|
|
97
|
-
return { ok: run.status === 0, detail: said === '' ? `exit ${String(run.status)}` : said };
|
|
98
|
-
}
|
|
99
|
-
export function resumeSessionId(args) {
|
|
100
|
-
const index = args.findLastIndex((arg) => arg === '--resume' || arg === '-r');
|
|
101
|
-
const id = index === -1 ? undefined : args[index + 1];
|
|
102
|
-
return id !== undefined && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(id) ? id : undefined;
|
|
103
|
-
}
|
|
104
|
-
function pinnedHeld(dir, live, id) {
|
|
105
|
-
if (live.has(id))
|
|
106
|
-
return id;
|
|
107
|
-
const path = join(dir, `${id}.jsonl`);
|
|
108
|
-
if (!existsSync(path))
|
|
109
|
-
return null;
|
|
110
|
-
const moved = continuedIn(readFileSync(path, 'utf8'));
|
|
111
|
-
return moved !== null && live.has(moved) ? moved : null;
|
|
112
|
-
}
|
|
113
|
-
const sleep = (ms) => new Promise((resolve) => {
|
|
114
|
-
setTimeout(resolve, ms);
|
|
115
|
-
});
|
|
116
|
-
async function settled(sessions, held, alive, ms) {
|
|
117
|
-
if (!liveBackground(sessions, alive).has(held))
|
|
118
|
-
return true;
|
|
119
|
-
if (ms <= 0)
|
|
120
|
-
return false;
|
|
121
|
-
await sleep(POLL_MS);
|
|
122
|
-
return settled(sessions, held, alive, ms - POLL_MS);
|
|
123
|
-
}
|
|
124
|
-
function heldFor(deps, dir, live) {
|
|
125
|
-
const project = projectDir(dir, deps.cwd ?? process.cwd());
|
|
126
|
-
return deps.sessionId === undefined ? heldConversation(project, live) : pinnedHeld(project, live, deps.sessionId);
|
|
127
|
-
}
|
|
128
|
-
export async function takeBackConversation(deps = {}) {
|
|
129
|
-
const env = deps.env ?? process.env;
|
|
130
|
-
const alive = deps.alive ?? processAlive;
|
|
131
|
-
const dir = claudeDir(env);
|
|
132
|
-
const sessions = join(dir, 'sessions');
|
|
133
|
-
const held = heldFor(deps, dir, liveBackground(sessions, alive));
|
|
134
|
-
if (held === null)
|
|
135
|
-
return null;
|
|
136
|
-
const id = held.slice(0, 8);
|
|
137
|
-
const stopped = (deps.stop ?? stopBackground)(id, env);
|
|
138
|
-
if (!stopped.ok || !(await settled(sessions, held, alive, deps.settleMs ?? SETTLE_MS)))
|
|
139
|
-
return `the conversation is held by the Claude Code background session ${id} and it could not be stopped (${stopped.detail}), so Claude Code will refuse to continue it: run claude stop ${id}`;
|
|
140
|
-
return `the conversation had moved to the Claude Code background session ${id}, where no chat reaches it; stopped that session (its conversation is kept) and continuing it here`;
|
|
141
|
-
}
|