tracegist-mcp-bridge 0.2.3 → 0.2.5

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/lib.mjs CHANGED
@@ -79,9 +79,10 @@ const SECTION_HINTS = {
79
79
  "backend log correlation": "Absolute timestamps for server-side log alignment",
80
80
  "full session console timeline": "All console output (deep exports only)",
81
81
  "full session network timeline": "All network requests (deep exports only)",
82
- "full session network timeline (third-party only)": "Third-party network calls (deep exports only)",
82
+ "full session network timeline (third-party only)":
83
+ "Third-party network calls (deep exports only)",
83
84
  "full session timelines": "Placeholder when deep timelines are omitted",
84
- "context": "AI-generated analysis context (when available)",
85
+ context: "AI-generated analysis context (when available)",
85
86
  "tracegist agent-processed context": "LLM-processed summary of the session",
86
87
  "tester intent": "What the tester was trying to accomplish",
87
88
  "handover tasks for coding agent": "Specific tasks the tester wants the agent to do",
@@ -3,8 +3,11 @@
3
3
  import fs from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
+ import crypto from "node:crypto";
6
7
  import { execFile } from "node:child_process";
7
8
  import { promisify } from "node:util";
9
+ import { createServer } from "node:http";
10
+ import { WebSocketServer } from "ws";
8
11
  import JSZip from "jszip";
9
12
  import { z } from "zod";
10
13
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -338,10 +341,10 @@ server.registerPrompt(
338
341
  "",
339
342
  "Best when the bug is clear from logs/screenshots and you can identify the root cause from the trace alone.",
340
343
  "",
341
- '1. Read **Session Triage** — the tester\'s summary: what was expected, what happened, severity.',
342
- '2. Read **Marker Timeline** — timestamped markers with ±5 s event windows (errors, network, interactions).',
343
- '3. Read **Notable Anomalies** — pre-flagged JS errors, failed requests, and console warnings.',
344
- '4. If needed: **Full Session Console/Network Timelines** (deep exports only), **Session Environment**.',
344
+ "1. Read **Session Triage** — the tester's summary: what was expected, what happened, severity.",
345
+ "2. Read **Marker Timeline** — timestamped markers with ±5 s event windows (errors, network, interactions).",
346
+ "3. Read **Notable Anomalies** — pre-flagged JS errors, failed requests, and console warnings.",
347
+ "4. If needed: **Full Session Console/Network Timelines** (deep exports only), **Session Environment**.",
345
348
  "5. Diagnose and fix directly in the codebase.",
346
349
  "",
347
350
  "### Path B: Reproduce first, then develop against the reproduction",
@@ -351,8 +354,8 @@ server.registerPrompt(
351
354
  "1. `get_tracegist_package_overview` — get the manifest. Note `playwrightScriptPath`.",
352
355
  "2. `read_tracegist_package_file({ zipPath, entryName: manifest.playwrightScriptPath })` — read the",
353
356
  " auto-generated Playwright repro script (click/fill steps derived from the interaction log).",
354
- '3. Read **Session Environment** — browser, viewport, URL, OS. Match your local setup.',
355
- '4. Read **User Interaction Timeline** — the full sequence of user actions to understand the reproduction flow.',
357
+ "3. Read **Session Environment** — browser, viewport, URL, OS. Match your local setup.",
358
+ "4. Read **User Interaction Timeline** — the full sequence of user actions to understand the reproduction flow.",
356
359
  "5. Extract and run the Playwright script:",
357
360
  " - `extract_tracegist_package_file` to get the script + config to disk.",
358
361
  " - The script needs authentication handled separately (storageState or global setup).",
@@ -382,6 +385,25 @@ server.registerPrompt(
382
385
  " (returned by `get_tracegist_package_overview`). Read it with:",
383
386
  " `read_tracegist_package_file({ zipPath, entryName: manifest.playwrightScriptPath })`.",
384
387
  " Contains click/fill steps derived from the interaction log — use as a starting point for automated reproduction.",
388
+ "",
389
+ "## Live Shadowing Mode (Real-Time Collaboration)",
390
+ "",
391
+ "If a live session is active, you can collaborate with the tester in real-time:",
392
+ "",
393
+ "1. `watch_live_session` — Poll for recent session events (console, network, errors, interactions).",
394
+ " Call every 3-5 seconds. Returns `sessionActive: false` when the session ends.",
395
+ "2. `get_live_screenshot` — Capture the current tab state.",
396
+ "3. `ask_tester_question` — Ask the tester a short question (max 200 chars).",
397
+ " The question appears as a notification in their browser.",
398
+ " They respond with a voice marker (Alt+Shift+M) and optional highlight captures (Alt+Shift+S).",
399
+ "4. `get_tester_response` — Check for the tester's answer (includes voice note, screenshots, highlights).",
400
+ "",
401
+ "Tips:",
402
+ "- Keep questions short and actionable ('Can you click the Save button again?')",
403
+ "- Wait for the tester to respond before asking another question",
404
+ "- Use `get_live_screenshot` to see what they're looking at",
405
+ "- Watch for errors, 5xx responses, and console.error entries in the live event stream",
406
+ "- Stop polling when `sessionActive` is false",
385
407
  ].join("\n"),
386
408
  },
387
409
  },
@@ -402,115 +424,125 @@ server.registerTool(
402
424
  },
403
425
  inputSchema: z.object({
404
426
  zipPath: z.string().describe("Absolute path to the TraceGist package zip file."),
405
- model: z.string().optional().describe('Whisper model size (default: "base"). Options: tiny, base, small, medium, large.'),
406
- language: z.string().optional().describe("ISO 639-1 language code (e.g. en, de, ja). Omit for auto-detection."),
427
+ model: z
428
+ .string()
429
+ .optional()
430
+ .describe(
431
+ 'Whisper model size (default: "base"). Options: tiny, base, small, medium, large.',
432
+ ),
433
+ language: z
434
+ .string()
435
+ .optional()
436
+ .describe("ISO 639-1 language code (e.g. en, de, ja). Omit for auto-detection."),
407
437
  }),
408
438
  },
