@stage-labs/metro 0.1.0-beta.264 → 0.1.0-beta.266

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/cli.js +2 -2
  2. package/package.json +1 -1
  3. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  4. package/runtime/node_modules/@metro-labs/core/package.json +1 -0
  5. package/runtime/node_modules/@metro-labs/core/src/call.ts +81 -0
  6. package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +35 -2
  8. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -1
  9. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +14 -11
  10. package/runtime/node_modules/@metro-labs/daemon/src/channels/pending.ts +3 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-bridge.ts +45 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-permission.ts +25 -0
  13. package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +2 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/mcp/event-store.ts +9 -2
  15. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +2 -1
  17. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +15 -4
  18. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +20 -1
  19. package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-schema.ts +11 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-send.ts +15 -0
  21. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +3 -1
  22. package/runtime/node_modules/@metro-labs/daemon/src/voice/audio.ts +50 -10
  23. package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +20 -9
  24. package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +125 -51
  25. package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +41 -15
  26. package/runtime/node_modules/@metro-labs/daemon/src/voice/peer.ts +9 -8
  27. package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +15 -3
  28. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared-talk.ts +85 -0
  29. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +199 -0
  30. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech-queue.ts +173 -0
  31. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +40 -15
  32. package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +15 -7
  33. package/runtime/runtime.json +1 -1
  34. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
  35. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/call.ts +81 -0
  36. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
  37. package/runtime/sdk-runner/runner.json +1 -1
  38. package/runtime/sdk-runner/src/activity.ts +14 -2
  39. package/runtime/sdk-runner/src/app.ts +5 -4
  40. package/runtime/sdk-runner/src/approvals.ts +14 -5
  41. package/runtime/sdk-runner/src/calls.ts +133 -0
  42. package/runtime/sdk-runner/src/inbox.ts +58 -26
  43. package/runtime/sdk-runner/src/input-queue.ts +75 -0
  44. package/runtime/sdk-runner/src/input-timing.ts +68 -0
  45. package/runtime/sdk-runner/src/link.ts +27 -4
  46. package/runtime/sdk-runner/src/main.ts +0 -4
  47. package/runtime/sdk-runner/src/rules.ts +19 -5
  48. package/runtime/sdk-runner/src/runner.ts +67 -89
  49. package/runtime/sdk-runner/src/session-store.ts +14 -1
  50. package/runtime/sdk-runner/src/session-watch.ts +51 -20
  51. package/runtime/sdk-runner/src/speech.ts +0 -79
@@ -1,8 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
3
+ import type { RunnerInput } from '@metro-labs/core/runner-activity';
4
+ import { InputQueue } from './input-queue.js';
5
+ import { InputTiming, type InputWatch } from './input-timing.js';
3
6
 
4
7
  export type Uuid = ReturnType<typeof randomUUID>;
5
- export type InputKind = 'chat' | 'call' | 'note';
8
+ export type InputKind = RunnerInput['kind'];
6
9
  export type Priority = 'now' | 'next' | 'later';
7
10
 
