voicecc 1.0.7
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/.claude-plugin/plugin.json +6 -0
- package/README.md +48 -0
- package/bin/voicecc.js +39 -0
- package/dashboard/dist/assets/index-BXemFrMp.css +1 -0
- package/dashboard/dist/assets/index-dAYfRls7.js +11 -0
- package/dashboard/dist/audio-processor.js +126 -0
- package/dashboard/dist/index.html +13 -0
- package/dashboard/routes/auth.ts +119 -0
- package/dashboard/routes/browser-call.ts +87 -0
- package/dashboard/routes/claude-md.ts +50 -0
- package/dashboard/routes/conversations.ts +203 -0
- package/dashboard/routes/integrations.ts +154 -0
- package/dashboard/routes/mcp-servers.ts +198 -0
- package/dashboard/routes/settings.ts +64 -0
- package/dashboard/routes/tunnel.ts +66 -0
- package/dashboard/routes/twilio.ts +120 -0
- package/dashboard/routes/voice.ts +48 -0
- package/dashboard/routes/webrtc.ts +85 -0
- package/dashboard/server.ts +130 -0
- package/dashboard/tsconfig.json +13 -0
- package/init/CLAUDE.md +18 -0
- package/package.json +59 -0
- package/run.ts +68 -0
- package/scripts/postinstall.js +228 -0
- package/services/browser-call-manager.ts +106 -0
- package/services/device-pairing.ts +176 -0
- package/services/env.ts +88 -0
- package/services/tunnel.ts +204 -0
- package/services/twilio-manager.ts +126 -0
- package/sidecar/assets/startup.pcm +0 -0
- package/sidecar/audio-adapter.ts +60 -0
- package/sidecar/audio-capture.ts +220 -0
- package/sidecar/browser-audio-playback.test.ts +149 -0
- package/sidecar/browser-audio.ts +147 -0
- package/sidecar/browser-server.ts +331 -0
- package/sidecar/chime.test.ts +69 -0
- package/sidecar/chime.ts +54 -0
- package/sidecar/claude-session.ts +295 -0
- package/sidecar/endpointing.ts +163 -0
- package/sidecar/index.ts +83 -0
- package/sidecar/local-audio.ts +126 -0
- package/sidecar/mic-vpio +0 -0
- package/sidecar/mic-vpio.swift +484 -0
- package/sidecar/mock-tts-server-tagged.mjs +132 -0
- package/sidecar/narration.ts +204 -0
- package/sidecar/scripts/generate-startup-audio.py +79 -0
- package/sidecar/session-lock.ts +123 -0
- package/sidecar/sherpa-onnx-node.d.ts +4 -0
- package/sidecar/stt.ts +199 -0
- package/sidecar/tts-server.py +193 -0
- package/sidecar/tts.ts +481 -0
- package/sidecar/twilio-audio.ts +338 -0
- package/sidecar/twilio-server.ts +436 -0
- package/sidecar/types.ts +210 -0
- package/sidecar/vad.ts +101 -0
- package/sidecar/voice-loop-bugs.test.ts +522 -0
- package/sidecar/voice-session.ts +523 -0
- package/skills/voice/SKILL.md +26 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AudioWorklet processor for browser voice calls.
|
|
3
|
+
*
|
|
4
|
+
* Runs in the browser's audio rendering thread. Handles two jobs:
|
|
5
|
+
* - Mic capture: accumulates input samples into chunks, posts them to main thread
|
|
6
|
+
* - Speaker playback: reads from a chunk queue fed by main thread, writes to output
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities:
|
|
9
|
+
* - Buffer incoming mic audio and emit fixed-size chunks to main thread
|
|
10
|
+
* - Accept playback audio from main thread and enqueue into chunk queue
|
|
11
|
+
* - Provide "clear" support to flush the chunk queue on interruption
|
|
12
|
+
*
|
|
13
|
+
* Must be plain JavaScript -- AudioWorklet modules cannot be bundled by Vite.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// CONSTANTS
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
/** Number of mic samples to accumulate before posting to main thread */
|
|
21
|
+
const CHUNK_SIZE = 512;
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// PROCESSOR
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
class AudioProcessor extends AudioWorkletProcessor {
|
|
28
|
+
constructor() {
|
|
29
|
+
super();
|
|
30
|
+
|
|
31
|
+
// Mic capture buffer
|
|
32
|
+
this._micBuffer = new Float32Array(CHUNK_SIZE);
|
|
33
|
+
this._micBufferIndex = 0;
|
|
34
|
+
|
|
35
|
+
// Speaker playback chunk queue
|
|
36
|
+
this._chunks = [];
|
|
37
|
+
this._chunkIndex = 0; // read position within current chunk
|
|
38
|
+
|
|
39
|
+
// Handle messages from main thread
|
|
40
|
+
this.port.onmessage = (event) => {
|
|
41
|
+
const { type, samples } = event.data;
|
|
42
|
+
|
|
43
|
+
if (type === "playback" && samples) {
|
|
44
|
+
this._chunks.push(samples);
|
|
45
|
+
} else if (type === "clear") {
|
|
46
|
+
this._chunks.length = 0;
|
|
47
|
+
this._chunkIndex = 0;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read samples from the chunk queue into the output array.
|
|
54
|
+
* Writes silence (0) when the queue is empty.
|
|
55
|
+
*
|
|
56
|
+
* @param {Float32Array} output - Destination array to fill
|
|
57
|
+
*/
|
|
58
|
+
_readFromQueue(output) {
|
|
59
|
+
let written = 0;
|
|
60
|
+
|
|
61
|
+
while (written < output.length) {
|
|
62
|
+
if (this._chunks.length === 0) {
|
|
63
|
+
// Queue empty -- fill remaining with silence
|
|
64
|
+
for (let i = written; i < output.length; i++) {
|
|
65
|
+
output[i] = 0;
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const chunk = this._chunks[0];
|
|
71
|
+
const available = chunk.length - this._chunkIndex;
|
|
72
|
+
const needed = output.length - written;
|
|
73
|
+
const toCopy = Math.min(available, needed);
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < toCopy; i++) {
|
|
76
|
+
output[written++] = chunk[this._chunkIndex++];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (this._chunkIndex >= chunk.length) {
|
|
80
|
+
this._chunks.shift();
|
|
81
|
+
this._chunkIndex = 0;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Called by the audio rendering thread for each 128-sample frame.
|
|
88
|
+
*
|
|
89
|
+
* @param {Float32Array[][]} inputs - Input audio channels (mic)
|
|
90
|
+
* @param {Float32Array[][]} outputs - Output audio channels (speaker)
|
|
91
|
+
* @param {Record<string, Float32Array>} parameters - AudioParam values (unused)
|
|
92
|
+
* @returns {boolean} true to keep the processor alive
|
|
93
|
+
*/
|
|
94
|
+
process(inputs, outputs, parameters) {
|
|
95
|
+
// -- Mic capture: accumulate input samples and post chunks --
|
|
96
|
+
const input = inputs[0];
|
|
97
|
+
if (input && input[0]) {
|
|
98
|
+
const inputChannel = input[0];
|
|
99
|
+
for (let i = 0; i < inputChannel.length; i++) {
|
|
100
|
+
this._micBuffer[this._micBufferIndex++] = inputChannel[i];
|
|
101
|
+
|
|
102
|
+
if (this._micBufferIndex >= CHUNK_SIZE) {
|
|
103
|
+
// Post a copy to main thread
|
|
104
|
+
this.port.postMessage({
|
|
105
|
+
type: "audio",
|
|
106
|
+
samples: this._micBuffer.slice(),
|
|
107
|
+
});
|
|
108
|
+
this._micBufferIndex = 0;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// -- Speaker playback: read from chunk queue into output (mono -> all channels) --
|
|
114
|
+
const output = outputs[0];
|
|
115
|
+
if (output && output[0]) {
|
|
116
|
+
this._readFromQueue(output[0]);
|
|
117
|
+
for (let ch = 1; ch < output.length; ch++) {
|
|
118
|
+
output[ch].set(output[0]);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
registerProcessor("audio-processor", AudioProcessor);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Claude Voice</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-dAYfRls7.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BXemFrMp.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code authentication probe route.
|
|
3
|
+
*
|
|
4
|
+
* Runs `claude -p "hi" --output-format json` to check whether the user
|
|
5
|
+
* is authenticated. When logged out this returns instantly (~30ms) with
|
|
6
|
+
* exit code 1 and `is_error: true`. When logged in it starts an API call,
|
|
7
|
+
* so we use a short timeout and kill the process -- if it's still running
|
|
8
|
+
* after the deadline, the user is authenticated.
|
|
9
|
+
*
|
|
10
|
+
* - GET / -- probe and return { authenticated: boolean }
|
|
11
|
+
* - POST /login -- open Terminal.app with `claude` for interactive login
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Hono } from "hono";
|
|
15
|
+
import { execFile, spawn } from "child_process";
|
|
16
|
+
import { homedir } from "os";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// CONSTANTS
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
const CLAUDE_BIN = join(homedir(), ".local", "bin", "claude");
|
|
24
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// HELPER FUNCTIONS
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run `claude -p "hi" --output-format json` with stdin closed.
|
|
32
|
+
*
|
|
33
|
+
* - Logged out: exits 1 instantly (~30ms) with "Not logged in" in the JSON result.
|
|
34
|
+
* - Logged in: completes the API call and exits 0 with a JSON response.
|
|
35
|
+
* - Safety timeout at 5s: kills the process and assumes authenticated.
|
|
36
|
+
*
|
|
37
|
+
* @returns true if authenticated, false otherwise
|
|
38
|
+
*/
|
|
39
|
+
async function probeClaudeAuth(): Promise<boolean> {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
let resolved = false;
|
|
42
|
+
const done = (value: boolean) => {
|
|
43
|
+
if (resolved) return;
|
|
44
|
+
resolved = true;
|
|
45
|
+
resolve(value);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const child = spawn(CLAUDE_BIN, ["-p", "hi", "--output-format", "json"], {
|
|
49
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
child.kill();
|
|
54
|
+
done(true);
|
|
55
|
+
}, PROBE_TIMEOUT_MS);
|
|
56
|
+
|
|
57
|
+
let stdout = "";
|
|
58
|
+
child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
|
|
59
|
+
|
|
60
|
+
child.on("close", (code) => {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
|
|
63
|
+
if (code === 0) {
|
|
64
|
+
done(true);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const json = JSON.parse(stdout);
|
|
70
|
+
if (json.is_error && typeof json.result === "string" && json.result.includes("Not logged in")) {
|
|
71
|
+
done(false);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// couldn't parse JSON
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
done(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// ROUTES
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create Hono route group for Claude Code auth testing.
|
|
89
|
+
*
|
|
90
|
+
* @returns Hono instance with GET / route
|
|
91
|
+
*/
|
|
92
|
+
export function authRoutes(): Hono {
|
|
93
|
+
const app = new Hono();
|
|
94
|
+
|
|
95
|
+
app.get("/", async (c) => {
|
|
96
|
+
const authenticated = await probeClaudeAuth();
|
|
97
|
+
return c.json({ authenticated });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/** Open Terminal.app with `claude` for interactive login */
|
|
101
|
+
app.post("/login", async (c) => {
|
|
102
|
+
const script = `tell application "Terminal"
|
|
103
|
+
activate
|
|
104
|
+
do script "${CLAUDE_BIN}"
|
|
105
|
+
end tell`;
|
|
106
|
+
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
execFile("osascript", ["-e", script], (err) => {
|
|
109
|
+
if (err) {
|
|
110
|
+
resolve(c.json({ error: err.message }, 500));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
resolve(c.json({ success: true }));
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return app;
|
|
119
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser call server management API routes.
|
|
3
|
+
*
|
|
4
|
+
* Manages the lifecycle of the browser-server (direct WebSocket audio):
|
|
5
|
+
* - GET /status -- browser-server running state + tunnel URL
|
|
6
|
+
* - POST /start -- start tunnel + browser-server (rejects if Twilio server holds the port)
|
|
7
|
+
* - POST /stop -- stop browser-server + tunnel
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Hono } from "hono";
|
|
11
|
+
import { startBrowserCallServer, stopBrowserCallServer, getBrowserCallStatus, isBrowserCallRunning } from "../../services/browser-call-manager.js";
|
|
12
|
+
import { startTunnel, stopTunnel, getTunnelUrl, isTunnelRunning } from "../../services/tunnel.js";
|
|
13
|
+
import { readEnv } from "../../services/env.js";
|
|
14
|
+
import { isRunning as isTwilioRunning } from "../../services/twilio-manager.js";
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// STATE
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
/** Dashboard port -- set by server.ts when calling setDashboardPort */
|
|
21
|
+
let dashboardPort = 0;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Set the dashboard port for browser-server proxying.
|
|
25
|
+
* Called by server.ts after the Hono server starts listening.
|
|
26
|
+
*
|
|
27
|
+
* @param port - The dashboard server port
|
|
28
|
+
*/
|
|
29
|
+
export function setDashboardPort(port: number): void {
|
|
30
|
+
dashboardPort = port;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// ROUTES
|
|
35
|
+
// ============================================================================
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create Hono route group for browser call operations.
|
|
39
|
+
*
|
|
40
|
+
* @returns Hono instance with status, start, stop routes
|
|
41
|
+
*/
|
|
42
|
+
export function browserCallRoutes(): Hono {
|
|
43
|
+
const app = new Hono();
|
|
44
|
+
|
|
45
|
+
/** Get browser call server status + tunnel URL */
|
|
46
|
+
app.get("/status", (c) => {
|
|
47
|
+
const status = getBrowserCallStatus();
|
|
48
|
+
return c.json({ ...status, tunnelUrl: getTunnelUrl() });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** Start tunnel + browser call server */
|
|
52
|
+
app.post("/start", async (c) => {
|
|
53
|
+
try {
|
|
54
|
+
// Port conflict check: Twilio server uses the same port
|
|
55
|
+
if (isTwilioRunning()) {
|
|
56
|
+
return c.json({ error: "Twilio server is already running on this port" }, 409);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const envVars = await readEnv();
|
|
60
|
+
const port = parseInt(envVars.TWILIO_PORT || "8080", 10);
|
|
61
|
+
|
|
62
|
+
if (!isTunnelRunning()) {
|
|
63
|
+
await startTunnel(port);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!isBrowserCallRunning()) {
|
|
67
|
+
await startBrowserCallServer(dashboardPort);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return c.json({ success: true });
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const message = err instanceof Error ? err.message : "Failed to start";
|
|
73
|
+
return c.json({ error: message }, 500);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** Stop browser call server. Only stops tunnel if Twilio is also stopped. */
|
|
78
|
+
app.post("/stop", (c) => {
|
|
79
|
+
stopBrowserCallServer();
|
|
80
|
+
if (!isTwilioRunning()) {
|
|
81
|
+
stopTunnel();
|
|
82
|
+
}
|
|
83
|
+
return c.json({ success: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return app;
|
|
87
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLAUDE.md read/write API routes.
|
|
3
|
+
*
|
|
4
|
+
* Exposes endpoints to read and write the project's CLAUDE.md file:
|
|
5
|
+
* - GET / -- read the current CLAUDE.md content
|
|
6
|
+
* - POST / -- write new content to CLAUDE.md
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Hono } from "hono";
|
|
10
|
+
import { readFile, writeFile } from "fs/promises";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// CONSTANTS
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
const CLAUDE_MD_PATH = join(process.cwd(), "CLAUDE.md");
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// ROUTES
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create Hono route group for CLAUDE.md operations.
|
|
25
|
+
*
|
|
26
|
+
* @returns Hono instance with GET / and POST / routes
|
|
27
|
+
*/
|
|
28
|
+
export function claudeMdRoutes(): Hono {
|
|
29
|
+
const app = new Hono();
|
|
30
|
+
|
|
31
|
+
/** Read the CLAUDE.md file */
|
|
32
|
+
app.get("/", async (c) => {
|
|
33
|
+
const content = await readFile(CLAUDE_MD_PATH, "utf-8");
|
|
34
|
+
return c.json({ content });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Write new content to CLAUDE.md */
|
|
38
|
+
app.post("/", async (c) => {
|
|
39
|
+
const body = await c.req.json<{ content?: string }>();
|
|
40
|
+
|
|
41
|
+
if (body.content === undefined) {
|
|
42
|
+
return c.json({ error: "Missing 'content' in request body" }, 400);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await writeFile(CLAUDE_MD_PATH, body.content, "utf-8");
|
|
46
|
+
return c.json({ success: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return app;
|
|
50
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation session API routes.
|
|
3
|
+
*
|
|
4
|
+
* Lists and reads Claude Code conversation sessions from JSONL log files:
|
|
5
|
+
* - GET / -- list all sessions with summaries
|
|
6
|
+
* - GET /:sessionId -- get all messages for a specific session
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Hono } from "hono";
|
|
10
|
+
import { readdir, stat, access } from "fs/promises";
|
|
11
|
+
import { join, basename } from "path";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { createReadStream } from "fs";
|
|
14
|
+
import { createInterface } from "readline";
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// TYPES
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
/** Summary of a conversation session */
|
|
21
|
+
interface ConversationSummary {
|
|
22
|
+
sessionId: string;
|
|
23
|
+
firstMessage: string;
|
|
24
|
+
timestamp: string;
|
|
25
|
+
messageCount: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A single conversation turn */
|
|
29
|
+
interface ConversationMessage {
|
|
30
|
+
role: "user" | "assistant";
|
|
31
|
+
content: string;
|
|
32
|
+
timestamp: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// CONSTANTS
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
/** Claude Code encodes the project path by replacing "/" with "-" */
|
|
40
|
+
const PROJECT_DIR_NAME = process.cwd().replace(/\//g, "-");
|
|
41
|
+
const SESSIONS_DIR = join(homedir(), ".claude", "projects", PROJECT_DIR_NAME);
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// ROUTES
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create Hono route group for conversation operations.
|
|
49
|
+
*
|
|
50
|
+
* @returns Hono instance with GET / (list) and GET /:sessionId (detail)
|
|
51
|
+
*/
|
|
52
|
+
export function conversationRoutes(): Hono {
|
|
53
|
+
const app = new Hono();
|
|
54
|
+
|
|
55
|
+
/** List all conversation sessions with summaries */
|
|
56
|
+
app.get("/", async (c) => {
|
|
57
|
+
const files = await readdir(SESSIONS_DIR);
|
|
58
|
+
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
|
|
59
|
+
|
|
60
|
+
const summaries: ConversationSummary[] = [];
|
|
61
|
+
|
|
62
|
+
for (const file of jsonlFiles) {
|
|
63
|
+
const filePath = join(SESSIONS_DIR, file);
|
|
64
|
+
const fileStat = await stat(filePath);
|
|
65
|
+
const sessionId = basename(file, ".jsonl");
|
|
66
|
+
|
|
67
|
+
const { firstUserMessage, messageCount } = await extractSessionSummary(filePath);
|
|
68
|
+
|
|
69
|
+
summaries.push({
|
|
70
|
+
sessionId,
|
|
71
|
+
firstMessage: firstUserMessage,
|
|
72
|
+
timestamp: fileStat.mtime.toISOString(),
|
|
73
|
+
messageCount,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
summaries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
78
|
+
return c.json(summaries);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** Get all messages for a specific session */
|
|
82
|
+
app.get("/:sessionId", async (c) => {
|
|
83
|
+
const sessionId = c.req.param("sessionId");
|
|
84
|
+
const filePath = join(SESSIONS_DIR, `${sessionId}.jsonl`);
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
await access(filePath);
|
|
88
|
+
} catch {
|
|
89
|
+
return c.json({ error: "Session not found" }, 404);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const messages = await parseSessionMessages(filePath);
|
|
93
|
+
return c.json(messages);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return app;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ============================================================================
|
|
100
|
+
// HELPER FUNCTIONS
|
|
101
|
+
// ============================================================================
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read the first user message and count total messages in a session file.
|
|
105
|
+
*
|
|
106
|
+
* @param filePath - Absolute path to the JSONL file
|
|
107
|
+
* @returns The first user message text and total message count
|
|
108
|
+
*/
|
|
109
|
+
async function extractSessionSummary(filePath: string): Promise<{ firstUserMessage: string; messageCount: number }> {
|
|
110
|
+
let firstUserMessage = "(empty)";
|
|
111
|
+
let messageCount = 0;
|
|
112
|
+
let foundFirst = false;
|
|
113
|
+
|
|
114
|
+
const rl = createInterface({ input: createReadStream(filePath, "utf-8"), crlfDelay: Infinity });
|
|
115
|
+
|
|
116
|
+
for await (const line of rl) {
|
|
117
|
+
if (!line.trim()) continue;
|
|
118
|
+
try {
|
|
119
|
+
const entry = JSON.parse(line);
|
|
120
|
+
if (entry.type !== "user" && entry.type !== "assistant") continue;
|
|
121
|
+
|
|
122
|
+
messageCount++;
|
|
123
|
+
|
|
124
|
+
if (!foundFirst && entry.type === "user") {
|
|
125
|
+
const content = entry.message?.content;
|
|
126
|
+
if (typeof content === "string") {
|
|
127
|
+
firstUserMessage = content.slice(0, 120);
|
|
128
|
+
}
|
|
129
|
+
foundFirst = true;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// Skip malformed lines
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { firstUserMessage, messageCount };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parse all user and assistant messages from a session JSONL file.
|
|
141
|
+
* Deduplicates assistant messages by requestId.
|
|
142
|
+
*
|
|
143
|
+
* @param filePath - Absolute path to the JSONL file
|
|
144
|
+
* @returns Array of ConversationMessage sorted by timestamp
|
|
145
|
+
*/
|
|
146
|
+
async function parseSessionMessages(filePath: string): Promise<ConversationMessage[]> {
|
|
147
|
+
const messages: ConversationMessage[] = [];
|
|
148
|
+
const seenUserUuids = new Set<string>();
|
|
149
|
+
const assistantTexts = new Map<string, { text: string; timestamp: string }>();
|
|
150
|
+
|
|
151
|
+
const rl = createInterface({ input: createReadStream(filePath, "utf-8"), crlfDelay: Infinity });
|
|
152
|
+
|
|
153
|
+
for await (const line of rl) {
|
|
154
|
+
if (!line.trim()) continue;
|
|
155
|
+
try {
|
|
156
|
+
const entry = JSON.parse(line);
|
|
157
|
+
|
|
158
|
+
if (entry.type === "user" && entry.message?.role === "user") {
|
|
159
|
+
if (seenUserUuids.has(entry.uuid)) continue;
|
|
160
|
+
seenUserUuids.add(entry.uuid);
|
|
161
|
+
|
|
162
|
+
const content = entry.message.content;
|
|
163
|
+
if (typeof content === "string" && content.trim()) {
|
|
164
|
+
messages.push({ role: "user", content, timestamp: entry.timestamp });
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (entry.type === "assistant" && entry.message?.content) {
|
|
170
|
+
const requestId = entry.requestId;
|
|
171
|
+
if (!requestId) continue;
|
|
172
|
+
|
|
173
|
+
const blocks = entry.message.content;
|
|
174
|
+
if (!Array.isArray(blocks)) continue;
|
|
175
|
+
|
|
176
|
+
const textParts: string[] = [];
|
|
177
|
+
for (const block of blocks) {
|
|
178
|
+
if (block.type === "text" && block.text?.trim()) {
|
|
179
|
+
textParts.push(block.text);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (textParts.length > 0) {
|
|
184
|
+
const combined = textParts.join("");
|
|
185
|
+
const existing = assistantTexts.get(requestId);
|
|
186
|
+
if (!existing || combined.length > existing.text.length) {
|
|
187
|
+
assistantTexts.set(requestId, { text: combined, timestamp: entry.timestamp });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// Skip malformed lines
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const [, { text, timestamp }] of assistantTexts) {
|
|
198
|
+
messages.push({ role: "assistant", content: text, timestamp });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
202
|
+
return messages;
|
|
203
|
+
}
|