409
439
  async ({ zipPath, model = "base", language }) => {
410
440
  try {
411
- if (whisperDependencyWarnings.length > 0) {
412
- return {
413
- content: [
414
- {
415
- type: "text",
416
- text: JSON.stringify(
417
- {
418
- zipPath,
419
- model,
420
- language: language || null,
421
- transcriptions: [],
422
- failures: [],
423
- warning:
424
- "Local Whisper dependencies are missing. Fix the dependency warnings and retry transcription.",
425
- dependencyWarnings: whisperDependencyWarnings,
426
- },
427
- null,
428
- 2,
429
- ),
430
- },
431
- ],
432
- isError: true,
433
- };
434
- }
435
-
436
- const { zip, entryNames } = await readZipEntries(zipPath);
437
- const voiceEntries = entryNames.filter((entryName) => looksLikeVoiceNote(entryName));
438
- if (voiceEntries.length === 0) {
439
- return {
440
- content: [
441
- {
442
- type: "text",
443
- text: JSON.stringify(
444
- {
445
- zipPath,
446
- model,
447
- language: language || null,
448
- transcriptions: [],
449
- warning: "No voice-note files found in package.",
450
- },
451
- null,
452
- 2,
453
- ),
454
- },
455
- ],
456
- };
457
- }
441
+ if (whisperDependencyWarnings.length > 0) {
442
+ return {
443
+ content: [
444
+ {
445
+ type: "text",
446
+ text: JSON.stringify(
447
+ {
448
+ zipPath,
449
+ model,
450
+ language: language || null,
451
+ transcriptions: [],
452
+ failures: [],
453
+ warning:
454
+ "Local Whisper dependencies are missing. Fix the dependency warnings and retry transcription.",
455
+ dependencyWarnings: whisperDependencyWarnings,
456
+ },
457
+ null,
458
+ 2,
459
+ ),
460
+ },
461
+ ],
462
+ isError: true,
463
+ };
464
+ }
458
465
 
459
- const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tracegist-whisper-"));
460
- const transcriptions = [];
461
- const failures = [];
462
- try {
463
- // Extract all audio files to disk first (fast I/O, safe to parallelize fully)
464
- const extracted = [];
465
- for (const entryName of voiceEntries) {
466
- const entry = zip.file(entryName);
467
- if (!entry) {
468
- failures.push({ entryName, error: "Entry missing from zip" });
469
- continue;
470
- }
471
- const entryDir = path.join(tmpRoot, String(extracted.length));
472
- await fs.mkdir(entryDir, { recursive: true });
473
- const outputPath = path.join(entryDir, path.basename(entryName));
474
- const data = await entry.async("nodebuffer");
475
- await fs.writeFile(outputPath, data);
476
- extracted.push({ entryName, outputPath });
466
+ const { zip, entryNames } = await readZipEntries(zipPath);
467
+ const voiceEntries = entryNames.filter((entryName) => looksLikeVoiceNote(entryName));
468
+ if (voiceEntries.length === 0) {
469
+ return {
470
+ content: [
471
+ {
472
+ type: "text",
473
+ text: JSON.stringify(
474
+ {
475
+ zipPath,
476
+ model,
477
+ language: language || null,
478
+ transcriptions: [],
479
+ warning: "No voice-note files found in package.",
480
+ },
481
+ null,
482
+ 2,
483
+ ),
484
+ },
485
+ ],
486
+ };
477
487
  }
478
488
 
479
- // Transcribe with bounded concurrency (Whisper is CPU/GPU-heavy)
480
- await mapConcurrent(
481
- extracted,
482
- async ({ entryName, outputPath }) => {
483
- try {
484
- const transcript = await transcribeWithLocalWhisper(outputPath, model, language);
485
- transcriptions.push({ entryName, transcript });
486
- } catch (err) {
487
- failures.push({
488
- entryName,
489
- error: err instanceof Error ? err.message : String(err),
490
- });
489
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tracegist-whisper-"));
490
+ const transcriptions = [];
491
+ const failures = [];
492
+ try {
493
+ // Extract all audio files to disk first (fast I/O, safe to parallelize fully)
494
+ const extracted = [];
495
+ for (const entryName of voiceEntries) {
496
+ const entry = zip.file(entryName);
497
+ if (!entry) {
498
+ failures.push({ entryName, error: "Entry missing from zip" });
499
+ continue;
491
500
  }
492
- },
493
- WHISPER_CONCURRENCY,
494
- );
495
- } finally {
496
- await fs.rm(tmpRoot, { recursive: true, force: true });
497
- }
501
+ const entryDir = path.join(tmpRoot, String(extracted.length));
502
+ await fs.mkdir(entryDir, { recursive: true });
503
+ const outputPath = path.join(entryDir, path.basename(entryName));
504
+ const data = await entry.async("nodebuffer");
505
+ await fs.writeFile(outputPath, data);
506
+ extracted.push({ entryName, outputPath });
507
+ }
498
508
 
499
- const result = {
500
- zipPath,
501
- model,
502
- language: language || null,
503
- voiceFileCount: voiceEntries.length,
504
- transcribedCount: transcriptions.length,
505
- transcriptions,
506
- failures,
507
- };
509
+ // Transcribe with bounded concurrency (Whisper is CPU/GPU-heavy)
510
+ await mapConcurrent(
511
+ extracted,
512
+ async ({ entryName, outputPath }) => {
513
+ try {
514
+ const transcript = await transcribeWithLocalWhisper(outputPath, model, language);
515
+ transcriptions.push({ entryName, transcript });
516
+ } catch (err) {
517
+ failures.push({
518
+ entryName,
519
+ error: err instanceof Error ? err.message : String(err),
520
+ });
521
+ }
522
+ },
523
+ WHISPER_CONCURRENCY,
524
+ );
525
+ } finally {
526
+ await fs.rm(tmpRoot, { recursive: true, force: true });
527
+ }
508
528
 
509
- return {
510
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
511
- ...(transcriptions.length === 0 && failures.length > 0 ? { isError: true } : {}),
512
- };
513
- } catch (err) { return toolError(err); }
529
+ const result = {
530
+ zipPath,
531
+ model,
532
+ language: language || null,
533
+ voiceFileCount: voiceEntries.length,
534
+ transcribedCount: transcriptions.length,
535
+ transcriptions,
536
+ failures,
537
+ };
538
+
539
+ return {
540
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
541
+ ...(transcriptions.length === 0 && failures.length > 0 ? { isError: true } : {}),
542
+ };
543
+ } catch (err) {
544
+ return toolError(err);
545
+ }
514
546
  },
515
547
  );
