voicecc 1.1.36 → 1.2.0

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 (45) hide show
  1. package/bin/voicecc.js +94 -1
  2. package/dashboard/dist/assets/index-DCeOdulF.js +28 -0
  3. package/dashboard/dist/index.html +1 -1
  4. package/dashboard/routes/agents.ts +28 -8
  5. package/dashboard/routes/browser-call.ts +3 -2
  6. package/dashboard/routes/chat.ts +75 -55
  7. package/dashboard/routes/providers.ts +5 -74
  8. package/dashboard/routes/twilio.ts +104 -5
  9. package/dashboard/routes/voice.ts +98 -0
  10. package/dashboard/server.ts +48 -1
  11. package/package.json +2 -3
  12. package/server/index.ts +96 -8
  13. package/server/services/twilio-manager.ts +29 -10
  14. package/dashboard/dist/assets/index-C62C9Gp0.js +0 -28
  15. package/dashboard/dist/audio-processor.js +0 -126
  16. package/server/services/heartbeat.ts +0 -403
  17. package/server/voice/assets/chime.wav +0 -0
  18. package/server/voice/assets/startup.pcm +0 -0
  19. package/server/voice/audio-adapter.ts +0 -60
  20. package/server/voice/audio-inactivity.test.ts +0 -108
  21. package/server/voice/audio-inactivity.ts +0 -91
  22. package/server/voice/browser-audio-playback.test.ts +0 -149
  23. package/server/voice/browser-audio.ts +0 -147
  24. package/server/voice/browser-server.ts +0 -311
  25. package/server/voice/chat-server.ts +0 -236
  26. package/server/voice/chime.test.ts +0 -69
  27. package/server/voice/chime.ts +0 -36
  28. package/server/voice/claude-session.ts +0 -293
  29. package/server/voice/endpointing.ts +0 -163
  30. package/server/voice/mic-vpio +0 -0
  31. package/server/voice/narration.ts +0 -204
  32. package/server/voice/prompt-builder.ts +0 -108
  33. package/server/voice/session-lock.ts +0 -123
  34. package/server/voice/stt-elevenlabs.ts +0 -210
  35. package/server/voice/stt-provider.ts +0 -106
  36. package/server/voice/tts-elevenlabs-hiss.test.ts +0 -183
  37. package/server/voice/tts-elevenlabs.ts +0 -397
  38. package/server/voice/tts-provider.ts +0 -155
  39. package/server/voice/twilio-audio.ts +0 -338
  40. package/server/voice/twilio-server.ts +0 -540
  41. package/server/voice/types.ts +0 -282
  42. package/server/voice/vad.ts +0 -101
  43. package/server/voice/voice-loop-bugs.test.ts +0 -348
  44. package/server/voice/voice-server.ts +0 -129
  45. package/server/voice/voice-session.ts +0 -539
