voicecc 1.1.35 → 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 +58 -2
- package/package.json +2 -3
- package/server/index.ts +96 -8
- package/server/services/device-pairing.ts +18 -2
- 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,236 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Core session management for text chat sessions.
|
|
3
|
-
*
|
|
4
|
-
* Manages ClaudeSession lifecycle for text chat: creation on first message,
|
|
5
|
-
* reuse across messages, and cleanup on close or inactivity timeout.
|
|
6
|
-
* Framework-agnostic -- the Hono route layer in dashboard/routes/chat.ts
|
|
7
|
-
* calls these functions.
|
|
8
|
-
*
|
|
9
|
-
* - getOrCreateSession: lazily creates a session on first message
|
|
10
|
-
* - streamMessage: sends user text to Claude, yields SSE events
|
|
11
|
-
* - closeSession: cleans up a session
|
|
12
|
-
* - Inactivity timeout auto-cleans abandoned sessions after 10 minutes
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { join } from "path";
|
|
16
|
-
|
|
17
|
-
import { createClaudeSession } from "./claude-session.js";
|
|
18
|
-
import { buildAgentPrompt, buildDefaultPrompt } from "./prompt-builder.js";
|
|
19
|
-
import { acquireSessionLock } from "./session-lock.js";
|
|
20
|
-
import { AGENTS_DIR } from "../services/agent-store.js";
|
|
21
|
-
|
|
22
|
-
import type { ClaudeSession } from "./claude-session.js";
|
|
23
|
-
import type { SessionLock } from "./session-lock.js";
|
|
24
|
-
import type { ClaudeStreamEvent } from "./types.js";
|
|
25
|
-
|
|
26
|
-
// ============================================================================
|
|
27
|
-
// CONSTANTS
|
|
28
|
-
// ============================================================================
|
|
29
|
-
|
|
30
|
-
/** Default max concurrent sessions (shared with voice sessions) */
|
|
31
|
-
const DEFAULT_MAX_SESSIONS = 3;
|
|
32
|
-
|
|
33
|
-
/** Inactivity timeout before auto-cleaning a session (10 minutes) */
|
|
34
|
-
const INACTIVITY_TIMEOUT_MS = 600_000;
|
|
35
|
-
|
|
36
|
-
/** Interval for checking inactive sessions (60 seconds) */
|
|
37
|
-
const CLEANUP_INTERVAL_MS = 60_000;
|
|
38
|
-
|
|
39
|
-
// ============================================================================
|
|
40
|
-
// TYPES
|
|
41
|
-
// ============================================================================
|
|
42
|
-
|
|
43
|
-
/** Tracks an active text chat session */
|
|
44
|
-
interface ActiveChatSession {
|
|
45
|
-
/** The device token used for this session */
|
|
46
|
-
deviceToken: string;
|
|
47
|
-
/** Claude session handle */
|
|
48
|
-
claudeSession: ClaudeSession;
|
|
49
|
-
/** Session lock handle */
|
|
50
|
-
lock: SessionLock;
|
|
51
|
-
/** Optional agent ID for agent-specific sessions */
|
|
52
|
-
agentId?: string;
|
|
53
|
-
/** Whether the session is currently streaming a response */
|
|
54
|
-
streaming: boolean;
|
|
55
|
-
/** Timestamp of last activity (used for inactivity timeout) */
|
|
56
|
-
lastActivity: number;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** SSE event sent to the client */
|
|
60
|
-
export interface ChatSseEvent {
|
|
61
|
-
/** Event type */
|
|
62
|
-
type: "text_delta" | "tool_start" | "tool_end" | "result" | "error";
|
|
63
|
-
/** Text content or error message */
|
|
64
|
-
content: string;
|
|
65
|
-
/** Tool name (only for tool_start events) */
|
|
66
|
-
toolName?: string;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// ============================================================================
|
|
70
|
-
// STATE
|
|
71
|
-
// ============================================================================
|
|
72
|
-
|
|
73
|
-
/** Active chat sessions keyed by device token */
|
|
74
|
-
const activeSessions = new Map<string, ActiveChatSession>();
|
|
75
|
-
|
|
76
|
-
// Start the inactivity cleanup timer
|
|
77
|
-
setInterval(() => {
|
|
78
|
-
const now = Date.now();
|
|
79
|
-
for (const [key, session] of activeSessions) {
|
|
80
|
-
if (now - session.lastActivity > INACTIVITY_TIMEOUT_MS) {
|
|
81
|
-
console.log(`Chat session timed out due to inactivity, token: ${key}`);
|
|
82
|
-
closeSession(key).catch((err) => {
|
|
83
|
-
console.error(`Error cleaning up timed-out chat session: ${err}`);
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}, CLEANUP_INTERVAL_MS);
|
|
88
|
-
|
|
89
|
-
// ============================================================================
|
|
90
|
-
// MAIN HANDLERS
|
|
91
|
-
// ============================================================================
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Get or create a chat session for the given token.
|
|
95
|
-
*
|
|
96
|
-
* On first call for a token, acquires a session lock, builds the system
|
|
97
|
-
* prompt, and creates a ClaudeSession. Subsequent calls return the existing
|
|
98
|
-
* session.
|
|
99
|
-
*
|
|
100
|
-
* @param sessionKey - Device token to key the session on
|
|
101
|
-
* @param agentId - Optional agent ID for agent-specific prompts
|
|
102
|
-
* @returns The active chat session
|
|
103
|
-
*/
|
|
104
|
-
export async function getOrCreateSession(sessionKey: string, agentId?: string): Promise<ActiveChatSession> {
|
|
105
|
-
const existing = activeSessions.get(sessionKey);
|
|
106
|
-
if (existing) return existing;
|
|
107
|
-
|
|
108
|
-
const maxSessions = parseInt(process.env.MAX_CONCURRENT_SESSIONS ?? "", 10) || DEFAULT_MAX_SESSIONS;
|
|
109
|
-
const lock = acquireSessionLock(maxSessions);
|
|
110
|
-
|
|
111
|
-
let systemPrompt: string;
|
|
112
|
-
let cwd: string | undefined;
|
|
113
|
-
|
|
114
|
-
if (agentId) {
|
|
115
|
-
systemPrompt = await buildAgentPrompt(agentId, "text");
|
|
116
|
-
cwd = join(AGENTS_DIR, agentId);
|
|
117
|
-
console.log(`Chat session using agent "${agentId}" for token ${sessionKey}`);
|
|
118
|
-
} else {
|
|
119
|
-
systemPrompt = buildDefaultPrompt("text");
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const claudeSession = await createClaudeSession({
|
|
123
|
-
allowedTools: [],
|
|
124
|
-
permissionMode: "bypassPermissions",
|
|
125
|
-
systemPrompt: "",
|
|
126
|
-
customSystemPrompt: systemPrompt,
|
|
127
|
-
...(cwd && { cwd }),
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
const session: ActiveChatSession = {
|
|
131
|
-
deviceToken: sessionKey,
|
|
132
|
-
claudeSession,
|
|
133
|
-
lock,
|
|
134
|
-
agentId,
|
|
135
|
-
streaming: false,
|
|
136
|
-
lastActivity: Date.now(),
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
activeSessions.set(sessionKey, session);
|
|
140
|
-
console.log(`Chat session created, token: ${sessionKey}`);
|
|
141
|
-
|
|
142
|
-
return session;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Send a user message and yield SSE events from Claude's response.
|
|
147
|
-
*
|
|
148
|
-
* Sets the streaming flag to prevent concurrent messages. Yields each
|
|
149
|
-
* ClaudeStreamEvent as a ChatSseEvent. Throws if the session is already
|
|
150
|
-
* streaming.
|
|
151
|
-
*
|
|
152
|
-
* @param sessionKey - Device token identifying the session
|
|
153
|
-
* @param text - User message text
|
|
154
|
-
* @yields ChatSseEvent objects for each streaming event
|
|
155
|
-
*/
|
|
156
|
-
export async function* streamMessage(sessionKey: string, text: string): AsyncGenerator<ChatSseEvent> {
|
|
157
|
-
const session = activeSessions.get(sessionKey);
|
|
158
|
-
if (!session) throw new Error("No active session");
|
|
159
|
-
|
|
160
|
-
if (session.streaming) {
|
|
161
|
-
throw new Error("ALREADY_STREAMING");
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
session.lastActivity = Date.now();
|
|
165
|
-
session.streaming = true;
|
|
166
|
-
|
|
167
|
-
try {
|
|
168
|
-
const events: AsyncIterable<ClaudeStreamEvent> = session.claudeSession.sendMessage(text);
|
|
169
|
-
|
|
170
|
-
for await (const event of events) {
|
|
171
|
-
const sseEvent: ChatSseEvent = {
|
|
172
|
-
type: event.type,
|
|
173
|
-
content: event.content,
|
|
174
|
-
...(event.toolName && { toolName: event.toolName }),
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
yield sseEvent;
|
|
178
|
-
}
|
|
179
|
-
} catch (err) {
|
|
180
|
-
const msg = err instanceof Error ? err.message : "Error during response streaming";
|
|
181
|
-
console.error(`Stream error for chat token ${sessionKey}:`, err);
|
|
182
|
-
yield { type: "error", content: msg };
|
|
183
|
-
} finally {
|
|
184
|
-
session.streaming = false;
|
|
185
|
-
session.lastActivity = Date.now();
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Close a chat session by closing ClaudeSession, releasing the lock,
|
|
191
|
-
* and removing from the activeSessions map.
|
|
192
|
-
*
|
|
193
|
-
* @param sessionKey - Device token identifying the session
|
|
194
|
-
*/
|
|
195
|
-
export async function closeSession(sessionKey: string): Promise<void> {
|
|
196
|
-
const session = activeSessions.get(sessionKey);
|
|
197
|
-
if (!session) return;
|
|
198
|
-
|
|
199
|
-
activeSessions.delete(sessionKey);
|
|
200
|
-
|
|
201
|
-
await session.claudeSession.close();
|
|
202
|
-
session.lock.release();
|
|
203
|
-
|
|
204
|
-
console.log(`Chat session cleaned up, token: ${sessionKey}`);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Interrupt the current streaming response for a session.
|
|
209
|
-
*
|
|
210
|
-
* Calls interrupt() on the underlying ClaudeSession to stop generation,
|
|
211
|
-
* and resets the streaming flag so the user can send a new message.
|
|
212
|
-
*
|
|
213
|
-
* @param sessionKey - Device token identifying the session
|
|
214
|
-
* @returns true if a streaming session was interrupted, false if nothing to interrupt
|
|
215
|
-
*/
|
|
216
|
-
export function interruptSession(sessionKey: string): boolean {
|
|
217
|
-
const session = activeSessions.get(sessionKey);
|
|
218
|
-
if (!session || !session.streaming) return false;
|
|
219
|
-
|
|
220
|
-
session.claudeSession.interrupt();
|
|
221
|
-
session.streaming = false;
|
|
222
|
-
session.lastActivity = Date.now();
|
|
223
|
-
|
|
224
|
-
console.log(`Chat session interrupted, token: ${sessionKey}`);
|
|
225
|
-
return true;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Check if a session exists for the given token.
|
|
230
|
-
*
|
|
231
|
-
* @param sessionKey - Device token to check
|
|
232
|
-
* @returns true if a session exists
|
|
233
|
-
*/
|
|
234
|
-
export function hasSession(sessionKey: string): boolean {
|
|
235
|
-
return activeSessions.has(sessionKey);
|
|
236
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests that decodeChimeToPcm produces clean audio without artifacts.
|
|
3
|
-
*
|
|
4
|
-
* The chime PCM is sent directly to the browser as raw int16 samples.
|
|
5
|
-
* If the buffer contains file-format headers, they get played as loud
|
|
6
|
-
* garbage ("bop") before the actual chime.
|
|
7
|
-
*
|
|
8
|
-
* Run: npx tsx --test server/voice/chime.test.ts
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { test } from "node:test";
|
|
12
|
-
import { strict as assert } from "node:assert";
|
|
13
|
-
|
|
14
|
-
import { decodeChimeToPcm } from "./chime.js";
|
|
15
|
-
|
|
16
|
-
// ============================================================================
|
|
17
|
-
// CONSTANTS
|
|
18
|
-
// ============================================================================
|
|
19
|
-
|
|
20
|
-
/** Sample rate of the decoded chime */
|
|
21
|
-
const CHIME_RATE = 24000;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Max acceptable amplitude for the first 10ms of the chime.
|
|
25
|
-
* Glass.aiff fades in from silence, so early samples should be near-zero.
|
|
26
|
-
* A value above this means non-audio data (e.g. file headers) is present.
|
|
27
|
-
*/
|
|
28
|
-
const MAX_AMPLITUDE_FIRST_10MS = 500;
|
|
29
|
-
|
|
30
|
-
// ============================================================================
|
|
31
|
-
// TESTS
|
|
32
|
-
// ============================================================================
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* The chime starts quietly -- the Glass.aiff sound fades in from silence.
|
|
36
|
-
* If the first samples contain large values, the buffer has non-audio data
|
|
37
|
-
* (file-format headers) that would be heard as a loud pop/bop.
|
|
38
|
-
*/
|
|
39
|
-
test("chime PCM starts with near-silent samples (no file header artifacts)", () => {
|
|
40
|
-
const buf = decodeChimeToPcm();
|
|
41
|
-
const int16 = new Int16Array(buf.buffer, buf.byteOffset, buf.byteLength / 2);
|
|
42
|
-
|
|
43
|
-
const samplesIn10ms = Math.floor(CHIME_RATE * 0.01);
|
|
44
|
-
|
|
45
|
-
let maxAmplitude = 0;
|
|
46
|
-
for (let i = 0; i < samplesIn10ms; i++) {
|
|
47
|
-
maxAmplitude = Math.max(maxAmplitude, Math.abs(int16[i]));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
assert.ok(
|
|
51
|
-
maxAmplitude < MAX_AMPLITUDE_FIRST_10MS,
|
|
52
|
-
`First 10ms of chime has amplitude ${maxAmplitude} (limit: ${MAX_AMPLITUDE_FIRST_10MS}). ` +
|
|
53
|
-
`This likely means file-format header bytes are being included as audio data.`
|
|
54
|
-
);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* The decoded chime should contain roughly 1-2 seconds of audio.
|
|
59
|
-
* If the buffer is much larger, it likely includes a large file header.
|
|
60
|
-
* If much smaller, the decoding failed.
|
|
61
|
-
*/
|
|
62
|
-
test("chime PCM has a plausible duration for Glass.aiff", () => {
|
|
63
|
-
const buf = decodeChimeToPcm();
|
|
64
|
-
const sampleCount = buf.byteLength / 2; // int16 = 2 bytes per sample
|
|
65
|
-
const durationSec = sampleCount / CHIME_RATE;
|
|
66
|
-
|
|
67
|
-
assert.ok(durationSec > 0.5, `Chime too short: ${durationSec.toFixed(2)}s`);
|
|
68
|
-
assert.ok(durationSec < 3.0, `Chime too long: ${durationSec.toFixed(2)}s -- may contain header data`);
|
|
69
|
-
});
|
package/server/voice/chime.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared utility for loading the ready chime as raw PCM.
|
|
3
|
-
*
|
|
4
|
-
* Reads a pre-converted raw 24kHz int16 mono PCM file bundled in init/.
|
|
5
|
-
* Works on both macOS and Linux with no runtime dependencies.
|
|
6
|
-
*
|
|
7
|
-
* Responsibilities:
|
|
8
|
-
* - Load the bundled chime-24k.raw file as a Buffer
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { readFileSync } from "fs";
|
|
12
|
-
import { join, dirname } from "path";
|
|
13
|
-
import { fileURLToPath } from "url";
|
|
14
|
-
|
|
15
|
-
// ============================================================================
|
|
16
|
-
// CONSTANTS
|
|
17
|
-
// ============================================================================
|
|
18
|
-
|
|
19
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
-
|
|
21
|
-
/** Path to the bundled raw PCM chime (24kHz int16 mono) */
|
|
22
|
-
const CHIME_PATH = join(__dirname, "..", "..", "init", "chime-24k.raw");
|
|
23
|
-
|
|
24
|
-
// ============================================================================
|
|
25
|
-
// MAIN ENTRYPOINT
|
|
26
|
-
// ============================================================================
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Load the bundled chime as raw 24kHz int16 mono PCM.
|
|
30
|
-
*
|
|
31
|
-
* @returns Buffer containing raw 24kHz int16 mono PCM
|
|
32
|
-
* @throws Error if the chime file is missing
|
|
33
|
-
*/
|
|
34
|
-
export function decodeChimeToPcm(): Buffer {
|
|
35
|
-
return readFileSync(CHIME_PATH);
|
|
36
|
-
}
|
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Claude session via the @anthropic-ai/claude-agent-sdk.
|
|
3
|
-
*
|
|
4
|
-
* Keeps a single persistent Claude Code process alive across turns using
|
|
5
|
-
* streaming I/O (AsyncIterable<SDKUserMessage> input). This eliminates the
|
|
6
|
-
* ~2-3s process spawn overhead on each turn.
|
|
7
|
-
*
|
|
8
|
-
* Responsibilities:
|
|
9
|
-
* - Start a persistent query() on createClaudeSession (process spawns once)
|
|
10
|
-
* - Push user messages into the live session via an async queue
|
|
11
|
-
* - Extract streaming text deltas from SDKPartialAssistantMessage events
|
|
12
|
-
* - Map tool_use content blocks to tool_start / tool_end events
|
|
13
|
-
* - Support interruption via query.interrupt()
|
|
14
|
-
* - Provide clean session teardown
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { query as claudeQuery, type Query, type Options, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
18
|
-
import { buildDefaultPrompt } from "./prompt-builder.js";
|
|
19
|
-
import type { ClaudeSessionConfig, ClaudeStreamEvent } from "./types.js";
|
|
20
|
-
|
|
21
|
-
/** Injectable query function signature for testing. Matches the SDK query() contract. */
|
|
22
|
-
export type QueryFn = (params: { prompt: AsyncIterable<SDKUserMessage>; options: Options }) => Query;
|
|
23
|
-
|
|
24
|
-
// ============================================================================
|
|
25
|
-
// ASYNC QUEUE
|
|
26
|
-
// ============================================================================
|
|
27
|
-
|
|
28
|
-
/** Simple async iterable backed by a push queue. */
|
|
29
|
-
class AsyncQueue<T> implements AsyncIterable<T> {
|
|
30
|
-
private buf: T[] = [];
|
|
31
|
-
private resolve: ((r: IteratorResult<T>) => void) | null = null;
|
|
32
|
-
private done = false;
|
|
33
|
-
|
|
34
|
-
push(item: T) {
|
|
35
|
-
if (this.resolve) {
|
|
36
|
-
const r = this.resolve;
|
|
37
|
-
this.resolve = null;
|
|
38
|
-
r({ value: item, done: false });
|
|
39
|
-
} else {
|
|
40
|
-
this.buf.push(item);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
close() {
|
|
45
|
-
this.done = true;
|
|
46
|
-
if (this.resolve) {
|
|
47
|
-
const r = this.resolve;
|
|
48
|
-
this.resolve = null;
|
|
49
|
-
r({ value: undefined as any, done: true });
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Discard all buffered items. Used to clear stale events after interruption. */
|
|
54
|
-
drain(): void {
|
|
55
|
-
this.buf.length = 0;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Read one item (used by sendMessage to drain the event channel). */
|
|
59
|
-
async next(): Promise<T | undefined> {
|
|
60
|
-
if (this.buf.length > 0) return this.buf.shift()!;
|
|
61
|
-
if (this.done) return undefined;
|
|
62
|
-
const result = await new Promise<IteratorResult<T>>((r) => { this.resolve = r; });
|
|
63
|
-
return result.done ? undefined : result.value;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
67
|
-
return {
|
|
68
|
-
next: (): Promise<IteratorResult<T>> => {
|
|
69
|
-
if (this.buf.length > 0) {
|
|
70
|
-
return Promise.resolve({ value: this.buf.shift()!, done: false as const });
|
|
71
|
-
}
|
|
72
|
-
if (this.done) {
|
|
73
|
-
return Promise.resolve({ value: undefined as any, done: true as const });
|
|
74
|
-
}
|
|
75
|
-
return new Promise<IteratorResult<T>>((r) => { this.resolve = r; });
|
|
76
|
-
},
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// ============================================================================
|
|
82
|
-
// INTERFACES
|
|
83
|
-
// ============================================================================
|
|
84
|
-
|
|
85
|
-
/** Session object returned by createClaudeSession. */
|
|
86
|
-
interface ClaudeSession {
|
|
87
|
-
sendMessage(text: string): AsyncIterable<ClaudeStreamEvent>;
|
|
88
|
-
interrupt(): void;
|
|
89
|
-
close(): Promise<void>;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ============================================================================
|
|
93
|
-
// MAIN HANDLERS
|
|
94
|
-
// ============================================================================
|
|
95
|
-
|
|
96
|
-
async function createClaudeSession(
|
|
97
|
-
config: ClaudeSessionConfig,
|
|
98
|
-
queryOverride?: QueryFn,
|
|
99
|
-
): Promise<ClaudeSession> {
|
|
100
|
-
const systemPrompt = config.systemPrompt || buildDefaultPrompt("voice");
|
|
101
|
-
let sessionId = "";
|
|
102
|
-
let closed = false;
|
|
103
|
-
let lastTurnCompletedCleanly = true;
|
|
104
|
-
|
|
105
|
-
// Persistent input stream — user messages are pushed here across turns
|
|
106
|
-
const userMessages = new AsyncQueue<SDKUserMessage>();
|
|
107
|
-
|
|
108
|
-
// Event channel — SDK events are routed here for sendMessage to consume
|
|
109
|
-
const sdkEvents = new AsyncQueue<SDKMessage>();
|
|
110
|
-
|
|
111
|
-
// systemPrompt as string replaces the entire system prompt.
|
|
112
|
-
// { type: 'preset', preset: 'claude_code', append: '...' } appends to default.
|
|
113
|
-
const options: Options = {
|
|
114
|
-
includePartialMessages: true,
|
|
115
|
-
maxThinkingTokens: 10000,
|
|
116
|
-
...(config.customSystemPrompt
|
|
117
|
-
? { systemPrompt: config.customSystemPrompt }
|
|
118
|
-
: { systemPrompt: { type: "preset" as const, preset: "claude_code" as const, append: systemPrompt } }),
|
|
119
|
-
permissionMode: config.permissionMode as Options["permissionMode"],
|
|
120
|
-
allowDangerouslySkipPermissions: config.permissionMode === "bypassPermissions",
|
|
121
|
-
settingSources: ["user", "project", "local"],
|
|
122
|
-
...(config.cwd && { cwd: config.cwd }),
|
|
123
|
-
stderr: (data: string) => {
|
|
124
|
-
const msg = data.trim();
|
|
125
|
-
if (msg) console.error(`[claude-stderr] ${msg}`);
|
|
126
|
-
},
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
// Start persistent query — process spawns once and stays alive.
|
|
130
|
-
// NOTE: with AsyncIterable<SDKUserMessage> input, the SDK won't yield
|
|
131
|
-
// events until the first user message is consumed, so we don't block
|
|
132
|
-
// waiting for a system init event here. Session ID is captured when
|
|
133
|
-
// the system event arrives during the first turn.
|
|
134
|
-
const queryFn = queryOverride ?? claudeQuery;
|
|
135
|
-
const q = queryFn({ prompt: userMessages, options });
|
|
136
|
-
|
|
137
|
-
// Background: pump SDK events into our channel
|
|
138
|
-
(async () => {
|
|
139
|
-
try {
|
|
140
|
-
for await (const msg of q) {
|
|
141
|
-
if (msg.type === "system" && !sessionId) {
|
|
142
|
-
sessionId = msg.session_id;
|
|
143
|
-
console.log(`[claude] session ready (id=${sessionId})`);
|
|
144
|
-
}
|
|
145
|
-
sdkEvents.push(msg);
|
|
146
|
-
}
|
|
147
|
-
} catch (err) {
|
|
148
|
-
console.error("[claude] SDK pump error:", err);
|
|
149
|
-
} finally {
|
|
150
|
-
sdkEvents.close();
|
|
151
|
-
}
|
|
152
|
-
})();
|
|
153
|
-
|
|
154
|
-
console.log("[claude] persistent process started");
|
|
155
|
-
|
|
156
|
-
return {
|
|
157
|
-
async *sendMessage(text: string): AsyncIterable<ClaudeStreamEvent> {
|
|
158
|
-
if (closed) {
|
|
159
|
-
throw new Error("Session is closed.");
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (!text.trim()) {
|
|
163
|
-
throw new Error("Cannot send empty message.");
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// If the previous turn was interrupted, consume remaining events until its result
|
|
167
|
-
if (!lastTurnCompletedCleanly) {
|
|
168
|
-
while (true) {
|
|
169
|
-
const msg = await sdkEvents.next();
|
|
170
|
-
if (!msg || msg.type === "result") break;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
sdkEvents.drain();
|
|
174
|
-
lastTurnCompletedCleanly = false;
|
|
175
|
-
|
|
176
|
-
const t0 = Date.now();
|
|
177
|
-
let hasStreamedContent = false;
|
|
178
|
-
const toolUseBlocks = new Set<number>();
|
|
179
|
-
const thinkingBlocks = new Set<number>();
|
|
180
|
-
|
|
181
|
-
// Push user message into the live session
|
|
182
|
-
userMessages.push({
|
|
183
|
-
type: "user",
|
|
184
|
-
message: { content: text, role: "user" },
|
|
185
|
-
parent_tool_use_id: null,
|
|
186
|
-
session_id: sessionId,
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
// Read events for this turn until result
|
|
190
|
-
while (true) {
|
|
191
|
-
const msg = await sdkEvents.next();
|
|
192
|
-
if (!msg) break; // channel closed (process died)
|
|
193
|
-
|
|
194
|
-
// Streaming events (token-level deltas)
|
|
195
|
-
if (msg.type === "stream_event") {
|
|
196
|
-
const event = msg.event;
|
|
197
|
-
|
|
198
|
-
if (event.type === "content_block_start") {
|
|
199
|
-
if (event.content_block.type === "tool_use") {
|
|
200
|
-
hasStreamedContent = true;
|
|
201
|
-
toolUseBlocks.add(event.index);
|
|
202
|
-
yield { type: "tool_start", content: "", toolName: event.content_block.name };
|
|
203
|
-
}
|
|
204
|
-
if (event.content_block.type === "thinking") {
|
|
205
|
-
hasStreamedContent = true;
|
|
206
|
-
thinkingBlocks.add(event.index);
|
|
207
|
-
console.log(`[claude] thinking started at +${Date.now() - t0}ms`);
|
|
208
|
-
yield { type: "text_delta", content: "Thinking... " };
|
|
209
|
-
}
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (event.type === "content_block_delta") {
|
|
214
|
-
if (event.delta.type === "text_delta") {
|
|
215
|
-
if (!hasStreamedContent) {
|
|
216
|
-
console.log(`[claude] first delta at +${Date.now() - t0}ms`);
|
|
217
|
-
}
|
|
218
|
-
hasStreamedContent = true;
|
|
219
|
-
yield { type: "text_delta", content: event.delta.text };
|
|
220
|
-
}
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (event.type === "content_block_stop") {
|
|
225
|
-
if (thinkingBlocks.has(event.index)) {
|
|
226
|
-
thinkingBlocks.delete(event.index);
|
|
227
|
-
console.log(`[claude] thinking ended at +${Date.now() - t0}ms`);
|
|
228
|
-
}
|
|
229
|
-
if (toolUseBlocks.has(event.index)) {
|
|
230
|
-
toolUseBlocks.delete(event.index);
|
|
231
|
-
yield { type: "tool_end", content: "" };
|
|
232
|
-
}
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
continue;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Full assistant message — fallback if streaming didn't produce deltas
|
|
240
|
-
if (msg.type === "assistant") {
|
|
241
|
-
if (hasStreamedContent) {
|
|
242
|
-
console.log(`[claude] full message at +${Date.now() - t0}ms (skipped, already streamed)`);
|
|
243
|
-
} else {
|
|
244
|
-
console.log(`[claude] full message at +${Date.now() - t0}ms (no streaming, using fallback)`);
|
|
245
|
-
const blocks = msg.message.content;
|
|
246
|
-
if (Array.isArray(blocks)) {
|
|
247
|
-
for (const block of blocks) {
|
|
248
|
-
if (block.type === "text") {
|
|
249
|
-
yield { type: "text_delta", content: block.text };
|
|
250
|
-
}
|
|
251
|
-
if (block.type === "tool_use") {
|
|
252
|
-
yield { type: "tool_start", content: "", toolName: block.name };
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
toolUseBlocks.clear();
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Skip system events and synthetic user messages (tool results)
|
|
262
|
-
if (msg.type === "system" || msg.type === "user") {
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// Result — turn complete
|
|
267
|
-
if (msg.type === "result") {
|
|
268
|
-
lastTurnCompletedCleanly = true;
|
|
269
|
-
console.log(`[claude] result at +${Date.now() - t0}ms (streamed=${hasStreamedContent})`);
|
|
270
|
-
if (msg.is_error) {
|
|
271
|
-
yield { type: "error", content: msg.subtype === "success" ? String((msg as any).result) : msg.subtype };
|
|
272
|
-
}
|
|
273
|
-
break;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
yield { type: "result", content: "" };
|
|
278
|
-
},
|
|
279
|
-
|
|
280
|
-
interrupt(): void {
|
|
281
|
-
q.interrupt();
|
|
282
|
-
},
|
|
283
|
-
|
|
284
|
-
async close(): Promise<void> {
|
|
285
|
-
closed = true;
|
|
286
|
-
userMessages.close();
|
|
287
|
-
await q.interrupt();
|
|
288
|
-
},
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
export { createClaudeSession };
|
|
293
|
-
export type { ClaudeSession };
|