516
548
 
@@ -526,18 +558,29 @@ server.registerTool(
526
558
  openWorldHint: false,
527
559
  },
528
560
  inputSchema: z.object({
529
- directory: z.string().optional().describe("Directory to search for packages. Defaults to TRACEGIST_DIR or ~/Downloads."),
530
- limit: z.number().int().min(1).max(200).optional().describe("Maximum number of packages to return (default: 20)."),
561
+ directory: z
562
+ .string()
563
+ .optional()
564
+ .describe("Directory to search for packages. Defaults to TRACEGIST_DIR or ~/Downloads."),
565
+ limit: z
566
+ .number()
567
+ .int()
568
+ .min(1)
569
+ .max(200)
570
+ .optional()
571
+ .describe("Maximum number of packages to return (default: 20)."),
531
572
  }),
532
573
  },
533
574
  async ({ directory, limit = 20 }) => {
534
575
  try {
535
- const searchDir = directory || DEFAULT_DOWNLOADS_DIR;
536
- const packages = await listTraceGistPackages(searchDir, limit);
537
- return {
538
- content: [{ type: "text", text: renderPackagesText(packages, searchDir) }],
539
- };
540
- } catch (err) { return toolError(err); }
576
+ const searchDir = directory || DEFAULT_DOWNLOADS_DIR;
577
+ const packages = await listTraceGistPackages(searchDir, limit);
578
+ return {
579
+ content: [{ type: "text", text: renderPackagesText(packages, searchDir) }],
580
+ };
581
+ } catch (err) {
582
+ return toolError(err);
583
+ }
541
584
  },
542
585
  );
543
586
 
@@ -560,28 +603,26 @@ server.registerTool(
560
603
  },
561
604
  async ({ zipPath }) => {
562
605
  try {
563
- const { zip, entryNames } = await readZipEntries(zipPath);
564
- const { manifest, manifestEntryName } = await tryReadManifest(zip);
565
- const { handoffEntryName } = await tryReadHandoffMarkdown(
566
- zip,
567
- entryNames,
568
- manifest,
569
- );
606
+ const { zip, entryNames } = await readZipEntries(zipPath);
607
+ const { manifest, manifestEntryName } = await tryReadManifest(zip);
608
+ const { handoffEntryName } = await tryReadHandoffMarkdown(zip, entryNames, manifest);
570
609
 
571
- const overview = {
572
- zipPath,
573
- entryCount: entryNames.length,
574
- manifestEntryName,
575
- handoffEntryName,
576
- manifest,
577
- entries: entryNames,
578
- hint: "Use get_tracegist_handoff_markdown (no section param) for a table of contents, then load sections by name.",
579
- };
610
+ const overview = {
611
+ zipPath,
612
+ entryCount: entryNames.length,
613
+ manifestEntryName,
614
+ handoffEntryName,
615
+ manifest,
616
+ entries: entryNames,
617
+ hint: "Use get_tracegist_handoff_markdown (no section param) for a table of contents, then load sections by name.",
618
+ };
580
619
 
581
- return {
582
- content: [{ type: "text", text: JSON.stringify(overview, null, 2) }],
583
- };
584
- } catch (err) { return toolError(err); }
620
+ return {
621
+ content: [{ type: "text", text: JSON.stringify(overview, null, 2) }],
622
+ };
623
+ } catch (err) {
624
+ return toolError(err);
625
+ }
585
626
  },
586
627
  );
587
628
 
@@ -603,102 +644,109 @@ server.registerTool(
603
644
  },
604
645
  inputSchema: z.object({
605
646
  zipPath: z.string().describe("Absolute path to the TraceGist package zip file."),
606
- section: z.string().optional().describe('Section name from the TOC (e.g. "Marker Timeline"), or "all" for the full document. Omit to get table of contents.'),
647
+ section: z
648
+ .string()
649
+ .optional()
650
+ .describe(
651
+ 'Section name from the TOC (e.g. "Marker Timeline"), or "all" for the full document. Omit to get table of contents.',
652
+ ),
607
653
  }),
608
654
  },