@@ -1,163 +0,0 @@
1
- /**
2
- * Endpointing module -- determines when the user is done speaking.
3
- *
4
- * Uses a two-tier approach to decide turn completion:
5
- * - Fast path: VAD silence duration + sufficient word count (0ms latency)
6
- * - Slow path: Haiku semantic check for short/ambiguous utterances (~200ms)
7
- * - Timeout path: Forces completion after extended silence regardless of content
8
- *
9
- * Responsibilities:
10
- * - Track silence duration from VAD events
11
- * - Apply fast-path completion for longer utterances
12
- * - Call Haiku API for semantic turn-completion on short utterances
13
- * - Force timeout after extended silence
14
- * - Reset state between turns
15
- */
16
-
17
- import Anthropic from "@anthropic-ai/sdk";
18
- import type { EndpointDecision, EndpointingConfig, VadEvent } from "./types.js";
19
-
20
- // ============================================================================
21
- // CONSTANTS
22
- // ============================================================================
23
-
24
- const HAIKU_MODEL = "claude-haiku-4-5-20251001";
25
- const HAIKU_MAX_TOKENS = 10;
26
-
27
- // ============================================================================
28
- // INTERFACES
29
- // ============================================================================
30
-
31
- /**
32
- * Endpointer that processes VAD events and decides when the user is done speaking.
33
- */
34
- export interface Endpointer {
35
- /**
36
- * Process a VAD event and determine if the user's turn is complete.
37
- * @param event - The VAD event from the voice activity detector
38
- * @param currentTranscript - The accumulated transcript so far
39
- * @returns Decision on whether the user has finished speaking
40
- */
41
- onVadEvent(event: VadEvent, currentTranscript: string): Promise<EndpointDecision>;
42
-
43
- /**
44
- * Reset internal state for a new turn.
45
- */
46
- reset(): void;
47
- }
48
-
49
- // ============================================================================
50
- // MAIN ENTRYPOINT
51
- // ============================================================================
52
-
53
- /**
54
- * Create an endpointer instance with the given configuration.
55
- * @param config - Endpointing thresholds and feature flags
56
- * @returns A configured Endpointer
57
- */
58
- export function createEndpointer(config: EndpointingConfig): Endpointer {
59
- const anthropicClient = config.enableHaikuFallback ? new Anthropic() : null;
60
-
61
- return {
62
- onVadEvent(event: VadEvent, currentTranscript: string): Promise<EndpointDecision> {
63
- return handleVadEvent(event, currentTranscript, config, anthropicClient);
64
- },
65
-
66
- reset(): void {
67
- // No internal state to reset -- completion is evaluated per SPEECH_END event.
68
- },
69
- };
70
- }
71
-
72
- // ============================================================================
73
- // MAIN LOGIC
74
- // ============================================================================
75
-
76
- /**
77
- * Handle a single VAD event and produce an endpoint decision.
78
- * @param event - The VAD event to process
79
- * @param transcript - Current accumulated transcript
80
- * @param config - Endpointing configuration
81
- * @param client - Anthropic client for Haiku calls (null if disabled)
82
- * @returns The endpoint decision
83
- */
84
- async function handleVadEvent(
85
- event: VadEvent,
86
- transcript: string,
87
- config: EndpointingConfig,
88
- client: Anthropic | null,
89
- ): Promise<EndpointDecision> {
90
- // Active speech -- not complete
91
- if (event.type === "SPEECH_START" || event.type === "SPEECH_CONTINUE") {
92
- return { isComplete: false, transcript, method: "vad_fast" };
93
- }
94
-
95
- // Speech ended -- evaluate completion immediately.
96
- // avr-vad's SPEECH_END fires after internal debouncing (redemptionFrames),
97
- // so silence has already been confirmed by the VAD. No need to wait for
98
- // separate SILENCE events (avr-vad doesn't emit them).
99
- if (event.type === "SPEECH_END") {
100
- const wordCount = countWords(transcript);
101
-
102
- // Fast path: sufficient words, complete immediately
103
- if (wordCount >= config.minWordCountForFastPath) {
104
- return { isComplete: true, transcript, method: "vad_fast" };
105
- }
106
-
107
- // Short utterance: ask Haiku for semantic turn-completion check
108
- if (config.enableHaikuFallback && client !== null) {
109
- const isComplete = await checkTurnCompletionWithHaiku(client, transcript);
110
- return { isComplete, transcript, method: "haiku_semantic" };
111
- }
112
-
113
- // Haiku disabled, treat as complete
114
- return { isComplete: true, transcript, method: "vad_fast" };
115
- }
116
-
117
- // Unknown event type -- not complete
118
- return { isComplete: false, transcript, method: "vad_fast" };
119
- }
120
-
121
- // ============================================================================
122
- // HELPER FUNCTIONS
123
- // ============================================================================
124
-
125
- /**
126
- * Count the number of words in a transcript string.
127
- * @param text - The transcript text
128
- * @returns Number of whitespace-separated words
129
- */
130
- function countWords(text: string): number {
131
- const trimmed = text.trim();
132
- if (trimmed.length === 0) {
133
- return 0;
134
- }
135
- return trimmed.split(/\s+/).length;
136
- }
137
-
138
- /**
139
- * Call Haiku to determine if a short transcript represents a complete user turn.
140
- * @param client - The Anthropic SDK client
141
- * @param transcript - The short transcript to evaluate
142
- * @returns True if Haiku considers the turn complete
143
- */
144
- async function checkTurnCompletionWithHaiku(client: Anthropic, transcript: string): Promise<boolean> {
145
- const response = await client.messages.create({
146
- model: HAIKU_MODEL,
147
- max_tokens: HAIKU_MAX_TOKENS,
148
- messages: [
149
- {
150
- role: "user",
151
- content: `Is this a complete user turn? Answer only "yes" or "no".\n\nTranscript: "${transcript}"`,
152
- },
153
- ],
154
- });
155
-
156
- const firstBlock = response.content[0];
157
- if (firstBlock.type !== "text") {
158
- throw new Error(`Unexpected Haiku response block type: ${firstBlock.type}`);
159
- }
160
-
161
- const answer = firstBlock.text.trim().toLowerCase();
162
- return answer.startsWith("yes");
163
- }
Binary file
@@ -1,204 +0,0 @@
1
- /**
2
- * Processes Claude's streaming output into TTS-friendly text.
3
- *
4
- * Two modes of operation:
5
- * - Response mode: passes text_delta content through immediately for streaming
6
- * TTS. Text is buffered into sentences downstream in the TTS module.
7
- * - Long-task mode: emits periodic template-based summaries during tool use
8
- * (e.g. "Running Bash...", "Still working on Bash...").
9
- *
10
- * Responsibilities:
11
- * - Pass through streaming text deltas immediately for low-latency TTS
12
- * - Track tool execution and emit periodic spoken summaries
13
- * - Flush remaining text on result/error events
14
- */
15
-
16
- import type { ClaudeStreamEvent, NarrationConfig } from "./types.js";
17
-
18
- /** Strip markdown syntax so text reads naturally when spoken. */
19
- function stripMarkdown(text: string): string {
20
- return text
21
- .replace(/\*+/g, "") // bold/italic asterisks
22
- .replace(/#+\s*/g, "") // heading markers
23
- .replace(/`+/g, "") // inline code / code fences
24
- .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // [text](url) → text
25
- .replace(/^-\s+/gm, "") // unordered list markers
26
- .replace(/^\d+\.\s+/gm, ""); // ordered list markers
27
- }
28
-
29
- // ============================================================================
30
- // INTERFACES
31
- // ============================================================================
32
-
33
- /**
34
- * Narrator instance that processes Claude stream events into speakable text.
35
- */
36
- export interface Narrator {
37
- /**
38
- * Process a single Claude stream event and return any text ready to be spoken.
39
- * @param event - The Claude stream event to process
40
- * @returns Array of strings to speak (often empty, sometimes 1-2 sentences)
41
- */
42
- processEvent(event: ClaudeStreamEvent): string[];
43
-
44
- /**
45
- * Flush any remaining buffered text that hasn't been emitted yet.
46
- * @returns Array of remaining text strings to speak
47
- */
48
- flush(): string[];
49
-
50
- /**
51
- * Reset all internal state for a new conversation turn.
52
- */
53
- reset(): void;
54
- }
55
-
56
- // ============================================================================
57
- // MAIN HANDLERS
58
- // ============================================================================
59
-
60
- /**
61
- * Create a new Narrator instance that converts Claude stream events into
62
- * TTS-friendly sentence chunks.
63
- * @param config - Narration configuration (summaryIntervalMs controls long-task summary frequency)
64
- * @returns A Narrator instance
65
- */
66
- export function createNarrator(config: NarrationConfig, onEmit?: (text: string) => void): Narrator {
67
- // -- internal state --
68
- let currentToolName: string | null = null;
69
- let summaryTimer: NodeJS.Timeout | null = null;
70
- let inLongTask = false;
71
-
72
- /**
73
- * Process a single Claude stream event.
74
- * @param event - The streaming event from Claude
75
- * @returns Array of strings to speak
76
- */
77
- function processEvent(event: ClaudeStreamEvent): string[] {
78
- switch (event.type) {
79
- case "text_delta":
80
- return handleTextDelta(event);
81
- case "tool_start":
82
- return handleToolStart(event);
83
- case "tool_end":
84
- return handleToolEnd();
85
- case "result":
86
- case "error":
87
- return handleTerminal();
88
- default:
89
- return [];
90
- }
91
- }
92
-
93
- /**
94
- * Flush any remaining text in the buffer.
95
- * @returns Array of remaining text strings
96
- */
97
- function flush(): string[] {
98
- return [];
99
- }
100
-
101
- /**
102
- * Reset all state for a new conversation turn.
103
- */
104
- function reset(): void {
105
- currentToolName = null;
106
- clearSummaryTimer();
107
- inLongTask = false;
108
- }
109
-
110
- return { processEvent, flush, reset };
111
-
112
- // ============================================================================
113
- // HELPER FUNCTIONS
114
- // ============================================================================
115
-
116
- /**
117
- * Handle a text_delta event: pass through immediately, exit long-task mode.
118
- * Text chunking for TTS is handled downstream by TextSplitterStream.
119
- * @param event - The text_delta event
120
- * @returns Array containing the delta text
121
- */
122
- function handleTextDelta(event: ClaudeStreamEvent): string[] {
123
- // Text arriving means Claude is responding directly -- leave long-task mode
124
- if (inLongTask) {
125
- clearSummaryTimer();
126
- inLongTask = false;
127
- currentToolName = null;
128
- }
129
-
130
- const results: string[] = [];
131
- if (event.content) {
132
- const clean = stripMarkdown(event.content);
133
- if (clean) results.push(clean);
134
- }
135
- return results;
136
- }
137
-
138
- /**
139
- * Handle a tool_start event: enter long-task mode, start the summary timer,
140
- * and emit an initial "Running {toolName}..." message.
141
- * @param event - The tool_start event (must have toolName)
142
- * @returns Array containing the initial tool message
143
- */
144
- function handleToolStart(event: ClaudeStreamEvent): string[] {
145
- const toolName = event.toolName ?? "unknown tool";
146
- currentToolName = toolName;
147
- inLongTask = true;
148
-
149
- // Clear any existing timer before starting a new one
150
- clearSummaryTimer();
151
- startSummaryTimer();
152
-
153
- return [`Running ${toolName}...`];
154
- }
155
-
156
- /**
157
- * Handle a tool_end event: clear current tool context but stay in long-task
158
- * mode since more tools might follow.
159
- * @returns Empty array
160
- */
161
- function handleToolEnd(): string[] {
162
- currentToolName = null;
163
- return [];
164
- }
165
-
166
- /**
167
- * Handle result or error events: flush all remaining text and reset state.
168
- * @returns Array of any remaining text
169
- */
170
- function handleTerminal(): string[] {
171
- const remaining = flush();
172
-
173
- // Full reset for next turn
174
- clearSummaryTimer();
175
- currentToolName = null;
176
- inLongTask = false;
177
-
178
- return remaining;
179
- }
180
-
181
- /**
182
- * Start the periodic summary timer for long-task mode.
183
- * Emits "Still working on {toolName}..." at the configured interval.
184
- */
185
- function startSummaryTimer(): void {
186
- summaryTimer = setInterval(() => {
187
- const name = currentToolName ?? "the task";
188
- const summary = `Still working on ${name}...`;
189
- if (onEmit) {
190
- onEmit(summary);
191
- }
192
- }, config.summaryIntervalMs);
193
- }
194
-
195
- /**
196
- * Clear the summary timer if one is active.
197
- */
198
- function clearSummaryTimer(): void {
199
- if (summaryTimer !== null) {
200
- clearInterval(summaryTimer);
201
- summaryTimer = null;
202
- }
203
- }
204
- }
@@ -1,108 +0,0 @@
1
- /**
2
- * Shared prompt builder for all session types (voice, text).
3
- *
4
- * Loads the base system.md template once at module level and replaces the
5
- * <<MODE_OVERLAY>> placeholder with the appropriate overlay file for the
6
- * given session mode. For agent sessions, also injects SOUL/MEMORY/HEARTBEAT
7
- * files and the agent working directory.
8
- *
9
- * - buildAgentPrompt: full agent prompt with mode overlay + agent files
10
- * - buildDefaultPrompt: base prompt with mode overlay only (no agent files)
11
- */
12
-
13
- import { readFileSync } from "fs";
14
- import { dirname, join } from "path";
15
- import { fileURLToPath } from "url";
16
-
17
- import { getAgent, AGENTS_DIR } from "../services/agent-store.js";
18
-
19
- // ============================================================================
20
- // CONSTANTS
21
- // ============================================================================
22
-
23
- const __dirname = dirname(fileURLToPath(import.meta.url));
24
- const DEFAULTS_DIR = join(__dirname, "..", "..", "init", "defaults");
25
-
26
- /** Base system prompt template with <<MODE_OVERLAY>> placeholder */
27
- const BASE_SYSTEM_PROMPT = readFileSync(join(DEFAULTS_DIR, "system.md"), "utf-8").trim();
28
-
29
- /** Voice-specific behavioral instructions */
30
- const VOICE_OVERLAY = readFileSync(join(DEFAULTS_DIR, "system-voice-overlay.md"), "utf-8").trim();
31
-
32
- /** Text-specific behavioral instructions */
33
- const TEXT_OVERLAY = readFileSync(join(DEFAULTS_DIR, "system-text-overlay.md"), "utf-8").trim();
34
-
35
- /** Map of session mode to overlay content */
36
- const OVERLAY_MAP: Record<SessionMode, string> = {
37
- voice: VOICE_OVERLAY,
38
- text: TEXT_OVERLAY,
39
- };
40
-
41
- // ============================================================================
42
- // TYPES
43
- // ============================================================================
44
-
45
- /** Session mode determines which overlay is injected into the base prompt */
46
- export type SessionMode = "voice" | "text";
47
-
48
- // ============================================================================
49
- // MAIN HANDLERS
50
- // ============================================================================
51
-
52
- /**
53
- * Build a complete system prompt for an agent session.
54
- *
55
- * Loads the agent's SOUL.md, MEMORY.md, and HEARTBEAT.md via getAgent(),
56
- * replaces <<MODE_OVERLAY>>, <<AGENT_DIR>>, and <<AGENT_FILES>> placeholders
57
- * in the base system prompt.
58
- *
59
- * @param agentId - The agent identifier to load files for
60
- * @param mode - Session mode ("voice" or "text") to select the overlay
61
- * @returns Complete system prompt string ready for customSystemPrompt
62
- */
63
- export async function buildAgentPrompt(agentId: string, mode: SessionMode): Promise<string> {
64
- const agent = await getAgent(agentId);
65
- const agentDir = join(AGENTS_DIR, agentId);
66
-
67
- const agentFiles = [
68
- `<SOUL.md>\n${agent.soulMd}\n</SOUL.md>`,
69
- `<HEARTBEAT.md>\n${agent.heartbeatMd}\n</HEARTBEAT.md>`,
70
- `<MEMORY.md>\n${agent.memoryMd}\n</MEMORY.md>`,
71
- ].join("\n\n");
72
-
73
- return applyOverlay(BASE_SYSTEM_PROMPT, mode)
74
- .replaceAll("<<AGENT_DIR>>", agentDir)
75
- .replace("<<AGENT_FILES>>", agentFiles);
76
- }
77
-
78
- /**
79
- * Build a base system prompt without agent files.
80
- *
81
- * Replaces <<MODE_OVERLAY>> with the appropriate overlay for the given mode.
82
- * Used for non-agent sessions (e.g. claude-session fallback, default Twilio calls).
83
- *
84
- * @param mode - Session mode ("voice" or "text") to select the overlay
85
- * @returns System prompt string with overlay applied but no agent files
86
- */
87
- export function buildDefaultPrompt(mode: SessionMode): string {
88
- return applyOverlay(BASE_SYSTEM_PROMPT, mode);
89
- }
90
-
91
- // ============================================================================
92
- // HELPER FUNCTIONS
93
- // ============================================================================
94
-
95
- /**
96
- * Replace <<MODE_OVERLAY>> placeholders in a template with the overlay for the given mode.
97
- *
98
- * @param template - Base prompt template containing <<MODE_OVERLAY>> placeholders
99
- * @param mode - Session mode to select the overlay content
100
- * @returns Template with all <<MODE_OVERLAY>> placeholders replaced
101
- */
102
- function applyOverlay(template: string, mode: SessionMode): string {
103
- const overlay = OVERLAY_MAP[mode];
104
- if (!overlay) {
105
- throw new Error(`Unknown session mode: "${mode}"`);
106
- }
107
- return template.replaceAll("<<MODE_OVERLAY>>", overlay);
108
- }
@@ -1,123 +0,0 @@
1
- /**
2
- * Cross-process session limiter using PID-based lock files.
3
- *
4
- * Ensures the total number of active voice sessions (local mic + Twilio combined)
5
- * does not exceed MAX_CONCURRENT_SESSIONS. Stale lock files from crashed processes
6
- * are automatically cleaned up on every acquire.
7
- *
8
- * Responsibilities:
9
- * - Acquire a session slot by creating a PID lock file in ~/.claude-voice-sessions/
10
- * - Validate existing lock files by checking if their PIDs are still alive
11
- * - Clean up stale lock files from dead processes
12
- * - Release the lock file on session stop or process exit
13
- */
14
-
15
- import { mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
16
- import { join } from "path";
17
- import { homedir } from "os";
18
- import { randomUUID } from "crypto";
19
-
20
- // ============================================================================
21
- // CONSTANTS
22
- // ============================================================================
23
-
24
- /** Directory where PID lock files are stored */
25
- const LOCK_DIR = join(homedir(), ".claude-voice-sessions");
26
-
27
- // ============================================================================
28
- // INTERFACES
29
- // ============================================================================
30
-
31
- /**
32
- * Handle returned by acquireSessionLock. Call release() to free the session slot.
33
- */
34
- export interface SessionLock {
35
- /** Release the session lock (deletes the lock file) */
36
- release: () => void;
37
- }
38
-
39
- // ============================================================================
40
- // MAIN HANDLERS
41
- // ============================================================================
42
-
43
- /**
44
- * Acquire a session lock slot. Throws if the maximum number of concurrent
45
- * sessions has been reached.
46
- *
47
- * Cleans up stale lock files (dead PIDs) on every call. Creates a new lock
48
- * file containing the current PID. Registers a process.on('exit') handler
49
- * as a safety net to release on shutdown.
50
- *
51
- * @param maxSessions - Maximum number of concurrent sessions allowed
52
- * @returns A SessionLock handle with a release() method
53
- * @throws Error if maxSessions has been reached
54
- */
55
- export function acquireSessionLock(maxSessions: number): SessionLock {
56
- // Ensure lock directory exists
57
- mkdirSync(LOCK_DIR, { recursive: true });
58
-
59
- // List existing lock files and validate their PIDs
60
- const files = readdirSync(LOCK_DIR).filter((f) => f.endsWith(".lock"));
61
- let activeCount = 0;
62
-
63
- for (const file of files) {
64
- const filePath = join(LOCK_DIR, file);
65
- try {
66
- const pid = parseInt(readFileSync(filePath, "utf-8").trim(), 10);
67
- if (isNaN(pid) || !isProcessAlive(pid)) {
68
- // Stale lock file -- process is dead, clean it up
69
- unlinkSync(filePath);
70
- } else {
71
- activeCount++;
72
- }
73
- } catch {
74
- // File disappeared between readdir and read, or parse error -- skip
75
- try { unlinkSync(filePath); } catch { /* already gone */ }
76
- }
77
- }
78
-
79
- if (activeCount >= maxSessions) {
80
- throw new Error(
81
- `Session limit reached (${activeCount}/${maxSessions}). ` +
82
- `Cannot start another voice session.`
83
- );
84
- }
85
-
86
- // Create a new lock file with the current PID
87
- const lockFile = join(LOCK_DIR, `${randomUUID()}.lock`);
88
- writeFileSync(lockFile, String(process.pid), "utf-8");
89
-
90
- let released = false;
91
-
92
- /** Delete the lock file if it hasn't been released yet */
93
- function release(): void {
94
- if (released) return;
95
- released = true;
96
- try { unlinkSync(lockFile); } catch { /* already gone */ }
97
- }
98
-
99
- // Safety net: release on process exit
100
- process.on("exit", release);
101
-
102
- return { release };
103
- }
104
-
105
- // ============================================================================
106
- // HELPER FUNCTIONS
107
- // ============================================================================
108
-
109
- /**
110
- * Check if a process with the given PID is still alive.
111
- * Uses signal 0 which does not kill the process -- it only checks existence.
112
- *
113
- * @param pid - The process ID to check
114
- * @returns true if the process is alive, false otherwise
115
- */
116
- export function isProcessAlive(pid: number): boolean {
117
- try {
118
- process.kill(pid, 0);
119
- return true;
120
- } catch {
121
- return false;
122
- }
123
- }