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.
@@ -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(`[${BRIDGE_NAME}] Live shadow WebSocket server listening on ws://127.0.0.1:${p}`);
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(port);
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
- liveEvents.shift();
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
- content.push({
1195
- type: "text",
1196
- text: "Voice note is attached. Use the transcribe_tracegist_package_voice_notes tool or listen locally.",
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tracegist-mcp-bridge",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Local-first MCP bridge for reading and transcribing TraceGist package zips.",
5
5
  "type": "module",
6
6
  "bin": {