609
655
  async ({ zipPath, section }) => {
610
656
  try {
611
- const { zip, entryNames } = await readZipEntries(zipPath);
612
- const { manifest } = await tryReadManifest(zip);
613
- const { handoffMarkdown, handoffEntryName, debugCandidates } = await tryReadHandoffMarkdown(
614
- zip,
615
- entryNames,
616
- manifest,
617
- );
618
- if (!handoffMarkdown) {
619
- const hintedEntries = entryNames
620
- .filter((name) => /handoff|coding-agent/i.test(path.posix.basename(name)))
621
- .slice(0, 5);
622
- return {
623
- content: [
624
- {
625
- type: "text",
626
- text: [
627
- `No handoff markdown entry found in ${zipPath}.`,
628
- manifest?.handoffMarkdownFilename
629
- ? `Manifest handoff filename: ${manifest.handoffMarkdownFilename}`
630
- : "Manifest handoff filename: (missing)",
631
- `Matching entry candidates: ${hintedEntries.length > 0 ? hintedEntries.join(", ") : "(none)"}`,
632
- `Debug candidates: ${debugCandidates.length > 0 ? debugCandidates.join(", ") : "(none)"}`,
633
- ].join("\n"),
634
- },
635
- ],
636
- isError: true,
637
- };
638
- }
657
+ const { zip, entryNames } = await readZipEntries(zipPath);
658
+ const { manifest } = await tryReadManifest(zip);
659
+ const { handoffMarkdown, handoffEntryName, debugCandidates } = await tryReadHandoffMarkdown(
660
+ zip,
661
+ entryNames,
662
+ manifest,
663
+ );
664
+ if (!handoffMarkdown) {
665
+ const hintedEntries = entryNames
666
+ .filter((name) => /handoff|coding-agent/i.test(path.posix.basename(name)))
667
+ .slice(0, 5);
668
+ return {
669
+ content: [
670
+ {
671
+ type: "text",
672
+ text: [
673
+ `No handoff markdown entry found in ${zipPath}.`,
674
+ manifest?.handoffMarkdownFilename
675
+ ? `Manifest handoff filename: ${manifest.handoffMarkdownFilename}`
676
+ : "Manifest handoff filename: (missing)",
677
+ `Matching entry candidates: ${hintedEntries.length > 0 ? hintedEntries.join(", ") : "(none)"}`,
678
+ `Debug candidates: ${debugCandidates.length > 0 ? debugCandidates.join(", ") : "(none)"}`,
679
+ ].join("\n"),
680
+ },
681
+ ],
682
+ isError: true,
683
+ };
684
+ }
639
685
 
640
- // Cache transcription results to avoid re-running Whisper on repeated calls
641
- const cachedZip = zipCache.get(zipPath);
642
- const currentMtime = cachedZip?.mtimeMs ?? 0;
643
- const cached = transcriptCache.get(zipPath);
644
- let enrichedMarkdown;
645
- if (cached && cached.mtimeMs === currentMtime) {
646
- enrichedMarkdown = cached.enrichedMarkdown;
647
- } else {
648
- enrichedMarkdown = await injectTranscriptsIntoMarkdown(handoffMarkdown, zip, entryNames);
649
- transcriptCache.set(zipPath, { enrichedMarkdown, mtimeMs: currentMtime });
650
- }
686
+ // Cache transcription results to avoid re-running Whisper on repeated calls
687
+ const cachedZip = zipCache.get(zipPath);
688
+ const currentMtime = cachedZip?.mtimeMs ?? 0;
689
+ const cached = transcriptCache.get(zipPath);
690
+ let enrichedMarkdown;
691
+ if (cached && cached.mtimeMs === currentMtime) {
692
+ enrichedMarkdown = cached.enrichedMarkdown;
693
+ } else {
694
+ enrichedMarkdown = await injectTranscriptsIntoMarkdown(handoffMarkdown, zip, entryNames);
695
+ transcriptCache.set(zipPath, { enrichedMarkdown, mtimeMs: currentMtime });
696
+ }
651
697
 
652
- // Full document retrieval
653
- if (section && section.toLowerCase() === "all") {
654
- return {
655
- content: [
656
- {
657
- type: "text",
658
- text: `# Source: ${handoffEntryName}\n\n${enrichedMarkdown}`,
659
- },
660
- ],
661
- };
662
- }
698
+ // Full document retrieval
699
+ if (section && section.toLowerCase() === "all") {
700
+ return {
701
+ content: [
702
+ {
703
+ type: "text",
704
+ text: `# Source: ${handoffEntryName}\n\n${enrichedMarkdown}`,
705
+ },
706
+ ],
707
+ };
708
+ }
663
709
 
664
- const sections = parseMarkdownSections(enrichedMarkdown);
710
+ const sections = parseMarkdownSections(enrichedMarkdown);
665
711
 
666
- // No section requested — return table of contents
667
- if (!section) {
668
- return {
669
- content: [
670
- {
671
- type: "text",
672
- text: `# Source: ${handoffEntryName}\n\n${renderSectionToc(sections, zipPath)}`,
673
- },
674
- ],
675
- };
676
- }
712
+ // No section requested — return table of contents
713
+ if (!section) {
714
+ return {
715
+ content: [
716
+ {
717
+ type: "text",
718
+ text: `# Source: ${handoffEntryName}\n\n${renderSectionToc(sections, zipPath)}`,
719
+ },
720
+ ],
721
+ };
722
+ }
677
723
 
678
- // Section requested — find by case-insensitive match
679
- const sectionLower = section.toLowerCase();
680
- const match = sections.find((s) => s.name.toLowerCase() === sectionLower);
681
- if (!match) {
682
- const available = sections.map((s) => s.name).join(", ");
724
+ // Section requested — find by case-insensitive match
725
+ const sectionLower = section.toLowerCase();
726
+ const match = sections.find((s) => s.name.toLowerCase() === sectionLower);
727
+ if (!match) {
728
+ const available = sections.map((s) => s.name).join(", ");
729
+ return {
730
+ content: [
731
+ {
732
+ type: "text",
733
+ text: `Section "${section}" not found. Available sections: ${available}`,
734
+ },
735
+ ],
736
+ isError: true,
737
+ };
738
+ }
683
739
  return {
684
740
  content: [
685
741
  {
686
742
  type: "text",
687
- text: `Section "${section}" not found. Available sections: ${available}`,
743
+ text: match.content,
688
744
  },
689
745
  ],
690
- isError: true,
691
746
  };
747
+ } catch (err) {
748
+ return toolError(err);
692
749
  }
693
- return {
694
- content: [
695
- {
696
- type: "text",
697
- text: match.content,
698
- },
699
- ],
700
- };
701
- } catch (err) { return toolError(err); }
702
750
  },
