@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
|
@@ -1,11 +1,25 @@
|
|
|
1
|
-
|
|
1
|
+
import type { CallRoute, CallSource } from '@metro-labs/core/call';
|
|
2
|
+
|
|
3
|
+
export const FRONT_RULES = `You are the orchestrator of this agent's persistent Agent SDK session. Typed chat, live call words (<call> blocks), and background worker reports share this conversation.
|
|
2
4
|
- Be fast. When you can answer a chat message at once, make the reaction and the answer one response of parallel tool calls (react, then send), with no typing signal. Use typing only when the answer will take a while.
|
|
3
5
|
- Stay light: talk, decide and delegate. Real work goes to background workers (Agent tool, subagent_type "worker", run_in_background: true); keep answering while they run, and relay what they report.
|
|
6
|
+
- Plain assistant text is never spoken. Reply to a live <call> with mcp__metro__send using its exact line, text, and speech:{callId,generation,sourceId}. This sends speech only, not a chat post. Keep it short. Never paste a spoken reply or transcript into chat unless the owner explicitly requests a chat post.
|
|
7
|
+
- Ordinary typed chat and worker results are never automatically spoken. For an explicit typed owner request to speak, use only that channel message's call_id, call_generation and call_source_id with its exact line as send speech:{callId:call_id,generation:call_generation,sourceId:call_source_id}. Without all three authorized live-call values, do not speak or guess a target. Do not reuse an earlier sourceId for a new reply.
|
|
8
|
+
- A call ending invalidates its speech targets. Keep the chat session and background work running. Nothing is posted about a hangup unless requested. Speech status notifications are status only, never a request to speak.
|
|
4
9
|
- Text in [square brackets] comes from Metro, not from a person.`;
|
|
5
10
|
|
|
6
|
-
|
|
7
|
-
|
|
11
|
+
const attr = (text: string): string => text.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
12
|
+
|
|
13
|
+
export function callWords(source: CallSource, text: string): string {
|
|
14
|
+
const { route, sourceId } = source;
|
|
15
|
+
return `<call line="${attr(route.line)}" from="${attr(route.from)}" callId="${attr(route.callId)}" generation="${attr(route.generation)}" sourceId="${attr(sourceId)}">\n${attr(text)}\n</call>`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const callStarted = (source: CallSource): string =>
|
|
19
|
+
`[A voice call started. Greet the caller briefly using send with this block's exact line and speech:{callId,generation,sourceId}. Do not post the greeting in chat.]\n${callWords(source, '')}`;
|
|
8
20
|
|
|
9
|
-
export const
|
|
21
|
+
export const callEnded = (route: CallRoute): string =>
|
|
22
|
+
`[The voice call ${attr(route.callId)} generation ${attr(route.generation)} ended. Its speech targets are invalid. Keep all other work running. Nothing about it is posted unless requested.]`;
|
|
10
23
|
|
|
11
|
-
export const
|
|
24
|
+
export const speechFailed = (route: CallRoute): string =>
|
|
25
|
+
`[Speech delivery failed for voice call ${attr(route.callId)} generation ${attr(route.generation)} on ${attr(route.line)}. The full reply was not confirmed. Voice delivery needs attention before another attempt. This is status only: do not retry, speak, or post to chat in response. Keep the chat session and other work running.]`;
|
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
1
|
import { query, type CanUseTool, type Options, type Query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
3
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
4
|
import type { Activity } from './activity.js';
|
|
5
|
+
import { RunnerCalls } from './calls.js';
|
|
5
6
|
import { channelText, type ChannelEvent } from './channel-text.js';
|
|
6
7
|
import type { RunnerConfig } from './config.js';
|
|
7
8
|
import { Inbox, type Unanswered, type Uuid } from './inbox.js';
|
|
8
|
-
import {
|
|
9
|
+
import { FRONT_RULES } from './rules.js';
|
|
9
10
|
import { INTERRUPTED_NOTICE } from './recovery.js';
|
|
10
11
|
import type { SessionStore } from './session-store.js';
|
|
11
12
|
import { SessionWatch, startedCommand, uuidsOf } from './session-watch.js';
|
|
12
|
-
import { SpeechRouter, type SpeechSink } from './speech.js';
|
|
13
13
|
import { METRO_SERVER, type MetroTools } from './tool-proxy.js';
|
|
14
14
|
|
|
15
15
|
const STARTUP_WAIT = 'CLAUDE_CODE_MCP_STARTUP_WAIT_MS';
|
|
16
|
-
const STALL_MS = 2_000;
|
|
17
16
|
export const COMPACT_AT = 120_000;
|
|
18
17
|
|
|
19
18
|
const MODEL_PINS: ReadonlySet<string> = new Set(['ANTHROPIC_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL']);
|
|
@@ -62,22 +61,33 @@ export type OpenSession = (params: { prompt: AsyncIterable<SDKUserMessage>; opti
|
|
|
62
61
|
|
|
63
62
|
export interface RunnerParts {
|
|
64
63
|
store: SessionStore;
|
|
65
|
-
sink: SpeechSink;
|
|
66
64
|
readOnly: (tool: string) => boolean;
|
|
67
65
|
compactAt?: number;
|
|
68
66
|
open?: OpenSession;
|
|
69
67
|
activity?: Activity;
|
|
70
68
|
}
|
|
71
69
|
|
|
70
|
+
function streamOutput(event: Record<string, unknown>): boolean {
|
|
71
|
+
if (event.type === 'content_block_start' && isRecord(event.content_block)) return event.content_block.type === 'tool_use';
|
|
72
|
+
return event.type === 'content_block_delta' && isRecord(event.delta) && event.delta.type === 'text_delta' && typeof event.delta.text === 'string' && event.delta.text.length > 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function visibleOutput(m: Record<string, unknown>): boolean {
|
|
76
|
+
if (m.type === 'assistant' && isRecord(m.message) && Array.isArray(m.message.content)) {
|
|
77
|
+
return m.message.content.some((block: unknown) => isRecord(block) && (block.type === 'tool_use' || (block.type === 'text' && typeof block.text === 'string' && block.text.length > 0)));
|
|
78
|
+
}
|
|
79
|
+
return m.type === 'stream_event' && isRecord(m.event) && streamOutput(m.event);
|
|
80
|
+
}
|
|
81
|
+
|
|
72
82
|
export class Runner {
|
|
73
83
|
readonly inbox: Inbox;
|
|
74
|
-
|
|
84
|
+
readonly calls: RunnerCalls;
|
|
75
85
|
private readonly watch: SessionWatch;
|
|
76
86
|
private session: Query | null = null;
|
|
77
87
|
private sessionId: string | null = null;
|
|
78
|
-
private compactAsked =
|
|
88
|
+
private compactAsked: Uuid | null = null;
|
|
79
89
|
private compactFloor: number | null = null;
|
|
80
|
-
private
|
|
90
|
+
private compacted = false;
|
|
81
91
|
private model: string | null = null;
|
|
82
92
|
private switching: Promise<void> = Promise.resolve();
|
|
83
93
|
private ended = false;
|
|
@@ -85,31 +95,27 @@ export class Runner {
|
|
|
85
95
|
private interruptionReported = false;
|
|
86
96
|
|
|
87
97
|
constructor(private readonly parts: RunnerParts) {
|
|
88
|
-
this.inbox = new Inbox((unanswered) => {
|
|
89
|
-
this.keep(unanswered);
|
|
90
|
-
});
|
|
91
|
-
this.speech = new SpeechRouter((uuid) => this.inbox.kindOf(uuid), {
|
|
92
|
-
say: (text) => {
|
|
93
|
-
this.clearStall();
|
|
94
|
-
parts.sink.say(text);
|
|
95
|
-
},
|
|
96
|
-
done: () => {
|
|
97
|
-
parts.sink.done();
|
|
98
|
-
},
|
|
99
|
-
});
|
|
100
98
|
this.watch = new SessionWatch(parts.readOnly);
|
|
101
|
-
|
|
99
|
+
this.inbox = new Inbox((unanswered) => { this.keep(unanswered); }, {
|
|
100
|
+
ready: () => this.watch.safe,
|
|
101
|
+
dispatch: () => { this.watch.dispatched(); },
|
|
102
|
+
input: (input) => { parts.activity?.input(input); },
|
|
103
|
+
queue: (count, oldest) => { parts.activity?.pending(count, oldest); },
|
|
104
|
+
});
|
|
105
|
+
this.calls = new RunnerCalls(this.inbox, parts.activity);
|
|
106
|
+
const { unanswered: again, interrupted, context } = parts.store.recover();
|
|
107
|
+
this.watch.restoreContext(context ?? 0);
|
|
102
108
|
this.interrupted = interrupted.length;
|
|
103
109
|
this.inbox.again(again);
|
|
104
110
|
if (again.length > 0) log.info({ count: again.length }, 'sdk-runner: chat messages the last session never read go in again');
|
|
105
111
|
}
|
|
106
112
|
|
|
107
|
-
get id(): string | null {
|
|
108
|
-
return this.sessionId;
|
|
109
|
-
}
|
|
113
|
+
get id(): string | null { return this.sessionId; }
|
|
110
114
|
|
|
111
115
|
start(options: Options): Query {
|
|
112
116
|
if (this.session !== null) throw new Error('the runner was already started');
|
|
117
|
+
if (options.resume === undefined) this.watch.restoreContext(0);
|
|
118
|
+
else this.maybeCompact(true);
|
|
113
119
|
this.session = (this.parts.open ?? query)({ prompt: this.inbox, options });
|
|
114
120
|
this.model = options.model ?? null;
|
|
115
121
|
this.queueModel(this.model);
|
|
@@ -128,9 +134,9 @@ export class Runner {
|
|
|
128
134
|
const m: Record<string, unknown> = { ...message };
|
|
129
135
|
this.trackInput(m);
|
|
130
136
|
this.watch.observe(m);
|
|
131
|
-
this.speech.observe(message);
|
|
132
137
|
this.note(m);
|
|
133
138
|
this.parts.activity?.observe(message);
|
|
139
|
+
this.inbox.notify();
|
|
134
140
|
observe?.(message);
|
|
135
141
|
}
|
|
136
142
|
} finally {
|
|
@@ -140,7 +146,9 @@ export class Runner {
|
|
|
140
146
|
|
|
141
147
|
chat(event: ChannelEvent): Uuid {
|
|
142
148
|
try {
|
|
143
|
-
|
|
149
|
+
const uuid = this.inbox.push('chat', channelText(METRO_SERVER, event.content, event.meta));
|
|
150
|
+
this.calls.chat(event);
|
|
151
|
+
return uuid;
|
|
144
152
|
} catch (err) {
|
|
145
153
|
log.error('sdk-runner: could not save incoming chat; stopping before further work');
|
|
146
154
|
try {
|
|
@@ -152,23 +160,8 @@ export class Runner {
|
|
|
152
160
|
}
|
|
153
161
|
}
|
|
154
162
|
|
|
155
|
-
callStarted(where: string): Uuid {
|
|
156
|
-
this.speech.setCallLive(true);
|
|
157
|
-
return this.callPush(callStarted(where));
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
heard(text: string): Uuid {
|
|
161
|
-
return this.callPush(callWords(text));
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
callEnded(): Uuid {
|
|
165
|
-
this.speech.setCallLive(false);
|
|
166
|
-
this.clearStall();
|
|
167
|
-
return this.inbox.push('note', CALL_ENDED);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
163
|
close(cancelActive = true): void {
|
|
171
|
-
this.
|
|
164
|
+
this.calls.close();
|
|
172
165
|
try {
|
|
173
166
|
if (cancelActive && !this.ended) this.inbox.finished();
|
|
174
167
|
} finally {
|
|
@@ -177,36 +170,6 @@ export class Runner {
|
|
|
177
170
|
}
|
|
178
171
|
}
|
|
179
172
|
|
|
180
|
-
private callPush(text: string): Uuid {
|
|
181
|
-
const uuid = randomUUID();
|
|
182
|
-
this.inbox.mark(uuid, 'call');
|
|
183
|
-
this.armStall();
|
|
184
|
-
if (this.watch.compacting) log.info('sdk-runner: a call message waits for the compaction to finish');
|
|
185
|
-
this.watch
|
|
186
|
-
.whenNotCompacting()
|
|
187
|
-
.then(() => this.watch.whenNotWriting())
|
|
188
|
-
.then(() => this.inbox.push('call', text, 'now', uuid))
|
|
189
|
-
.catch((err: unknown) => {
|
|
190
|
-
log.warn({ err: errMsg(err) }, 'sdk-runner: could not hand a call message to the session');
|
|
191
|
-
});
|
|
192
|
-
return uuid;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
private armStall(): void {
|
|
196
|
-
this.clearStall();
|
|
197
|
-
if (this.parts.sink.stalled === undefined) return;
|
|
198
|
-
this.stall = setTimeout(() => {
|
|
199
|
-
this.stall = null;
|
|
200
|
-
if (this.speech.callLive && !this.speech.talking) this.parts.sink.stalled?.();
|
|
201
|
-
}, STALL_MS);
|
|
202
|
-
this.stall.unref();
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
private clearStall(): void {
|
|
206
|
-
if (this.stall !== null) clearTimeout(this.stall);
|
|
207
|
-
this.stall = null;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
173
|
private queueModel(model: string | null): void {
|
|
211
174
|
this.switching = this.switching
|
|
212
175
|
.then(() => this.applyModel(model))
|
|
@@ -238,37 +201,50 @@ export class Runner {
|
|
|
238
201
|
if (m.type === 'result') {
|
|
239
202
|
const uuids = uuidsOf(m);
|
|
240
203
|
if (uuids !== null) this.inbox.finished(uuids);
|
|
204
|
+
this.inbox.boundary();
|
|
241
205
|
return;
|
|
242
206
|
}
|
|
243
207
|
const started = startedCommand(m);
|
|
244
208
|
this.inbox.started([...(uuidsOf(m) ?? []), ...(started === null ? [] : [started])]);
|
|
209
|
+
if (visibleOutput(m)) this.inbox.output();
|
|
245
210
|
}
|
|
246
211
|
|
|
247
|
-
private keep(unanswered: Unanswered[]): void {
|
|
248
|
-
this.parts.store.saveUnanswered(unanswered);
|
|
249
|
-
this.parts.activity?.pending(unanswered.filter((entry) => entry.state !== 'started').length);
|
|
250
|
-
}
|
|
212
|
+
private keep(unanswered: Unanswered[]): void { this.parts.store.saveUnanswered(unanswered); }
|
|
251
213
|
|
|
252
214
|
private note(m: Record<string, unknown>): void {
|
|
253
|
-
if (m.
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
215
|
+
if (m.parent_tool_use_id !== null && m.parent_tool_use_id !== undefined) return;
|
|
216
|
+
if (m.type === 'result') this.turnDone(m);
|
|
217
|
+
if (m.type !== 'system') return;
|
|
218
|
+
if (m.subtype === 'init') this.started(m);
|
|
219
|
+
else if (m.subtype === 'session_state_changed' && m.state === 'idle') this.maybeCompact();
|
|
220
|
+
else if (m.subtype === 'compact_boundary') {
|
|
221
|
+
this.compacted = true;
|
|
258
222
|
this.compactFloor = null;
|
|
223
|
+
this.parts.store.saveContext(0);
|
|
259
224
|
log.info({ compact: m.compact_metadata }, 'sdk-runner: the conversation was compacted');
|
|
260
225
|
}
|
|
261
226
|
}
|
|
262
227
|
|
|
263
228
|
private turnDone(m: Record<string, unknown>): void {
|
|
264
|
-
log.info({ turnMs: m.duration_ms, context: this.watch.context, costUsd: m.total_cost_usd, subtype: m.subtype }, 'sdk-runner: turn done');
|
|
265
|
-
const limit = this.parts.compactAt ?? COMPACT_AT;
|
|
266
229
|
const context = this.watch.context;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
this.compactAsked
|
|
270
|
-
|
|
271
|
-
|
|
230
|
+
log.info({ turnMs: m.duration_ms, context, costUsd: m.total_cost_usd, subtype: m.subtype }, 'sdk-runner: turn done');
|
|
231
|
+
this.parts.store.saveContext(context);
|
|
232
|
+
if (this.compactAsked !== null && uuidsOf(m)?.includes(this.compactAsked)) {
|
|
233
|
+
this.compactAsked = null;
|
|
234
|
+
if (!this.compacted) this.compactFloor = context;
|
|
235
|
+
}
|
|
236
|
+
if (this.compacted && context > 0) {
|
|
237
|
+
this.compactFloor = context;
|
|
238
|
+
this.compacted = false;
|
|
239
|
+
}
|
|
240
|
+
this.maybeCompact();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private maybeCompact(resuming = false): void {
|
|
244
|
+
const busy = this.compactAsked !== null || !this.watch.safe || (!resuming && (this.calls.live || this.inbox.pending > 0));
|
|
245
|
+
if (!compactDue({ context: this.watch.context, floor: this.compactFloor, limit: this.parts.compactAt ?? COMPACT_AT, busy })) return;
|
|
246
|
+
this.compactAsked = this.inbox.compact();
|
|
247
|
+
log.info({ context: this.watch.context, resuming }, 'sdk-runner: compacting at a safe idle boundary');
|
|
272
248
|
}
|
|
273
249
|
|
|
274
250
|
private started(m: Record<string, unknown>): void {
|
|
@@ -13,6 +13,7 @@ interface Stored {
|
|
|
13
13
|
sessionId: string | null;
|
|
14
14
|
unanswered: Unanswered[];
|
|
15
15
|
interrupted: Unanswered[];
|
|
16
|
+
context?: number;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
function inputId(raw: unknown): Uuid | undefined {
|
|
@@ -35,11 +36,18 @@ function entry(raw: unknown): Unanswered | null {
|
|
|
35
36
|
|
|
36
37
|
const UNREADABLE = 'The saved Agent SDK state cannot be read. Restore it before starting.';
|
|
37
38
|
|
|
39
|
+
function contextOf(raw: unknown): number | undefined {
|
|
40
|
+
if (raw === undefined) return undefined;
|
|
41
|
+
if (typeof raw !== 'number' || !Number.isSafeInteger(raw) || raw < 0) throw new Error(UNREADABLE);
|
|
42
|
+
return raw;
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
function stored(raw: unknown): Stored {
|
|
39
46
|
if (!isRecord(raw)) throw new Error(UNREADABLE);
|
|
40
47
|
const id = raw.sessionId ?? null;
|
|
41
48
|
if (id !== null && (typeof id !== 'string' || !SESSION_ID.test(id))) throw new Error(UNREADABLE);
|
|
42
|
-
|
|
49
|
+
const context = contextOf(raw.context);
|
|
50
|
+
return { sessionId: id, unanswered: entries(raw.unanswered), interrupted: entries(raw.interrupted), ...(context === undefined ? {} : { context }) };
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
function entries(raw: unknown): Unanswered[] {
|
|
@@ -86,6 +94,11 @@ export class SessionStore {
|
|
|
86
94
|
this.write({ ...this.read(), unanswered });
|
|
87
95
|
}
|
|
88
96
|
|
|
97
|
+
saveContext(context: number): void {
|
|
98
|
+
const stored = this.read();
|
|
99
|
+
if (stored.context !== context) this.write({ ...stored, context });
|
|
100
|
+
}
|
|
101
|
+
|
|
89
102
|
private read(): Stored {
|
|
90
103
|
try {
|
|
91
104
|
return stored(JSON.parse(readFileSync(this.path, 'utf8')));
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
2
|
|
|
3
3
|
const METRO_TOOL = /^mcp__metro__(.+)$/;
|
|
4
|
-
const GATE_CAP_MS = 5_000;
|
|
5
|
-
const COMPACT_CAP_MS = 20_000;
|
|
6
4
|
|
|
7
5
|
export const uuidsOf = (m: Record<string, unknown>): string[] | null => {
|
|
8
6
|
if (Array.isArray(m.user_message_uuids)) return m.user_message_uuids.filter((u): u is string => typeof u === 'string');
|
|
@@ -24,21 +22,14 @@ function toolResultIds(message: unknown): string[] {
|
|
|
24
22
|
return message.content.filter(isRecord).filter((b) => b.type === 'tool_result' && typeof b.tool_use_id === 'string').map((b) => String(b.tool_use_id));
|
|
25
23
|
}
|
|
26
24
|
|
|
27
|
-
function capped(waiters: (() => void)[], capMs: number): Promise<void> {
|
|
28
|
-
return new Promise((resolve) => {
|
|
29
|
-
const timer = setTimeout(resolve, capMs);
|
|
30
|
-
waiters.push(() => {
|
|
31
|
-
clearTimeout(timer);
|
|
32
|
-
resolve();
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
25
|
|
|
37
26
|
export class SessionWatch {
|
|
38
27
|
private readonly writes = new Set<string>();
|
|
39
28
|
private readonly idle: (() => void)[] = [];
|
|
40
29
|
private readonly compacted: (() => void)[] = [];
|
|
41
30
|
private compactingNow = false;
|
|
31
|
+
private busy = false;
|
|
32
|
+
private sessionStateSeen = false;
|
|
42
33
|
private lastContext = 0;
|
|
43
34
|
private streamUsage: Record<string, unknown> = {};
|
|
44
35
|
|
|
@@ -56,26 +47,42 @@ export class SessionWatch {
|
|
|
56
47
|
return this.writes.size > 0;
|
|
57
48
|
}
|
|
58
49
|
|
|
50
|
+
get safe(): boolean { return !this.busy && !this.writing && !this.compacting; }
|
|
51
|
+
|
|
52
|
+
dispatched(): void { this.busy = true; }
|
|
53
|
+
restoreContext(context: number): void { this.lastContext = context; }
|
|
54
|
+
|
|
59
55
|
observe(m: Record<string, unknown>): void {
|
|
60
|
-
if (m.
|
|
61
|
-
|
|
62
|
-
if (m.parent_tool_use_id !== null) return;
|
|
56
|
+
if (m.parent_tool_use_id !== null && m.parent_tool_use_id !== undefined) return;
|
|
57
|
+
this.lifecycle(m);
|
|
63
58
|
if (m.type === 'assistant' && isRecord(m.message)) this.assistant(m.message);
|
|
64
59
|
else if (m.type === 'stream_event' && isRecord(m.event)) this.stream(m.event);
|
|
65
60
|
else if (m.type === 'user') for (const id of toolResultIds(m.message)) this.done(id);
|
|
66
61
|
}
|
|
67
62
|
|
|
68
|
-
whenNotWriting(
|
|
69
|
-
return this.writing ?
|
|
63
|
+
whenNotWriting(): Promise<void> {
|
|
64
|
+
return this.writing ? new Promise((resolve) => { this.idle.push(resolve); }) : Promise.resolve();
|
|
70
65
|
}
|
|
71
66
|
|
|
72
|
-
whenNotCompacting(
|
|
73
|
-
return this.compactingNow ?
|
|
67
|
+
whenNotCompacting(): Promise<void> {
|
|
68
|
+
return this.compactingNow ? new Promise((resolve) => { this.compacted.push(resolve); }) : Promise.resolve();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private lifecycle(m: Record<string, unknown>): void {
|
|
72
|
+
if (m.type === 'system') this.system(m);
|
|
73
|
+
else if (m.type === 'result' && !this.sessionStateSeen) {
|
|
74
|
+
this.busy = typeof m.queued_turn_count === 'number' && m.queued_turn_count > 0;
|
|
75
|
+
this.settle();
|
|
76
|
+
}
|
|
77
|
+
if (m.type === 'assistant' || m.type === 'stream_event' || startedCommand(m) !== null) this.busy = true;
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
private system(m: Record<string, unknown>): void {
|
|
77
|
-
if (m.subtype === '
|
|
78
|
-
|
|
81
|
+
if (m.subtype === 'session_state_changed') {
|
|
82
|
+
this.sessionStateSeen = true;
|
|
83
|
+
if (m.state === 'running' || m.state === 'requires_action') this.busy = true;
|
|
84
|
+
else if (m.state === 'idle') { this.busy = false; this.settle(); }
|
|
85
|
+
} else if (m.subtype === 'status') this.setCompacting(m.status === 'compacting');
|
|
79
86
|
else if (m.subtype === 'compact_boundary') {
|
|
80
87
|
this.setCompacting(false);
|
|
81
88
|
this.lastContext = 0;
|
package/runtime/stations.json
CHANGED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
-
import type { InputKind } from './inbox.js';
|
|
4
|
-
import { startedCommand, uuidsOf } from './session-watch.js';
|
|
5
|
-
|
|
6
|
-
export interface SpeechSink {
|
|
7
|
-
say(text: string): void;
|
|
8
|
-
done(): void;
|
|
9
|
-
stalled?(): void;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
function textDelta(event: unknown): string | null {
|
|
14
|
-
if (!isRecord(event) || event.type !== 'content_block_delta' || !isRecord(event.delta)) return null;
|
|
15
|
-
return event.delta.type === 'text_delta' && typeof event.delta.text === 'string' ? event.delta.text : null;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export class SpeechRouter {
|
|
19
|
-
private answering: string[] = [];
|
|
20
|
-
private speaking = false;
|
|
21
|
-
private live = false;
|
|
22
|
-
|
|
23
|
-
constructor(
|
|
24
|
-
private readonly kindOf: (uuid: string) => InputKind | undefined,
|
|
25
|
-
private readonly sink: SpeechSink,
|
|
26
|
-
) {}
|
|
27
|
-
|
|
28
|
-
get callLive(): boolean {
|
|
29
|
-
return this.live;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
get talking(): boolean {
|
|
33
|
-
return this.speaking;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
setCallLive(on: boolean): void {
|
|
37
|
-
this.live = on;
|
|
38
|
-
if (!on) this.endTurn();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
observe(message: SDKMessage): void {
|
|
42
|
-
const m: Record<string, unknown> = { ...message };
|
|
43
|
-
if (m.type === 'result') {
|
|
44
|
-
this.endTurn();
|
|
45
|
-
this.answering = [];
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
if (m.type === 'system' && m.subtype === 'init') this.endTurn();
|
|
49
|
-
const started = startedCommand(m);
|
|
50
|
-
if (started !== null) this.answer([started]);
|
|
51
|
-
if (m.parent_tool_use_id === null) this.mainThread(m);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
private answer(uuids: readonly string[]): void {
|
|
55
|
-
this.answering = [...new Set([...this.answering, ...uuids])];
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
private mainThread(m: Record<string, unknown>): void {
|
|
59
|
-
if (m.type !== 'stream_event' && m.type !== 'assistant') return;
|
|
60
|
-
const uuids = uuidsOf(m);
|
|
61
|
-
if (uuids !== null) this.answer(uuids);
|
|
62
|
-
const text = m.type === 'stream_event' ? textDelta(m.event) : null;
|
|
63
|
-
if (text === null || text === '' || !this.speaks()) return;
|
|
64
|
-
this.speaking = true;
|
|
65
|
-
this.sink.say(text);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
private speaks(): boolean {
|
|
69
|
-
if (!this.live) return false;
|
|
70
|
-
if (this.answering.length === 0) return true;
|
|
71
|
-
return this.answering.some((uuid) => this.kindOf(uuid) === 'call');
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
private endTurn(): void {
|
|
75
|
-
if (!this.speaking) return;
|
|
76
|
-
this.speaking = false;
|
|
77
|
-
this.sink.done();
|
|
78
|
-
}
|
|
79
|
-
}
|