8
11
  export interface Unanswered {
@@ -12,32 +15,49 @@ export interface Unanswered {
12
15
  state?: 'queued' | 'started';
13
16
  }
14
17
 
18
+ interface InboxHooks {
19
+ ready?(): boolean;
20
+ dispatch?(message: SDKUserMessage): void;
21
+ queue?(count: number, oldest: number | null): void;
22
+ input?: InputWatch;
23
+ }
24
+
15
25
  const KINDS_MAX = 2_000;
16
26
  const LEDGER_MAX = 100;
17
27
  const LEDGER_AGE_MS = 6 * 60 * 60_000;
18
28
 
19
29
  export class Inbox implements AsyncIterable<SDKUserMessage> {
20
- private readonly queue: SDKUserMessage[] = [];
30
+ private readonly queue = new InputQueue();
21
31
  private readonly kinds = new Map<string, InputKind>();
22
32
  private readonly ledger = new Map<string, Unanswered>();
33
+ private readonly timing: InputTiming;
23
34
  private wake: (() => void) | null = null;
24
35
  private closed = false;
25
36
  private restoring = false;
26
37
 
27
- constructor(private readonly changed: (unanswered: Unanswered[]) => void = () => undefined) {}
38
+ constructor(private readonly changed: (unanswered: Unanswered[]) => void = () => undefined, private readonly hooks: InboxHooks = {}) {
39
+ this.timing = new InputTiming(hooks.input ?? (() => undefined));
40
+ }
28
41
 
29
- push(kind: InputKind, text: string, priority?: Priority, uuid: Uuid = randomUUID(), at = Date.now()): Uuid {
30
- this.mark(uuid, kind);
42
+ get pending(): number { return this.queue.size; }
43
+
44
+ compact(): Uuid {
45
+ const uuid = this.push('note', '/compact');
46
+ this.queue.prefer(uuid);
47
+ return uuid;
48
+ }
49
+
50
+ push(kind: InputKind, text: string, _priority?: Priority, uuid: Uuid = randomUUID(), at = Date.now()): Uuid {
51
+ if (this.closed) throw new Error('The SDK input queue is closed.');
52
+ this.queue.check(kind, text);
31
53
  if (kind === 'chat') this.remember(uuid, text, at);
32
- this.queue.push({
33
- type: 'user',
34
- message: { role: 'user', content: text },
35
- parent_tool_use_id: null,
36
- uuid,
37
- origin: { kind: 'channel', server: 'metro' },
38
- ...(priority === undefined ? {} : { priority }),
39
- });
40
- this.wake?.();
54
+ this.mark(uuid, kind);
55
+ this.queue.push(kind, {
56
+ type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null,
57
+ uuid, origin: { kind: 'channel', server: 'metro' },
58
+ }, text, at);
59
+ this.timing.accept(uuid, kind, at);
60
+ this.notify();
41
61
  return uuid;
42
62
  }
43
63
 
@@ -48,9 +68,7 @@ export class Inbox implements AsyncIterable<SDKUserMessage> {
48
68
  if (oldest !== undefined) this.kinds.delete(oldest);
49
69
  }
50
70
 
51
- kindOf(uuid: string): InputKind | undefined {
52
- return this.kinds.get(uuid);
53
- }
71
+ kindOf(uuid: string): InputKind | undefined { return this.kinds.get(uuid); }
54
72
 
55
73
  started(uuids: readonly string[]): void {
56
74
  let changed = false;
@@ -61,12 +79,24 @@ export class Inbox implements AsyncIterable<SDKUserMessage> {
61
79
  changed = true;
62
80
  }
63
81
  if (changed) this.changed(this.unanswered());
82
+ this.timing.consume(uuids);
64
83
  }
65
84
 
85
+ output(): void { this.timing.output(); }
86
+ boundary(): void { this.timing.boundary(); }
87
+
66
88
  finished(uuids = this.startedUuids()): void {
67
89
  let changed = false;
68
90
  for (const uuid of uuids) changed = this.ledger.delete(uuid) || changed;
69
91
  if (changed) this.changed(this.unanswered());
92
+ this.timing.finish(uuids);
93
+ }
94
+
95
+ cancel(uuids: readonly string[]): void {
96
+ const ephemeral = uuids.filter((uuid) => this.kindOf(uuid) !== 'chat');
97
+ const removed = this.queue.cancel(new Set(ephemeral));
98
+ this.timing.finish(removed, true);
99
+ this.notify();
70
100
  }
71
101
 
72
102
  startedUuids(): string[] {
@@ -82,16 +112,22 @@ export class Inbox implements AsyncIterable<SDKUserMessage> {
82
112
  this.wake?.();
83
113
  }
84
114
 
115
+ notify(): void {
116
+ this.hooks.queue?.(this.queue.size, this.queue.oldest);
117
+ this.wake?.();
118
+ }
119
+
85
120
  async *[Symbol.asyncIterator](): AsyncIterator<SDKUserMessage> {
86
121
  while (!this.closed) {
87
- const next = this.queue.shift();
122
+ const next = (this.hooks.ready?.() ?? true) ? this.queue.take() : undefined;
88
123
  if (next !== undefined) {
124
+ this.hooks.dispatch?.(next);
125
+ if (next.uuid !== undefined) this.timing.dispatch(next.uuid);
126
+ this.notify();
89
127
  yield next;
90
128
  continue;
91
129
  }
92
- await new Promise<void>((resolve) => {
93
- this.wake = resolve;
94
- });
130
+ await new Promise<void>((resolve) => { this.wake = resolve; });
95
131
  this.wake = null;
96
132
  }
97
133
  }
@@ -108,12 +144,8 @@ export class Inbox implements AsyncIterable<SDKUserMessage> {
108
144
  }
109
145
 
110
146
  private remember(uuid: Uuid, text: string, at: number): void {
147
+ if (!this.ledger.has(uuid) && this.ledger.size >= LEDGER_MAX) throw new Error('The SDK input queue is full. Wait for queued work to finish before retrying.');
111
148
  this.ledger.set(uuid, { text, at, uuid, state: 'queued' });
112
- while (this.ledger.size > LEDGER_MAX) {
113
- const oldest = [...this.ledger].find(([, entry]) => entry.state !== 'started')?.[0];
114
- if (oldest === undefined) break;
115
- this.ledger.delete(oldest);
116
- }
117
149
  if (!this.restoring) this.changed(this.unanswered());
118
150
  }
119
151
  }
@@ -0,0 +1,75 @@
1
+ import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
+ import type { RunnerInput } from '@metro-labs/core/runner-activity';
3
+
4
+ type InputKind = RunnerInput['kind'];
5
+
6
+ const LANES: readonly InputKind[] = ['chat', 'call', 'note'];
7
+ const CAPS: Record<InputKind, number> = { chat: 100, call: 32, note: 16 };
8
+ const BYTES_MAX = 4 * 1024 * 1024;
9
+ const INPUT_MAX = 256 * 1024;
10
+
11
+ interface Queued {
12
+ kind: InputKind;
13
+ message: SDKUserMessage;
14
+ bytes: number;
15
+ at: number;
16
+ }
17
+
18
+ export class InputQueue {
19
+ private readonly items: Queued[] = [];
20
+ private bytes = 0;
21
+ private lane = 0;
22
+ private first: string | null = null;
23
+
24
+ prefer(uuid: string): void { this.first = uuid; }
25
+
26
+ get size(): number { return this.items.length; }
27
+ get oldest(): number | null { return this.items.reduce<number | null>((at, item) => at === null ? item.at : Math.min(at, item.at), null); }
28
+
29
+ check(kind: InputKind, text: string): void {
30
+ const bytes = Buffer.byteLength(text);
31
+ if (bytes > INPUT_MAX || this.bytes + bytes > BYTES_MAX || this.items.filter((item) => item.kind === kind).length >= CAPS[kind]) {
32
+ throw new Error('The SDK input queue is full. Wait for queued work to finish before retrying.');
33
+ }
34
+ }
35
+
36
+ push(kind: InputKind, message: SDKUserMessage, text: string, at: number): void {
37
+ const bytes = Buffer.byteLength(text);
38
+ this.items.push({ kind, message, bytes, at });
39
+ this.bytes += bytes;
40
+ }
41
+
42
+ take(): SDKUserMessage | undefined {
43
+ if (this.first !== null) {
44
+ const index = this.items.findIndex((item) => item.message.uuid === this.first);
45
+ this.first = null;
46
+ if (index >= 0) return this.remove(index)?.message;
47
+ }
48
+ for (let i = 0; i < LANES.length; i += 1) {
49
+ const lane = (this.lane + i) % LANES.length;
50
+ const index = this.items.findIndex((item) => item.kind === LANES[lane]);
51
+ if (index < 0) continue;
52
+ this.lane = (lane + 1) % LANES.length;
53
+ return this.remove(index)?.message;
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ cancel(uuids: ReadonlySet<string>): string[] {
59
+ const removed: string[] = [];
60
+ for (let index = this.items.length - 1; index >= 0; index -= 1) {
61
+ const uuid = this.items[index]?.message.uuid;
62
+ if (uuid !== undefined && uuids.has(uuid)) {
63
+ this.remove(index);
64
+ removed.push(uuid);
65
+ }
66
+ }
67
+ return removed;
68
+ }
69
+
70
+ private remove(index: number): Queued | undefined {
71
+ const [item] = this.items.splice(index, 1);
72
+ if (item !== undefined) this.bytes -= item.bytes;
73
+ return item;
74
+ }
75
+ }
@@ -0,0 +1,68 @@
1
+ import type { RunnerInput } from '@metro-labs/core/runner-activity';
2
+ import { log } from '@metro-labs/core/log';
3
+
4
+ export type InputWatch = (input: RunnerInput) => void;
5
+
6
+ export class InputTiming {
7
+ private readonly inputs = new Map<string, RunnerInput>();
8
+ private readonly active = new Set<string>();
9
+
10
+ constructor(private readonly changed: InputWatch) {}
11
+
12
+ accept(id: string, kind: RunnerInput['kind'], at: number): void {
13
+ const input: RunnerInput = { id, kind, state: 'accepted', acceptedAt: at, dispatchedAt: null, consumedAt: null, firstOutputAt: null, completedAt: null };
14
+ this.inputs.set(id, input);
15
+ if (this.inputs.size > 2_000) {
16
+ const oldest = this.inputs.keys().next().value;
17
+ if (oldest !== undefined) { this.inputs.delete(oldest); this.active.delete(oldest); }
18
+ }
19
+ this.note(input);
20
+ }
21
+
22
+ dispatch(id: string): void {
23
+ const input = this.inputs.get(id);
24
+ if (input === undefined) return;
25
+ input.dispatchedAt = Date.now();
26
+ this.note(input);
27
+ }
28
+
29
+ consume(uuids: readonly string[]): void {
30
+ for (const id of uuids) {
31
+ const input = this.inputs.get(id);
32
+ if (input === undefined || input.consumedAt !== null) continue;
33
+ input.consumedAt = Date.now();
34
+ input.state = 'consumed';
35
+ this.active.add(id);
36
+ this.note(input);
37
+ }
38
+ }
39
+
40
+ output(): void {
41
+ for (const id of this.active) {
42
+ const input = this.inputs.get(id);
43
+ if (input === undefined || input.firstOutputAt !== null) continue;
44
+ input.firstOutputAt = Date.now();
45
+ input.state = 'output';
46
+ this.note(input);
47
+ }
48
+ }
49
+
50
+ finish(uuids: readonly string[], cancelled = false): void {
51
+ for (const id of uuids) {
52
+ const input = this.inputs.get(id);
53
+ if (input === undefined) continue;
54
+ input.completedAt = Date.now();
55
+ input.state = cancelled ? 'cancelled' : 'completed';
56
+ this.inputs.delete(id);
57
+ this.active.delete(id);
58
+ this.note(input);
59
+ }
60
+ }
61
+
62
+ boundary(): void { this.active.clear(); }
63
+
64
+ private note(input: RunnerInput): void {
65
+ this.changed({ ...input });
66
+ log.info({ input: input.id, kind: input.kind, state: input.state, queueAgeMs: (input.consumedAt ?? Date.now()) - input.acceptedAt }, 'sdk-runner: input lifecycle');
67
+ }
68
+ }
@@ -3,6 +3,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
3
3
  import { CallToolResultSchema, type CallToolRequest, type CallToolResult, type ListToolsResult, type Notification } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { errMsg, log } from '@metro-labs/core/log';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
6
+ import { CALL_NOTICE, CALL_STATE, callNotice, callState, type CallNotice, type CallSource, type CallState } from '@metro-labs/core/call';
6
7
  import { channelEvent, type ChannelEvent } from './channel-text.js';
7
8
 
8
9
  const CHANNEL = 'notifications/claude/channel';
@@ -23,10 +24,13 @@ export interface PermissionAsk {
23
24
  tool_name: string;
24
25
  description: string;
25
26
  input_preview: string;
27
+ call?: CallSource;
26
28
  }
27
29
 
28
30
  export interface LinkEvents {
29
31
  channel(event: ChannelEvent): void;
32
+ call?(notice: CallNotice): void;
33
+ callState?(state: CallState): void;
30
34
  toolsChanged(): void;
31
35
  model(model: string | null): void;
32
36
  lost(reason: string): void;
@@ -81,14 +85,19 @@ export class MetroLink implements Asker {
81
85
  ask(ask: PermissionAsk, signal: AbortSignal): Promise<Behavior> {
82
86
  if (signal.aborted) return Promise.resolve('deny');
83
87
  return new Promise((resolve) => {
88
+ const abort = (): void => {
89
+ settle('deny');
90
+ this.client.notification({ method: 'notifications/metro/permission_cancel', params: { request_id: ask.request_id } }).catch(() => {
91
+ log.warn('sdk-runner: could not cancel a pending approval with metro');
92
+ });
93
+ };
84
94
  const settle = (behavior: Behavior): void => {
85
- this.waiting.delete(ask.request_id);
95
+ signal.removeEventListener('abort', abort);
96
+ if (!this.waiting.delete(ask.request_id)) return;
86
97
  resolve(behavior);
87
98
  };
88
99
  this.waiting.set(ask.request_id, settle);
89
- signal.addEventListener('abort', () => {
90
- settle('deny');
91
- }, { once: true });
100
+ signal.addEventListener('abort', abort, { once: true });
92
101
  this.client.notification({ method: ASK, params: { ...ask } }).catch((err: unknown) => {
93
102
  log.warn({ err: errMsg(err), id: ask.request_id }, 'sdk-runner: could not hand an approval to metro');
94
103
  settle('deny');
@@ -106,6 +115,10 @@ export class MetroLink implements Asker {
106
115
  if (notification.method === CHANNEL) {
107
116
  const event = channelEvent(params);
108
117
  if (event !== null) this.events.channel(event);
118
+ } else if (notification.method === CALL_NOTICE) {
119
+ this.callNotified(params);
120
+ } else if (notification.method === CALL_STATE) {
121
+ this.reconciled(params);
109
122
  } else if (notification.method === ANSWER) {
110
123
  this.answered(params);
111
124
  } else if (notification.method === TOOLS_CHANGED) {
@@ -115,6 +128,16 @@ export class MetroLink implements Asker {
115
128
  }
116
129
  }
117
130
 
131
+ private callNotified(params: Record<string, unknown>): void {
132
+ const notice = callNotice(params);
133
+ if (notice !== null) this.events.call?.(notice);
134
+ }
135
+
136
+ private reconciled(params: Record<string, unknown>): void {
137
+ const state = callState(params);
138
+ if (state !== null) this.events.callState?.(state);
139
+ }
140
+
118
141
  private answered(params: Record<string, unknown>): void {
119
142
  const id = typeof params.request_id === 'string' ? params.request_id : '';
120
143
  this.waiting.get(id)?.(params.behavior === 'allow' ? 'allow' : 'deny');
@@ -23,10 +23,6 @@ try {
23
23
  activity = new Activity(join(dirname(cfg.statePath), 'agent-status.json'));
24
24
  agent = await startAgent(cfg, {
25
25
  activity,
26
- speech: {
27
- say: (text) => { log.debug({ chars: text.length }, 'sdk-runner: speech'); },
28
- done: () => { log.debug('sdk-runner: speech done'); },
29
- },
30
26
  lost: () => {
31
27
  activity?.fail('The SDK lost its Metro connection. The session will restart.');
32
28
  exitWith(1)();
@@ -1,11 +1,25 @@
1
- export const FRONT_RULES = `You are the orchestrator of this agent's Agent SDK chat session. Chat messages (<channel> blocks) and the reports of your background workers arrive in this conversation. Voice calls still use a separate session. Do not claim that chat and calls share context.
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
- export const callStarted = (where: string): string =>
7
- `[A voice call started: ${where}. Until it ends, the caller's words arrive as <call> blocks, and your plain text answer to a <call> block is spoken aloud: one to three short sentences, no markdown, lists, links or emoji. A <call> block comes first: start your response with the words for the caller, before any tool call, then finish other work, and write no more text in that response unless the caller needs to hear it. Never answer a <call> block with send. Chat messages that arrive during the call keep their usual handling with the metro tools; your text about them is not spoken. When a worker finishes during the call, tell the caller in a sentence or two. Greet the caller now in one short sentence.]`;
11
+ const attr = (text: string): string => text.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 CALL_ENDED = '[The voice call ended. Nothing about it is posted anywhere unless someone asks.]';
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 callWords = (text: string): string => `<call>${text}</call>`;
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.]`;