703
751
  );
704
752
 
@@ -717,24 +765,28 @@ server.registerTool(
717
765
  },
718
766
  inputSchema: z.object({
719
767
  zipPath: z.string().describe("Absolute path to the TraceGist package zip file."),
720
- entryName: z.string().describe("Path of the file inside the zip (e.g. network/api-requests.jsonl)."),
768
+ entryName: z
769
+ .string()
770
+ .describe("Path of the file inside the zip (e.g. network/api-requests.jsonl)."),
721
771
  }),
722
772
  },
723
773
  async ({ zipPath, entryName }) => {
724
774
  try {
725
- const { zip } = await readZipEntries(zipPath);
726
- const entry = zip.file(entryName);
727
- if (!entry) {
775
+ const { zip } = await readZipEntries(zipPath);
776
+ const entry = zip.file(entryName);
777
+ if (!entry) {
778
+ return {
779
+ content: [{ type: "text", text: `Entry "${entryName}" not found in ${zipPath}.` }],
780
+ isError: true,
781
+ };
782
+ }
783
+ const text = await entry.async("text");
728
784
  return {
729
- content: [{ type: "text", text: `Entry "${entryName}" not found in ${zipPath}.` }],
730
- isError: true,
785
+ content: [{ type: "text", text }],
731
786
  };
787
+ } catch (err) {
788
+ return toolError(err);
732
789
  }
733
- const text = await entry.async("text");
734
- return {
735
- content: [{ type: "text", text }],
736
- };
737
- } catch (err) { return toolError(err); }
738
790
  },
739
791
  );
740
792
 
@@ -751,39 +803,627 @@ server.registerTool(
751
803
  inputSchema: z.object({
752
804
  zipPath: z.string().describe("Absolute path to the TraceGist package zip file."),
753
805
  entryName: z.string().describe("Path of the file inside the zip to extract."),
754
- outputDirectory: z.string().optional().describe("Directory to write the file to. Defaults to the zip file's directory."),
806
+ outputDirectory: z
807
+ .string()
808
+ .optional()
809
+ .describe("Directory to write the file to. Defaults to the zip file's directory."),
755
810
  }),
756
811
  },
