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.
- package/bin/voicecc.js +94 -1
- package/dashboard/dist/assets/index-DCeOdulF.js +28 -0
- package/dashboard/dist/index.html +1 -1
- package/dashboard/routes/agents.ts +28 -8
- package/dashboard/routes/browser-call.ts +3 -2
- package/dashboard/routes/chat.ts +75 -55
- package/dashboard/routes/providers.ts +5 -74
- package/dashboard/routes/twilio.ts +104 -5
- package/dashboard/routes/voice.ts +98 -0
- package/dashboard/server.ts +48 -1
- package/package.json +2 -3
- package/server/index.ts +96 -8
- package/server/services/twilio-manager.ts +29 -10
- package/dashboard/dist/assets/index-C62C9Gp0.js +0 -28
- package/dashboard/dist/audio-processor.js +0 -126
- package/server/services/heartbeat.ts +0 -403
- package/server/voice/assets/chime.wav +0 -0
- package/server/voice/assets/startup.pcm +0 -0
- package/server/voice/audio-adapter.ts +0 -60
- package/server/voice/audio-inactivity.test.ts +0 -108
- package/server/voice/audio-inactivity.ts +0 -91
- package/server/voice/browser-audio-playback.test.ts +0 -149
- package/server/voice/browser-audio.ts +0 -147
- package/server/voice/browser-server.ts +0 -311
- package/server/voice/chat-server.ts +0 -236
- package/server/voice/chime.test.ts +0 -69
- package/server/voice/chime.ts +0 -36
- package/server/voice/claude-session.ts +0 -293
- package/server/voice/endpointing.ts +0 -163
- package/server/voice/mic-vpio +0 -0
- package/server/voice/narration.ts +0 -204
- package/server/voice/prompt-builder.ts +0 -108
- package/server/voice/session-lock.ts +0 -123
- package/server/voice/stt-elevenlabs.ts +0 -210
- package/server/voice/stt-provider.ts +0 -106
- package/server/voice/tts-elevenlabs-hiss.test.ts +0 -183
- package/server/voice/tts-elevenlabs.ts +0 -397
- package/server/voice/tts-provider.ts +0 -155
- package/server/voice/twilio-audio.ts +0 -338
- package/server/voice/twilio-server.ts +0 -540
- package/server/voice/types.ts +0 -282
- package/server/voice/vad.ts +0 -101
- package/server/voice/voice-loop-bugs.test.ts +0 -348
- package/server/voice/voice-server.ts +0 -129
- 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
|
-
}
|
package/server/voice/mic-vpio
DELETED
|
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
|
-
}
|