tracegist-mcp-bridge 0.2.4 → 0.2.6
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/tracegist-mcp-bridge.mjs +172 -10
- package/package.json +1 -1
|
@@ -848,9 +848,14 @@ server.registerTool(
|
|
|
848
848
|
const LIVE_SHADOW_PORT = 19384;
|
|
849
849
|
const LIVE_SHADOW_PORT_RANGE = 5;
|
|
850
850
|
const LIVE_SHADOW_EVENT_BUFFER_SIZE = 2000;
|
|
851
|
+
const PENDING_QUESTIONS_MAX = 50;
|
|
852
|
+
const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions";
|
|
853
|
+
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || "";
|
|
854
|
+
const LIVE_TRANSCRIPTION_MODEL = "openai/gpt-audio-mini";
|
|
851
855
|
|
|
852
856
|
/** @type {Array<{seq: number, ts: number, eventType: string, data: object}>} */
|
|
853
857
|
const liveEvents = [];
|
|
858
|
+
let liveEventSeq = 0;
|
|
854
859
|
let liveSessionActive = false;
|
|
855
860
|
/** @type {{sessionId: string, url: string, title: string, startedAt: number} | null} */
|
|
856
861
|
let liveSessionMeta = null;
|
|
@@ -864,8 +869,97 @@ const questionResponses = new Map();
|
|
|
864
869
|
/** @type {Array<{resolve: Function, reject: Function, timeout: ReturnType<typeof setTimeout>}>} */
|
|
865
870
|
const screenshotWaiters = [];
|
|
866
871
|
|
|
872
|
+
/**
|
|
873
|
+
* Transcribe a voice data URL using OpenRouter API or local Whisper fallback.
|
|
874
|
+
* @param {string} voiceBlobDataUrl - data:audio/...;base64,... URL
|
|
875
|
+
* @returns {Promise<{transcription?: string, error?: string}>}
|
|
876
|
+
*/
|
|
877
|
+
async function transcribeLiveVoice(voiceBlobDataUrl) {
|
|
878
|
+
// Try OpenRouter API first
|
|
879
|
+
if (OPENROUTER_API_KEY) {
|
|
880
|
+
try {
|
|
881
|
+
const match = voiceBlobDataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
|
882
|
+
if (!match) return { error: "Invalid voice data URL format" };
|
|
883
|
+
|
|
884
|
+
const mimeType = match[1];
|
|
885
|
+
const base64Data = match[2];
|
|
886
|
+
const format = mimeType.includes("wav") ? "wav" : mimeType.includes("mp3") ? "mp3" : "webm";
|
|
887
|
+
|
|
888
|
+
const response = await fetch(OPENROUTER_API_URL, {
|
|
889
|
+
method: "POST",
|
|
890
|
+
headers: {
|
|
891
|
+
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
|
|
892
|
+
"Content-Type": "application/json",
|
|
893
|
+
"HTTP-Referer": "npm:tracegist-mcp-bridge",
|
|
894
|
+
"X-Title": "TraceGist MCP Bridge",
|
|
895
|
+
},
|
|
896
|
+
body: JSON.stringify({
|
|
897
|
+
model: LIVE_TRANSCRIPTION_MODEL,
|
|
898
|
+
messages: [
|
|
899
|
+
{
|
|
900
|
+
role: "user",
|
|
901
|
+
content: [
|
|
902
|
+
{
|
|
903
|
+
type: "text",
|
|
904
|
+
text: "Transcribe this voice note verbatim. Return only the transcription text, no commentary.",
|
|
905
|
+
},
|
|
906
|
+
{ type: "input_audio", input_audio: { data: base64Data, format } },
|
|
907
|
+
],
|
|
908
|
+
},
|
|
909
|
+
],
|
|
910
|
+
temperature: 0,
|
|
911
|
+
}),
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
if (!response.ok) {
|
|
915
|
+
const errorText = await response.text();
|
|
916
|
+
return { error: `OpenRouter API error (${response.status}): ${errorText}` };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const result = await response.json();
|
|
920
|
+
const text = result.choices?.[0]?.message?.content?.trim();
|
|
921
|
+
return text ? { transcription: text } : { error: "Empty transcription response" };
|
|
922
|
+
} catch (err) {
|
|
923
|
+
return { error: `OpenRouter transcription failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Fallback to local Whisper
|
|
928
|
+
if (whisperDependencyWarnings.length === 0) {
|
|
929
|
+
try {
|
|
930
|
+
const match = voiceBlobDataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
|
931
|
+
if (!match) return { error: "Invalid voice data URL format" };
|
|
932
|
+
|
|
933
|
+
const buffer = Buffer.from(match[2], "base64");
|
|
934
|
+
const ext = match[1].includes("wav") ? "wav" : match[1].includes("mp3") ? "mp3" : "webm";
|
|
935
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tracegist-live-whisper-"));
|
|
936
|
+
const tmpPath = path.join(tmpDir, `voice.${ext}`);
|
|
937
|
+
await fs.writeFile(tmpPath, buffer);
|
|
938
|
+
try {
|
|
939
|
+
const text = await transcribeWithLocalWhisper(tmpPath, "base", undefined);
|
|
940
|
+
return { transcription: text };
|
|
941
|
+
} finally {
|
|
942
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
943
|
+
}
|
|
944
|
+
} catch (err) {
|
|
945
|
+
return { error: `Whisper transcription failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
return { error: "No transcription available (set OPENROUTER_API_KEY or install Whisper)" };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** Push a live event into the ring buffer with auto-incrementing seq. */
|
|
953
|
+
function pushLiveEvent(eventType, data) {
|
|
954
|
+
liveEventSeq++;
|
|
955
|
+
liveEvents.push({ seq: liveEventSeq, ts: Date.now(), eventType, data });
|
|
956
|
+
if (liveEvents.length > LIVE_SHADOW_EVENT_BUFFER_SIZE) {
|
|
957
|
+
const evictCount = Math.floor(LIVE_SHADOW_EVENT_BUFFER_SIZE * 0.25);
|
|
958
|
+
liveEvents.splice(0, evictCount);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
867
962
|
function startLiveShadowServer() {
|
|
868
|
-
let port = LIVE_SHADOW_PORT;
|
|
869
963
|
let attempts = 0;
|
|
870
964
|
|
|
871
965
|
function tryPort(p) {
|
|
@@ -899,6 +993,11 @@ function startLiveShadowServer() {
|
|
|
899
993
|
if (liveSessionActive) {
|
|
900
994
|
liveSessionActive = false;
|
|
901
995
|
console.error(`[${BRIDGE_NAME}] Live shadow: session ended (extension disconnected)`);
|
|
996
|
+
try {
|
|
997
|
+
server.sendResourceListChanged();
|
|
998
|
+
} catch {
|
|
999
|
+
// Not all transports support notifications
|
|
1000
|
+
}
|
|
902
1001
|
}
|
|
903
1002
|
}
|
|
904
1003
|
});
|
|
@@ -918,11 +1017,13 @@ function startLiveShadowServer() {
|
|
|
918
1017
|
});
|
|
919
1018
|
|
|
920
1019
|
httpServer.listen(p, "127.0.0.1", () => {
|
|
921
|
-
console.error(
|
|
1020
|
+
console.error(
|
|
1021
|
+
`[${BRIDGE_NAME}] Live shadow WebSocket server listening on ws://127.0.0.1:${p}`,
|
|
1022
|
+
);
|
|
922
1023
|
});
|
|
923
1024
|
}
|
|
924
1025
|
|
|
925
|
-
tryPort(
|
|
1026
|
+
tryPort(LIVE_SHADOW_PORT);
|
|
926
1027
|
}
|
|
927
1028
|
|
|
928
1029
|
function handleExtensionMessage(msg) {
|
|
@@ -955,11 +1056,13 @@ function handleExtensionMessage(msg) {
|
|
|
955
1056
|
data: msg.data,
|
|
956
1057
|
});
|
|
957
1058
|
if (liveEvents.length > LIVE_SHADOW_EVENT_BUFFER_SIZE) {
|
|
958
|
-
|
|
1059
|
+
// Batch-evict oldest 25% to amortize the O(n) splice cost
|
|
1060
|
+
const evictCount = Math.floor(LIVE_SHADOW_EVENT_BUFFER_SIZE * 0.25);
|
|
1061
|
+
liveEvents.splice(0, evictCount);
|
|
959
1062
|
}
|
|
960
1063
|
break;
|
|
961
1064
|
|
|
962
|
-
case "question-response":
|
|
1065
|
+
case "question-response": {
|
|
963
1066
|
questionResponses.set(msg.questionId, {
|
|
964
1067
|
questionId: msg.questionId,
|
|
965
1068
|
timestamp: msg.timestamp,
|
|
@@ -967,11 +1070,17 @@ function handleExtensionMessage(msg) {
|
|
|
967
1070
|
screenshot: msg.screenshot || null,
|
|
968
1071
|
highlightCaptures: msg.highlightCaptures || [],
|
|
969
1072
|
});
|
|
1073
|
+
const rIdx = pendingQuestions.findIndex((q) => q.questionId === msg.questionId);
|
|
1074
|
+
if (rIdx >= 0) pendingQuestions.splice(rIdx, 1);
|
|
970
1075
|
break;
|
|
1076
|
+
}
|
|
971
1077
|
|
|
972
|
-
case "question-dismissed":
|
|
1078
|
+
case "question-dismissed": {
|
|
973
1079
|
questionResponses.set(msg.questionId, { dismissed: true });
|
|
1080
|
+
const dIdx = pendingQuestions.findIndex((q) => q.questionId === msg.questionId);
|
|
1081
|
+
if (dIdx >= 0) pendingQuestions.splice(dIdx, 1);
|
|
974
1082
|
break;
|
|
1083
|
+
}
|
|
975
1084
|
|
|
976
1085
|
case "screenshot-response":
|
|
977
1086
|
// Resolve any pending screenshot waiters
|
|
@@ -981,9 +1090,35 @@ function handleExtensionMessage(msg) {
|
|
|
981
1090
|
}
|
|
982
1091
|
break;
|
|
983
1092
|
|
|
1093
|
+
case "marker-voice": {
|
|
1094
|
+
const { markerId, voiceBlobDataUrl } = msg;
|
|
1095
|
+
transcribeLiveVoice(voiceBlobDataUrl)
|
|
1096
|
+
.then((result) => {
|
|
1097
|
+
pushLiveEvent("voice-transcription", {
|
|
1098
|
+
markerId,
|
|
1099
|
+
transcription: result.transcription || null,
|
|
1100
|
+
error: result.error || undefined,
|
|
1101
|
+
});
|
|
1102
|
+
})
|
|
1103
|
+
.catch((err) => {
|
|
1104
|
+
console.error(`[${BRIDGE_NAME}] Live voice transcription failed:`, err);
|
|
1105
|
+
pushLiveEvent("voice-transcription", {
|
|
1106
|
+
markerId,
|
|
1107
|
+
transcription: null,
|
|
1108
|
+
error: String(err),
|
|
1109
|
+
});
|
|
1110
|
+
});
|
|
1111
|
+
break;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
984
1114
|
case "session-end":
|
|
985
1115
|
liveSessionActive = false;
|
|
986
1116
|
console.error(`[${BRIDGE_NAME}] Live shadow: session ended`);
|
|
1117
|
+
try {
|
|
1118
|
+
server.sendResourceListChanged();
|
|
1119
|
+
} catch {
|
|
1120
|
+
// Not all transports support notifications
|
|
1121
|
+
}
|
|
987
1122
|
break;
|
|
988
1123
|
}
|
|
989
1124
|
}
|
|
@@ -1048,6 +1183,22 @@ server.tool(
|
|
|
1048
1183
|
events,
|
|
1049
1184
|
latestSeq,
|
|
1050
1185
|
eventCount: events.length,
|
|
1186
|
+
...(liveSessionActive
|
|
1187
|
+
? {
|
|
1188
|
+
availableActions: [
|
|
1189
|
+
{
|
|
1190
|
+
tool: "ask_tester_question",
|
|
1191
|
+
description:
|
|
1192
|
+
"Ask the tester a short question (max 200 chars). They respond with voice + highlights.",
|
|
1193
|
+
},
|
|
1194
|
+
{
|
|
1195
|
+
tool: "get_live_screenshot",
|
|
1196
|
+
description:
|
|
1197
|
+
"Capture a screenshot of the tester's current browser tab.",
|
|
1198
|
+
},
|
|
1199
|
+
],
|
|
1200
|
+
}
|
|
1201
|
+
: {}),
|
|
1051
1202
|
},
|
|
1052
1203
|
null,
|
|
1053
1204
|
2,
|
|
@@ -1093,6 +1244,9 @@ server.tool(
|
|
|
1093
1244
|
}
|
|
1094
1245
|
|
|
1095
1246
|
pendingQuestions.push({ questionId, question, sentAt: Date.now() });
|
|
1247
|
+
while (pendingQuestions.length > PENDING_QUESTIONS_MAX) {
|
|
1248
|
+
pendingQuestions.shift();
|
|
1249
|
+
}
|
|
1096
1250
|
|
|
1097
1251
|
return {
|
|
1098
1252
|
content: [
|
|
@@ -1191,10 +1345,18 @@ server.tool(
|
|
|
1191
1345
|
}
|
|
1192
1346
|
|
|
1193
1347
|
if (response.voiceBlobDataUrl) {
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1348
|
+
const voiceResult = await transcribeLiveVoice(response.voiceBlobDataUrl);
|
|
1349
|
+
if (voiceResult.transcription) {
|
|
1350
|
+
content.push({
|
|
1351
|
+
type: "text",
|
|
1352
|
+
text: `Voice transcription:\n${voiceResult.transcription}`,
|
|
1353
|
+
});
|
|
1354
|
+
} else {
|
|
1355
|
+
content.push({
|
|
1356
|
+
type: "text",
|
|
1357
|
+
text: `Voice note is attached but could not be transcribed${voiceResult.error ? ` (${voiceResult.error})` : ""}. Set OPENROUTER_API_KEY or install local Whisper for transcription.`,
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1198
1360
|
}
|
|
1199
1361
|
|
|
1200
1362
|
// Clean up after retrieval
|