757
812
  async ({ zipPath, entryName, outputDirectory }) => {
758
813
  try {
759
- const { zip } = await readZipEntries(zipPath);
760
- const entry = zip.file(entryName);
761
- if (!entry) {
814
+ const { zip } = await readZipEntries(zipPath);
815
+ const entry = zip.file(entryName);
816
+ if (!entry) {
817
+ return {
818
+ content: [{ type: "text", text: `Entry "${entryName}" not found in ${zipPath}.` }],
819
+ isError: true,
820
+ };
821
+ }
822
+ const outDir = path.resolve(outputDirectory || path.dirname(zipPath));
823
+ const outputPath = path.resolve(outDir, path.basename(entryName));
824
+ if (!outputPath.startsWith(outDir + path.sep) && outputPath !== outDir) {
825
+ return {
826
+ content: [
827
+ { type: "text", text: `Refusing to write outside output directory: ${outputPath}` },
828
+ ],
829
+ isError: true,
830
+ };
831
+ }
832
+ await fs.mkdir(outDir, { recursive: true });
833
+ const data = await entry.async("nodebuffer");
834
+ await fs.writeFile(outputPath, data);
762
835
  return {
763
- content: [{ type: "text", text: `Entry "${entryName}" not found in ${zipPath}.` }],
764
- isError: true,
836
+ content: [{ type: "text", text: `Extracted ${entryName} -> ${outputPath}` }],
765
837
  };
838
+ } catch (err) {
839
+ return toolError(err);
840
+ }
841
+ },
842
+ );
843
+
844
+ // ---------------------------------------------------------------------------
845
+ // Live Shadowing — WebSocket server + state
846
+ // ---------------------------------------------------------------------------
847
+
848
+ const LIVE_SHADOW_PORT = 19384;
849
+ const LIVE_SHADOW_PORT_RANGE = 5;
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";
855
+
856
+ /** @type {Array<{seq: number, ts: number, eventType: string, data: object}>} */
857
+ const liveEvents = [];
858
+ let liveEventSeq = 0;
859
+ let liveSessionActive = false;
860
+ /** @type {{sessionId: string, url: string, title: string, startedAt: number} | null} */
861
+ let liveSessionMeta = null;
862
+ /** @type {import("ws").WebSocket | null} */
863
+ let activeConnection = null;
864
+ /** @type {Array<{questionId: string, question: string, sentAt: number}>} */
865
+ const pendingQuestions = [];
866
+ /** @type {Map<string, object>} */
867
+ const questionResponses = new Map();
868
+
869
+ /** @type {Array<{resolve: Function, reject: Function, timeout: ReturnType<typeof setTimeout>}>} */
870
+ const screenshotWaiters = [];
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)}` };
766
924
  }
767
- const outDir = path.resolve(outputDirectory || path.dirname(zipPath));
768
- const outputPath = path.resolve(outDir, path.basename(entryName));
769
- if (!outputPath.startsWith(outDir + path.sep) && outputPath !== outDir) {
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
+
962
+ function startLiveShadowServer() {
963
+ let attempts = 0;
964
+
965
+ function tryPort(p) {
966
+ const httpServer = createServer();
967
+ const wss = new WebSocketServer({ server: httpServer });
968
+
969
+ wss.on("connection", (ws) => {
970
+ // Only allow one connection at a time
971
+ if (activeConnection) {
972
+ try {
973
+ activeConnection.close();
974
+ } catch {
975
+ // ignore
976
+ }
977
+ }
978
+ activeConnection = ws;
979
+ console.error(`[${BRIDGE_NAME}] Live shadow: extension connected`);
980
+
981
+ ws.on("message", (raw) => {
982
+ try {
983
+ const msg = JSON.parse(String(raw));
984
+ handleExtensionMessage(msg);
985
+ } catch (err) {
986
+ console.error(`[${BRIDGE_NAME}] Live shadow: invalid message:`, err);
987
+ }
988
+ });
989
+
990
+ ws.on("close", () => {
991
+ if (activeConnection === ws) {
992
+ activeConnection = null;
993
+ if (liveSessionActive) {
994
+ liveSessionActive = false;
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
+ }
1001
+ }
1002
+ }
1003
+ });
1004
+ });
1005
+
1006
+ httpServer.on("error", (err) => {
1007
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === "EADDRINUSE") {
1008
+ attempts++;
1009
+ if (attempts < LIVE_SHADOW_PORT_RANGE) {
1010
+ tryPort(p + 1);
1011
+ } else {
1012
+ console.error(
1013
+ `[${BRIDGE_NAME}] Live shadow: all ports ${LIVE_SHADOW_PORT}-${LIVE_SHADOW_PORT + LIVE_SHADOW_PORT_RANGE - 1} in use, live shadowing disabled`,
1014
+ );
1015
+ }
1016
+ }
1017
+ });
1018
+
1019
+ httpServer.listen(p, "127.0.0.1", () => {
1020
+ console.error(
1021
+ `[${BRIDGE_NAME}] Live shadow WebSocket server listening on ws://127.0.0.1:${p}`,
1022
+ );
1023
+ });
1024
+ }
1025
+
1026
+ tryPort(LIVE_SHADOW_PORT);
1027
+ }
1028
+
1029
+ function handleExtensionMessage(msg) {
1030
+ switch (msg.type) {
1031
+ case "session-start":
1032
+ liveSessionActive = true;
1033
+ liveSessionMeta = {
1034
+ sessionId: msg.sessionId,
1035
+ url: msg.url,
1036
+ title: msg.title,
1037
+ startedAt: Date.now(),
1038
+ };
1039
+ liveEvents.length = 0;
1040
+ pendingQuestions.length = 0;
1041
+ questionResponses.clear();
1042
+ console.error(`[${BRIDGE_NAME}] Live shadow: session started (${msg.sessionId})`);
1043
+ // Notify MCP clients that resources changed
1044
+ try {
1045
+ server.sendResourceListChanged();
1046
+ } catch {
1047
+ // Not all transports support notifications
1048
+ }
1049
+ break;
1050
+
1051
+ case "event":
1052
+ liveEvents.push({
1053
+ seq: msg.seq,
1054
+ ts: msg.ts,
1055
+ eventType: msg.eventType,
1056
+ data: msg.data,
1057
+ });
1058
+ if (liveEvents.length > LIVE_SHADOW_EVENT_BUFFER_SIZE) {
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);
1062
+ }
1063
+ break;
1064
+
1065
+ case "question-response": {
1066
+ questionResponses.set(msg.questionId, {
1067
+ questionId: msg.questionId,
1068
+ timestamp: msg.timestamp,
1069
+ voiceBlobDataUrl: msg.voiceBlobDataUrl || null,
1070
+ screenshot: msg.screenshot || null,
1071
+ highlightCaptures: msg.highlightCaptures || [],
1072
+ });
1073
+ const rIdx = pendingQuestions.findIndex((q) => q.questionId === msg.questionId);
1074
+ if (rIdx >= 0) pendingQuestions.splice(rIdx, 1);
1075
+ break;
1076
+ }
1077
+
1078
+ case "question-dismissed": {
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);
1082
+ break;
1083
+ }
1084
+
1085
+ case "screenshot-response":
1086
+ // Resolve any pending screenshot waiters
1087
+ for (const waiter of screenshotWaiters.splice(0)) {
1088
+ clearTimeout(waiter.timeout);
1089
+ waiter.resolve(msg.screenshot || null);
1090
+ }
1091
+ break;
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
+
1114
+ case "session-end":
1115
+ liveSessionActive = false;
1116
+ console.error(`[${BRIDGE_NAME}] Live shadow: session ended`);
1117
+ try {
1118
+ server.sendResourceListChanged();
1119
+ } catch {
1120
+ // Not all transports support notifications
1121
+ }
1122
+ break;
1123
+ }
1124
+ }
1125
+
1126
+ // Register live shadowing MCP resource
1127
+ server.resource(
1128
+ "live-session-status",
1129
+ "live-session://status",
1130
+ { description: "Current live shadowing session status" },
1131
+ async () => ({
1132
+ contents: [
1133
+ {
1134
+ uri: "live-session://status",
1135
+ text: JSON.stringify(
1136
+ {
1137
+ active: liveSessionActive,
1138
+ session: liveSessionMeta,
1139
+ eventCount: liveEvents.length,
1140
+ pendingQuestions: pendingQuestions.length,
1141
+ },
1142
+ null,
1143
+ 2,
1144
+ ),
1145
+ mimeType: "application/json",
1146
+ },
1147
+ ],
1148
+ }),
1149
+ );
1150
+
1151
+ // Tool: watch_live_session
1152
+ server.tool(
1153
+ "watch_live_session",
1154
+ "Watch a live TraceGist shadowing session. Returns buffered events since the given sequence number. " +
1155
+ "Poll every 3-5 seconds to watch events in real-time. " +
1156
+ "Returns sessionActive: false when the session ends.",
1157
+ {
1158
+ since_seq: z
1159
+ .number()
1160
+ .optional()
1161
+ .describe("Return events after this sequence number. Omit for all buffered events."),
1162
+ },
1163
+ async ({ since_seq }) => {
1164
+ if (!liveSessionActive && liveEvents.length === 0) {
1165
+ return toolError(
1166
+ "No active live shadowing session. Ensure the TraceGist extension has Live Shadowing enabled and is recording.",
1167
+ );
1168
+ }
1169
+
1170
+ const events =
1171
+ since_seq != null ? liveEvents.filter((e) => e.seq > since_seq) : [...liveEvents];
1172
+
1173
+ const latestSeq = liveEvents.length > 0 ? liveEvents[liveEvents.length - 1].seq : 0;
1174
+
1175
+ return {
1176
+ content: [
1177
+ {
1178
+ type: "text",
1179
+ text: JSON.stringify(
1180
+ {
1181
+ sessionActive: liveSessionActive,
1182
+ session: liveSessionMeta,
1183
+ events,
1184
+ latestSeq,
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
+ : {}),
1202
+ },
1203
+ null,
1204
+ 2,
1205
+ ),
1206
+ },
1207
+ ],
1208
+ };
1209
+ },
1210
+ );
1211
+
1212
+ // Tool: ask_tester_question
1213
+ server.tool(
1214
+ "ask_tester_question",
1215
+ "Ask the tester a short, concise question during a live shadowing session. " +
1216
+ "The question appears as a toast notification in their browser. " +
1217
+ "The tester responds with a voice marker (Alt+Shift+M) and optional highlight captures (Alt+Shift+S). " +
1218
+ "Use get_tester_response to check for their answer.",
1219
+ {
1220
+ question: z
1221
+ .string()
1222
+ .max(200)
1223
+ .describe(
1224
+ "Short, concise question for the tester (max 200 chars). " +
1225
+ "Example: 'Can you click the Save button again?' or 'Does the error appear with a different email?'",
1226
+ ),
1227
+ },
1228
+ async ({ question }) => {
1229
+ if (!liveSessionActive || !activeConnection) {
1230
+ return toolError("No active live shadowing session or extension not connected.");
1231
+ }
1232
+
1233
+ const questionId = crypto.randomUUID();
1234
+ try {
1235
+ activeConnection.send(
1236
+ JSON.stringify({
1237
+ type: "agent-question",
1238
+ questionId,
1239
+ question,
1240
+ }),
1241
+ );
1242
+ } catch (err) {
1243
+ return toolError(`Failed to send question to extension: ${err}`);
1244
+ }
1245
+
1246
+ pendingQuestions.push({ questionId, question, sentAt: Date.now() });
1247
+ while (pendingQuestions.length > PENDING_QUESTIONS_MAX) {
1248
+ pendingQuestions.shift();
1249
+ }
1250
+
1251
+ return {
1252
+ content: [
1253
+ {
1254
+ type: "text",
1255
+ text: [
1256
+ `Question sent to tester (ID: ${questionId}).`,
1257
+ "",
1258
+ "The tester sees the question as a notification in their browser.",
1259
+ "They can respond with:",
1260
+ "- Voice marker: Alt/Option + Shift + M (start/stop recording)",
1261
+ "- Highlight captures: Alt/Option + Shift + S (draw on screen)",
1262
+ "",
1263
+ "Use `get_tester_response` with this question ID to check for their answer.",
1264
+ ].join("\n"),
1265
+ },
1266
+ ],
1267
+ };
1268
+ },
1269
+ );
1270
+
1271
+ // Tool: get_tester_response
1272
+ server.tool(
1273
+ "get_tester_response",
1274
+ "Check for the tester's response to a question asked during live shadowing. " +
1275
+ "Returns the tester's screenshot, highlight captures, and voice note availability.",
1276
+ {
1277
+ question_id: z.string().describe("The question ID returned by ask_tester_question."),
1278
+ },
1279
+ async ({ question_id }) => {
1280
+ const response = questionResponses.get(question_id);
1281
+
1282
+ if (!response) {
770
1283
  return {
771
1284
  content: [
772
- { type: "text", text: `Refusing to write outside output directory: ${outputPath}` },
1285
+ {
1286
+ type: "text",
1287
+ text: "No response yet. The tester may still be recording their answer. Try again in a few seconds.",
1288
+ },
773
1289
  ],
774
- isError: true,
775
1290
  };
776
1291
  }
777
- await fs.mkdir(outDir, { recursive: true });
778
- const data = await entry.async("nodebuffer");
779
- await fs.writeFile(outputPath, data);
780
- return {
781
- content: [{ type: "text", text: `Extracted ${entryName} -> ${outputPath}` }],
1292
+
1293
+ if (response.dismissed) {
1294
+ questionResponses.delete(question_id);
1295
+ return {
1296
+ content: [
1297
+ {
1298
+ type: "text",
1299
+ text: "The tester dismissed this question without responding.",
1300
+ },
1301
+ ],
1302
+ };
1303
+ }
1304
+
1305
+ // Build multimodal content with images inline
1306
+ const content = [];
1307
+
1308
+ const meta = {
1309
+ questionId: question_id,
1310
+ hasVoice: !!response.voiceBlobDataUrl,
1311
+ hasScreenshot: !!response.screenshot,
1312
+ highlightCount: response.highlightCaptures?.length || 0,
782
1313
  };
783
- } catch (err) { return toolError(err); }
1314
+ content.push({
1315
+ type: "text",
1316
+ text: JSON.stringify(meta, null, 2),
1317
+ });
1318
+
1319
+ // Include screenshot as image
1320
+ if (response.screenshot && response.screenshot.startsWith("data:image/")) {
1321
+ const match = response.screenshot.match(/^data:([^;]+);base64,(.+)$/);
1322
+ if (match) {
1323
+ content.push({
1324
+ type: "image",
1325
+ data: match[2],
1326
+ mimeType: match[1],
1327
+ });
1328
+ }
1329
+ }
1330
+
1331
+ // Include highlight captures as images
1332
+ if (response.highlightCaptures) {
1333
+ for (const hl of response.highlightCaptures) {
1334
+ if (hl.screenshot && hl.screenshot.startsWith("data:image/")) {
1335
+ const match = hl.screenshot.match(/^data:([^;]+);base64,(.+)$/);
1336
+ if (match) {
1337
+ content.push({
1338
+ type: "image",
1339
+ data: match[2],
1340
+ mimeType: match[1],
1341
+ });
1342
+ }
1343
+ }
1344
+ }
1345
+ }
1346
+
1347
+ if (response.voiceBlobDataUrl) {
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
+ }
1360
+ }
1361
+
1362
+ // Clean up after retrieval
1363
+ questionResponses.delete(question_id);
1364
+
1365
+ return { content };
784
1366
  },
