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 +3 -2
- package/bin/tracegist-mcp-bridge.mjs +883 -240
- package/package.json +2 -1
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)":
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
355
|
-
|
|
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
|
|
406
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
530
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
} catch (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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
} catch (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
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
710
|
+
const sections = parseMarkdownSections(enrichedMarkdown);
|
|
665
711
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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: `
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
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
|
+
"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
|
}
|