785
1367
  );
786
1368
 
1369
+ // Tool: get_live_screenshot
1370
+ server.tool(
1371
+ "get_live_screenshot",
1372
+ "Capture a screenshot of the tester's current browser tab during a live shadowing session.",
1373
+ {},
1374
+ async () => {
1375
+ if (!liveSessionActive || !activeConnection) {
1376
+ return toolError("No active live shadowing session or extension not connected.");
1377
+ }
1378
+
1379
+ // Request screenshot from extension and wait for response
1380
+ const screenshotPromise = new Promise((resolve, reject) => {
1381
+ const timeout = setTimeout(() => {
1382
+ const idx = screenshotWaiters.findIndex((w) => w.resolve === resolve);
1383
+ if (idx >= 0) screenshotWaiters.splice(idx, 1);
1384
+ reject(new Error("Screenshot request timed out (5s)"));
1385
+ }, 5000);
1386
+ screenshotWaiters.push({ resolve, reject, timeout });
1387
+ });
1388
+
1389
+ try {
1390
+ activeConnection.send(JSON.stringify({ type: "request-screenshot" }));
1391
+ } catch (err) {
1392
+ // Clean up waiter
1393
+ screenshotWaiters.splice(0);
1394
+ return toolError(`Failed to request screenshot: ${err}`);
1395
+ }
1396
+
1397
+ try {
1398
+ const screenshot = await screenshotPromise;
1399
+ if (!screenshot) {
1400
+ return toolError("Extension returned empty screenshot");
1401
+ }
1402
+
1403
+ const match = String(screenshot).match(/^data:([^;]+);base64,(.+)$/);
1404
+ if (match) {
1405
+ return {
1406
+ content: [
1407
+ {
1408
+ type: "image",
1409
+ data: match[2],
1410
+ mimeType: match[1],
1411
+ },
1412
+ ],
1413
+ };
1414
+ }
1415
+
1416
+ return toolError("Screenshot data format not recognized");
1417
+ } catch (err) {
1418
+ return toolError(String(err));
1419
+ }
1420
+ },
1421
+ );
1422
+
1423
+ // ---------------------------------------------------------------------------
1424
+ // Startup
1425
+ // ---------------------------------------------------------------------------
1426
+
787
1427
  whisperDependencyWarnings = await checkWhisperDependencies();
788
1428
  console.error(
789
1429
  `[${BRIDGE_NAME}] Default package directory: ${DEFAULT_DOWNLOADS_DIR}${process.env.TRACEGIST_DIR ? " (from TRACEGIST_DIR)" : ""}`,
@@ -796,5 +1436,8 @@ if (whisperDependencyWarnings.length > 0) {
796
1436
  console.error(`[${BRIDGE_NAME}] Whisper dependencies check passed.`);
797
1437
  }
798
1438
 
1439
+ // Start the live shadow WebSocket server
1440
+ startLiveShadowServer();
1441
+
799
1442
  const transport = new StdioServerTransport();
800
1443
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tracegist-mcp-bridge",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Local-first MCP bridge for reading and transcribing TraceGist package zips.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "dependencies": {
28
28
  "@modelcontextprotocol/sdk": "^1.26.0",
29
29
  "jszip": "^3.10.1",
30
+ "ws": "^8.18.0",
30
31
  "zod": "^4.3.6"
31
32
  }
32
33
  }