tracegist-mcp-bridge 0.2.2 → 0.2.4

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.
@@ -3,12 +3,27 @@
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";
11
14
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
+ import {
16
+ isTraceGistPackageFile,
17
+ normalizeZipBaseName,
18
+ renderPackagesText,
19
+ parseMarkdownSections,
20
+ renderSectionToc,
21
+ looksLikeVoiceNote,
22
+ mapConcurrent,
23
+ toolError,
24
+ formatExecError,
25
+ HANDOFF_SUFFIXES,
26
+ } from "./lib.mjs";
12
27
 
13
28
  const BRIDGE_NAME = "tracegist-mcp-bridge";
14
29
  const { version: BRIDGE_VERSION } = JSON.parse(
@@ -25,9 +40,6 @@ function resolveDefaultDirectory() {
25
40
  }
26
41
 
27
42
  const DEFAULT_DOWNLOADS_DIR = resolveDefaultDirectory();
28
- const PACKAGE_SUFFIX = "-package.zip";
29
- const HANDOFF_SUFFIXES = ["-coding-agent-handoff.md", "-cursor-handoff.md"];
30
- const MAX_PREVIEW_CHARS = 12_000;
31
43
  const ZIP_CACHE_MAX = 5;
32
44
  const execFileAsync = promisify(execFile);
33
45
  let whisperDependencyWarnings = [];
@@ -35,6 +47,9 @@ let whisperDependencyWarnings = [];
35
47
  /** @type {Map<string, { zip: JSZip, entryNames: string[], mtimeMs: number }>} */
36
48
  const zipCache = new Map();
37
49
 
50
+ /** @type {Map<string, { enrichedMarkdown: string, mtimeMs: number }>} */
51
+ const transcriptCache = new Map();
52
+
38
53
  async function checkWhisperDependencies() {
39
54
  const warnings = [];
40
55
 
@@ -70,15 +85,6 @@ async function checkWhisperDependencies() {
70
85
  return warnings;
71
86
  }
72
87
 
73
- function formatExecError(err) {
74
- if (err instanceof Error && err.message) return err.message;
75
- return String(err);
76
- }
77
-
78
- function isTraceGistPackageFile(fileName) {
79
- return fileName.startsWith("tracegist-") && fileName.endsWith(PACKAGE_SUFFIX);
80
- }
81
-
82
88
  async function listTraceGistPackages(directory, limit) {
83
89
  const entries = await fs.readdir(directory, { withFileTypes: true });
84
90
  const zipNames = entries
@@ -144,10 +150,6 @@ async function tryReadManifest(zip) {
144
150
  }
145
151
  }
146
152
 
147
- function normalizeZipBaseName(entryName) {
148
- return path.posix.basename(entryName).toLowerCase();
149
- }
150
-
151
153
  async function tryReadHandoffMarkdown(zip, entryNames, manifest = null) {
152
154
  const manifestNamedHandoff =
153
155
  typeof manifest?.handoffMarkdownFilename === "string"
@@ -181,100 +183,8 @@ async function tryReadHandoffMarkdown(zip, entryNames, manifest = null) {
181
183
  return { handoffMarkdown, handoffEntryName: selectedEntryName, debugCandidates: [] };
182
184
  }
183
185
 
184
- function renderPackagesText(packages, directory) {
185
- if (packages.length === 0) {
186
- return [
187
- `No TraceGist package zips found in ${directory}.`,
188
- "Expected naming pattern: tracegist-...-package.zip",
189
- ].join("\n");
190
- }
191
- const lines = [`Found ${packages.length} TraceGist package(s) in ${directory}:`, ""];
192
- for (const pkg of packages) {
193
- lines.push(
194
- `- ${pkg.name}`,
195
- ` path: ${pkg.path}`,
196
- ` size: ${pkg.sizeBytes} bytes`,
197
- ` modified: ${pkg.modifiedAt}`,
198
- "",
199
- );
200
- }
201
- return lines.join("\n");
202
- }
203
-
204
- function truncateText(value, maxChars = MAX_PREVIEW_CHARS) {
205
- if (value.length <= maxChars) return value;
206
- return `${value.slice(0, maxChars)}\n\n... [truncated ${value.length - maxChars} chars]`;
207
- }
208
-
209
- function parseMarkdownSections(markdown) {
210
- const sections = [];
211
- const lines = markdown.split("\n");
212
- let currentName = null;
213
- let currentStart = 0;
214
-
215
- for (let i = 0; i < lines.length; i++) {
216
- if (lines[i].startsWith("## ")) {
217
- if (currentName !== null) {
218
- sections.push({
219
- name: currentName,
220
- content: lines.slice(currentStart, i).join("\n"),
221
- });
222
- }
223
- currentName = lines[i].slice(3).trim();
224
- currentStart = i;
225
- }
226
- }
227
-
228
- if (currentName !== null) {
229
- sections.push({
230
- name: currentName,
231
- content: lines.slice(currentStart).join("\n"),
232
- });
233
- }
234
-
235
- return sections;
236
- }
237
-
238
- function renderSectionToc(sections, zipPath) {
239
- const totalChars = sections.reduce((sum, s) => sum + s.content.length, 0);
240
- const lines = [
241
- `Handoff document for ${path.basename(zipPath)} — ${sections.length} sections, ${totalChars.toLocaleString()} chars total.`,
242
- "",
243
- "| # | Section | Size |",
244
- "|---|---------|------|",
245
- ];
246
- for (let i = 0; i < sections.length; i++) {
247
- lines.push(
248
- `| ${i + 1} | ${sections[i].name} | ${sections[i].content.length.toLocaleString()} chars |`,
249
- );
250
- }
251
- lines.push(
252
- "",
253
- 'Pass `section` with a section name (e.g. "Marker Timeline") to retrieve its full content.',
254
- );
255
- return lines.join("\n");
256
- }
257
-
258
- function looksLikeVoiceNote(entryName) {
259
- const base = path.posix.basename(entryName).toLowerCase();
260
- return /voice/.test(base) && /\.(webm|wav|mp3|m4a|ogg|flac)$/i.test(base);
261
- }
262
-
263
186
  const WHISPER_CONCURRENCY = 2;
264
187
 
265
- async function mapConcurrent(items, fn, concurrency) {
266
- const results = new Array(items.length);
267
- let i = 0;
268
- const worker = async () => {
269
- while (i < items.length) {
270
- const idx = i++;
271
- results[idx] = await fn(items[idx], idx);
272
- }
273
- };
274
- await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
275
- return results;
276
- }
277
-
278
188
  async function transcribeWithLocalWhisper(audioPath, model, language) {
279
189
  const args = [
280
190
  "-m",
@@ -337,7 +247,9 @@ async function injectTranscriptsIntoMarkdown(markdown, zip, entryNames) {
337
247
  transcriptByShortId.set(shortId, "[VOICE NOTE NOT FOUND IN ZIP]");
338
248
  return;
339
249
  }
340
- const outputPath = path.join(tmpRoot, path.basename(entryName));
250
+ const markerDir = path.join(tmpRoot, shortId);
251
+ await fs.mkdir(markerDir, { recursive: true });
252
+ const outputPath = path.join(markerDir, path.basename(entryName));
341
253
  const data = await entry.async("nodebuffer");
342
254
  await fs.writeFile(outputPath, data);
343
255
  try {
@@ -411,27 +323,58 @@ server.registerPrompt(
411
323
  "Each package contains screenshots, voice notes, network logs, interaction events, and a",
412
324
  "pre-generated handoff markdown file.",
413
325
  "",
414
- "## Recommended workflow",
326
+ "## Step 1 — Discover & orient",
415
327
  "",
416
328
  "1. `list_tracegist_packages` — find available packages (searches TRACEGIST_DIR env var, or ~/Downloads by default).",
417
- "2. `get_tracegist_handoff_markdown` — call without a `section` parameter to get a table of contents",
418
- ' showing all sections and their sizes. Then request specific sections by name (e.g. `section: "Marker Timeline"`).',
329
+ "2. `get_tracegist_handoff_markdown` — call **without** a `section` parameter to get a table of contents (TOC)",
330
+ " showing all sections, their sizes, and a brief purpose hint for each section.",
331
+ ' Then request specific sections by name (e.g. `section: "Marker Timeline"`).',
419
332
  ' Use `section: "all"` to load the full document at once (can be large for deep exports).',
420
333
  " **Voice note transcripts are injected automatically** into the marker sections — no separate step needed.",
421
334
  " If Whisper is unavailable, a placeholder is injected with setup instructions.",
422
- "3. `read_tracegist_package_file` — read text files directly from the ZIP without writing to disk.",
423
- " - **Playwright repro script**: the manifest's `playwrightScriptPath` field gives the entry name.",
424
- " Call `read_tracegist_package_file({ zipPath, entryName: manifest.playwrightScriptPath })`.",
425
- " - **Network bodies**: `network/api-requests.jsonl` has POST/PUT/PATCH bodies + response statuses.",
426
- " - Also useful for the raw manifest JSON or any other text entry.",
427
- "4. `extract_tracegist_package_file` — extract a file to disk.",
428
- " Use for: screenshots (PNG) and other binary files, or when a downstream tool needs a real file path.",
335
+ "",
336
+ "## Step 2 — Decide: analyze-only vs. reproduce-first",
337
+ "",
338
+ "After reading the TOC, choose one of two paths:",
339
+ "",
340
+ "### Path A: Analyze and fix (no reproduction needed)",
341
+ "",
342
+ "Best when the bug is clear from logs/screenshots and you can identify the root cause from the trace alone.",
343
+ "",
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**.",
348
+ "5. Diagnose and fix directly in the codebase.",
349
+ "",
350
+ "### Path B: Reproduce first, then develop against the reproduction",
351
+ "",
352
+ "Best when you need to see the failure live, the bug is intermittent, or you want a test harness to develop against.",
353
+ "",
354
+ "1. `get_tracegist_package_overview` — get the manifest. Note `playwrightScriptPath`.",
355
+ "2. `read_tracegist_package_file({ zipPath, entryName: manifest.playwrightScriptPath })` — read the",
356
+ " auto-generated Playwright repro script (click/fill steps derived from the interaction log).",
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.",
359
+ "5. Extract and run the Playwright script:",
360
+ " - `extract_tracegist_package_file` to get the script + config to disk.",
361
+ " - The script needs authentication handled separately (storageState or global setup).",
362
+ " - `npx playwright test <script>.spec.ts` — adapt the script as needed, then develop your fix against it.",
363
+ "6. Once the bug reproduces, switch to Path A sections for root-cause analysis.",
364
+ "",
365
+ "## Additional tools",
366
+ "",
367
+ "- `read_tracegist_package_file` — read any text file directly from the ZIP without writing to disk.",
368
+ " - **Network bodies**: `network/api-requests.jsonl` has POST/PUT/PATCH bodies + response statuses.",
369
+ " - Also useful for the raw manifest JSON or any other text entry.",
370
+ "- `extract_tracegist_package_file` — extract a file to disk.",
371
+ " Use for: screenshots (PNG) and other binary files, or when a downstream tool needs a real file path.",
429
372
  "",
430
373
  "## Key facts",
431
374
  "",
432
- "- **Overview vs. full handoff**: `get_tracegist_package_overview` returns a TRUNCATED handoff preview",
433
- " (first 12,000 chars — may cut off mid-section). Do NOT treat it as the complete handoff.",
434
- " Always follow up with `get_tracegist_handoff_markdown` (no section → TOC, then load sections by name).",
375
+ "- **Overview vs. full handoff**: `get_tracegist_package_overview` returns only metadata (manifest + file list).",
376
+ " It does NOT include handoff content. Always use `get_tracegist_handoff_markdown`",
377
+ " (no section → TOC, then load sections by name) for the actual handoff document.",
435
378
  "- Voice notes answer the triage prompts shown to the tester during recording.",
436
379
  " The first marker within 30 s of session start is a session context note (starting state, not a bug).",
437
380
  "- Marker timestamps are relative to session start. Each marker has a ±5 s event window",
@@ -442,6 +385,25 @@ server.registerPrompt(
442
385
  " (returned by `get_tracegist_package_overview`). Read it with:",
443
386
  " `read_tracegist_package_file({ zipPath, entryName: manifest.playwrightScriptPath })`.",
444
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",
445
407
  ].join("\n"),
446
408
  },
447
409
  },
@@ -454,113 +416,133 @@ server.registerTool(
454
416
  {
455
417
  description:
456
418
  "Transcribe voice-note files inside a TraceGist package zip using local Python Whisper (no external API).",
419
+ annotations: {
420
+ readOnlyHint: true,
421
+ destructiveHint: false,
422
+ idempotentHint: true,
423
+ openWorldHint: false,
424
+ },
457
425
  inputSchema: z.object({
458
- zipPath: z.string(),
459
- model: z.string().optional(),
460
- language: z.string().optional(),
426
+ zipPath: z.string().describe("Absolute path to the TraceGist package zip file."),
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."),
461
437
  }),
462
438
  },
463
439
  async ({ zipPath, model = "base", language }) => {
464
- if (whisperDependencyWarnings.length > 0) {
465
- return {
466
- content: [
467
- {
468
- type: "text",
469
- text: JSON.stringify(
470
- {
471
- zipPath,
472
- model,
473
- language: language || null,
474
- transcriptions: [],
475
- failures: [],
476
- warning:
477
- "Local Whisper dependencies are missing. Fix the dependency warnings and retry transcription.",
478
- dependencyWarnings: whisperDependencyWarnings,
479
- },
480
- null,
481
- 2,
482
- ),
483
- },
484
- ],
485
- isError: true,
486
- };
487
- }
488
-
489
- const { zip, entryNames } = await readZipEntries(zipPath);
490
- const voiceEntries = entryNames.filter((entryName) => looksLikeVoiceNote(entryName));
491
- if (voiceEntries.length === 0) {
492
- return {
493
- content: [
494
- {
495
- type: "text",
496
- text: JSON.stringify(
497
- {
498
- zipPath,
499
- model,
500
- language: language || null,
501
- transcriptions: [],
502
- warning: "No voice-note files found in package.",
503
- },
504
- null,
505
- 2,
506
- ),
507
- },
508
- ],
509
- };
510
- }
511
-
512
- const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tracegist-whisper-"));
513
- const transcriptions = [];
514
- const failures = [];
515
440
  try {
516
- // Extract all audio files to disk first (fast I/O, safe to parallelize fully)
517
- const extracted = [];
518
- for (const entryName of voiceEntries) {
519
- const entry = zip.file(entryName);
520
- if (!entry) {
521
- failures.push({ entryName, error: "Entry missing from zip" });
522
- continue;
523
- }
524
- const outputPath = path.join(tmpRoot, path.basename(entryName));
525
- const data = await entry.async("nodebuffer");
526
- await fs.writeFile(outputPath, data);
527
- extracted.push({ entryName, outputPath });
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
+ };
528
464
  }
529
465
 
530
- // Transcribe with bounded concurrency (Whisper is CPU/GPU-heavy)
531
- await mapConcurrent(
532
- extracted,
533
- async ({ entryName, outputPath }) => {
534
- try {
535
- const transcript = await transcribeWithLocalWhisper(outputPath, model, language);
536
- transcriptions.push({ entryName, transcript });
537
- } catch (err) {
538
- failures.push({
539
- entryName,
540
- error: err instanceof Error ? err.message : String(err),
541
- });
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
+ };
487
+ }
488
+
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;
542
500
  }
543
- },
544
- WHISPER_CONCURRENCY,
545
- );
546
- } finally {
547
- await fs.rm(tmpRoot, { recursive: true, force: true });
548
- }
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
+ }
549
508
 
550
- const result = {
551
- zipPath,
552
- model,
553
- language: language || null,
554
- voiceFileCount: voiceEntries.length,
555
- transcribedCount: transcriptions.length,
556
- transcriptions,
557
- failures,
558
- };
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
+ }
559
528
 
560
- return {
561
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
562
- ...(transcriptions.length === 0 && failures.length > 0 ? { isError: true } : {}),
563
- };
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
+ }
564
546
  },
565
547
  );
566
548
 
@@ -569,17 +551,36 @@ server.registerTool(
569
551
  {
570
552
  description:
571
553
  "List TraceGist marker package zip files. Searches TRACEGIST_DIR (env var), falls back to ~/Downloads, or uses the given directory.",
554
+ annotations: {
555
+ readOnlyHint: true,
556
+ destructiveHint: false,
557
+ idempotentHint: true,
558
+ openWorldHint: false,
559
+ },
572
560
  inputSchema: z.object({
573
- directory: z.string().optional(),
574
- limit: z.number().int().min(1).max(200).optional(),
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)."),
575
572
  }),
576
573
  },
577
574
  async ({ directory, limit = 20 }) => {
578
- const searchDir = directory || DEFAULT_DOWNLOADS_DIR;
579
- const packages = await listTraceGistPackages(searchDir, limit);
580
- return {
581
- content: [{ type: "text", text: renderPackagesText(packages, searchDir) }],
582
- };
575
+ try {
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
+ }
583
584
  },
584
585
  );
585
586
 
@@ -587,35 +588,41 @@ server.registerTool(
587
588
  "get_tracegist_package_overview",
588
589
  {
589
590
  description:
590
- "Quick overview of a TraceGist package: manifest, file list, and a TRUNCATED handoff preview (first 12,000 chars only — may cut off mid-section). " +
591
- "For the full handoff, call get_tracegist_handoff_markdown (no section param → table of contents, then load sections by name). " +
591
+ "Quick metadata overview of a TraceGist package: manifest and file list. " +
592
+ "Does NOT include handoff content — call get_tracegist_handoff_markdown (no section param → table of contents, then load sections by name) for the actual handoff. " +
592
593
  "The manifest includes playwrightScriptPath — use read_tracegist_package_file with that path to get the Playwright repro script.",
594
+ annotations: {
595
+ readOnlyHint: true,
596
+ destructiveHint: false,
597
+ idempotentHint: true,
598
+ openWorldHint: false,
599
+ },
593
600
  inputSchema: z.object({
594
- zipPath: z.string(),
601
+ zipPath: z.string().describe("Absolute path to the TraceGist package zip file."),
595
602
  }),
596
603
  },
597
604
  async ({ zipPath }) => {
598
- const { zip, entryNames } = await readZipEntries(zipPath);
599
- const { manifest, manifestEntryName } = await tryReadManifest(zip);
600
- const { handoffMarkdown, handoffEntryName } = await tryReadHandoffMarkdown(
601
- zip,
602
- entryNames,
603
- manifest,
604
- );
605
-
606
- const overview = {
607
- zipPath,
608
- entryCount: entryNames.length,
609
- manifestEntryName,
610
- handoffEntryName,
611
- manifest,
612
- handoffPreview: handoffMarkdown ? truncateText(handoffMarkdown, MAX_PREVIEW_CHARS) : null,
613
- entries: entryNames,
614
- };
605
+ try {
606
+ const { zip, entryNames } = await readZipEntries(zipPath);
607
+ const { manifest, manifestEntryName } = await tryReadManifest(zip);
608
+ const { handoffEntryName } = await tryReadHandoffMarkdown(zip, entryNames, manifest);
609
+
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
+ };
615
619
 
616
- return {
617
- content: [{ type: "text", text: JSON.stringify(overview, null, 2) }],
618
- };
620
+ return {
621
+ content: [{ type: "text", text: JSON.stringify(overview, null, 2) }],
622
+ };
623
+ } catch (err) {
624
+ return toolError(err);
625
+ }
619
626
  },
620
627
  );
621
628
 
@@ -629,92 +636,117 @@ server.registerTool(
629
636
  "Without a `section` parameter, returns a table of contents with section names and sizes. " +
630
637
  "With a `section` parameter, returns just that section's content. " +
631
638
  'Use section name "all" to retrieve the full document.',
639
+ annotations: {
640
+ readOnlyHint: true,
641
+ destructiveHint: false,
642
+ idempotentHint: true,
643
+ openWorldHint: false,
644
+ },
632
645
  inputSchema: z.object({
633
- zipPath: z.string(),
634
- section: z.string().optional(),
646
+ zipPath: z.string().describe("Absolute path to the TraceGist package zip file."),
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
+ ),
635
653
  }),
636
654
  },
637
655
  async ({ zipPath, section }) => {
638
- const { zip, entryNames } = await readZipEntries(zipPath);
639
- const { manifest } = await tryReadManifest(zip);
640
- const { handoffMarkdown, handoffEntryName, debugCandidates } = await tryReadHandoffMarkdown(
641
- zip,
642
- entryNames,
643
- manifest,
644
- );
645
- if (!handoffMarkdown) {
646
- const hintedEntries = entryNames
647
- .filter((name) => /handoff|coding-agent/i.test(path.posix.basename(name)))
648
- .slice(0, 5);
649
- return {
650
- content: [
651
- {
652
- type: "text",
653
- text: [
654
- `No handoff markdown entry found in ${zipPath}.`,
655
- manifest?.handoffMarkdownFilename
656
- ? `Manifest handoff filename: ${manifest.handoffMarkdownFilename}`
657
- : "Manifest handoff filename: (missing)",
658
- `Matching entry candidates: ${hintedEntries.length > 0 ? hintedEntries.join(", ") : "(none)"}`,
659
- `Debug candidates: ${debugCandidates.length > 0 ? debugCandidates.join(", ") : "(none)"}`,
660
- ].join("\n"),
661
- },
662
- ],
663
- isError: true,
664
- };
665
- }
666
-
667
- const enrichedMarkdown = await injectTranscriptsIntoMarkdown(handoffMarkdown, zip, entryNames);
656
+ try {
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
+ }
668
685
 
669
- // Full document retrieval
670
- if (section && section.toLowerCase() === "all") {
671
- return {
672
- content: [
673
- {
674
- type: "text",
675
- text: `# Source: ${handoffEntryName}\n\n${enrichedMarkdown}`,
676
- },
677
- ],
678
- };
679
- }
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
+ }
680
697
 
681
- const sections = parseMarkdownSections(enrichedMarkdown);
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
+ }
682
709
 
683
- // No section requested — return table of contents
684
- if (!section) {
685
- return {
686
- content: [
687
- {
688
- type: "text",
689
- text: `# Source: ${handoffEntryName}\n\n${renderSectionToc(sections, zipPath)}`,
690
- },
691
- ],
692
- };
693
- }
710
+ const sections = parseMarkdownSections(enrichedMarkdown);
711
+
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
+ }
694
723
 
695
- // Section requested — find by case-insensitive match
696
- const sectionLower = section.toLowerCase();
697
- const match = sections.find((s) => s.name.toLowerCase() === sectionLower);
698
- if (!match) {
699
- 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
+ }
700
739
  return {
701
740
  content: [
702
741
  {
703
742
  type: "text",
704
- text: `Section "${section}" not found. Available sections: ${available}`,
743
+ text: match.content,
705
744
  },
706
745
  ],
707
- isError: true,
708
746
  };
747
+ } catch (err) {
748
+ return toolError(err);
709
749
  }
710
- return {
711
- content: [
712
- {
713
- type: "text",
714
- text: match.content,
715
- },
716
- ],
717
- };
718
750
  },
719
751
  );
720
752
 
@@ -725,24 +757,36 @@ server.registerTool(
725
757
  "Read a text file directly from inside a TraceGist package zip and return its content without writing to disk. " +
726
758
  "Common uses: `network/api-requests.jsonl` for request/response bodies, or the Playwright repro script " +
727
759
  "(its path is in the manifest's `playwrightScriptPath` field, returned by get_tracegist_package_overview).",
760
+ annotations: {
761
+ readOnlyHint: true,
762
+ destructiveHint: false,
763
+ idempotentHint: true,
764
+ openWorldHint: false,
765
+ },
728
766
  inputSchema: z.object({
729
- zipPath: z.string(),
730
- entryName: z.string(),
767
+ zipPath: z.string().describe("Absolute path to the TraceGist package zip file."),
768
+ entryName: z
769
+ .string()
770
+ .describe("Path of the file inside the zip (e.g. network/api-requests.jsonl)."),
731
771
  }),
732
772
  },
733
773
  async ({ zipPath, entryName }) => {
734
- const { zip } = await readZipEntries(zipPath);
735
- const entry = zip.file(entryName);
736
- if (!entry) {
774
+ try {
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");
737
784
  return {
738
- content: [{ type: "text", text: `Entry "${entryName}" not found in ${zipPath}.` }],
739
- isError: true,
785
+ content: [{ type: "text", text }],
740
786
  };
787
+ } catch (err) {
788
+ return toolError(err);
741
789
  }
742
- const text = await entry.async("text");
743
- return {
744
- content: [{ type: "text", text }],
745
- };
746
790
  },
747
791
  );
748
792
 
@@ -750,40 +794,474 @@ server.registerTool(
750
794
  "extract_tracegist_package_file",
751
795
  {
752
796
  description: "Extract one file from a TraceGist package zip to a local directory.",
797
+ annotations: {
798
+ readOnlyHint: false,
799
+ destructiveHint: false,
800
+ idempotentHint: true,
801
+ openWorldHint: false,
802
+ },
753
803
  inputSchema: z.object({
754
- zipPath: z.string(),
755
- entryName: z.string(),
756
- outputDirectory: z.string().optional(),
804
+ zipPath: z.string().describe("Absolute path to the TraceGist package zip file."),
805
+ entryName: z.string().describe("Path of the file inside the zip to extract."),
806
+ outputDirectory: z
807
+ .string()
808
+ .optional()
809
+ .describe("Directory to write the file to. Defaults to the zip file's directory."),
757
810
  }),
758
811
  },
759
812
  async ({ zipPath, entryName, outputDirectory }) => {
760
- const { zip } = await readZipEntries(zipPath);
761
- const entry = zip.file(entryName);
762
- if (!entry) {
813
+ try {
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);
763
835
  return {
764
- content: [{ type: "text", text: `Entry "${entryName}" not found in ${zipPath}.` }],
765
- isError: true,
836
+ content: [{ type: "text", text: `Extracted ${entryName} -> ${outputPath}` }],
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
+
852
+ /** @type {Array<{seq: number, ts: number, eventType: string, data: object}>} */
853
+ const liveEvents = [];
854
+ let liveSessionActive = false;
855
+ /** @type {{sessionId: string, url: string, title: string, startedAt: number} | null} */
856
+ let liveSessionMeta = null;
857
+ /** @type {import("ws").WebSocket | null} */
858
+ let activeConnection = null;
859
+ /** @type {Array<{questionId: string, question: string, sentAt: number}>} */
860
+ const pendingQuestions = [];
861
+ /** @type {Map<string, object>} */
862
+ const questionResponses = new Map();
863
+
864
+ /** @type {Array<{resolve: Function, reject: Function, timeout: ReturnType<typeof setTimeout>}>} */
865
+ const screenshotWaiters = [];
866
+
867
+ function startLiveShadowServer() {
868
+ let port = LIVE_SHADOW_PORT;
869
+ let attempts = 0;
870
+
871
+ function tryPort(p) {
872
+ const httpServer = createServer();
873
+ const wss = new WebSocketServer({ server: httpServer });
874
+
875
+ wss.on("connection", (ws) => {
876
+ // Only allow one connection at a time
877
+ if (activeConnection) {
878
+ try {
879
+ activeConnection.close();
880
+ } catch {
881
+ // ignore
882
+ }
883
+ }
884
+ activeConnection = ws;
885
+ console.error(`[${BRIDGE_NAME}] Live shadow: extension connected`);
886
+
887
+ ws.on("message", (raw) => {
888
+ try {
889
+ const msg = JSON.parse(String(raw));
890
+ handleExtensionMessage(msg);
891
+ } catch (err) {
892
+ console.error(`[${BRIDGE_NAME}] Live shadow: invalid message:`, err);
893
+ }
894
+ });
895
+
896
+ ws.on("close", () => {
897
+ if (activeConnection === ws) {
898
+ activeConnection = null;
899
+ if (liveSessionActive) {
900
+ liveSessionActive = false;
901
+ console.error(`[${BRIDGE_NAME}] Live shadow: session ended (extension disconnected)`);
902
+ }
903
+ }
904
+ });
905
+ });
906
+
907
+ httpServer.on("error", (err) => {
908
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === "EADDRINUSE") {
909
+ attempts++;
910
+ if (attempts < LIVE_SHADOW_PORT_RANGE) {
911
+ tryPort(p + 1);
912
+ } else {
913
+ console.error(
914
+ `[${BRIDGE_NAME}] Live shadow: all ports ${LIVE_SHADOW_PORT}-${LIVE_SHADOW_PORT + LIVE_SHADOW_PORT_RANGE - 1} in use, live shadowing disabled`,
915
+ );
916
+ }
917
+ }
918
+ });
919
+
920
+ httpServer.listen(p, "127.0.0.1", () => {
921
+ console.error(`[${BRIDGE_NAME}] Live shadow WebSocket server listening on ws://127.0.0.1:${p}`);
922
+ });
923
+ }
924
+
925
+ tryPort(port);
926
+ }
927
+
928
+ function handleExtensionMessage(msg) {
929
+ switch (msg.type) {
930
+ case "session-start":
931
+ liveSessionActive = true;
932
+ liveSessionMeta = {
933
+ sessionId: msg.sessionId,
934
+ url: msg.url,
935
+ title: msg.title,
936
+ startedAt: Date.now(),
766
937
  };
938
+ liveEvents.length = 0;
939
+ pendingQuestions.length = 0;
940
+ questionResponses.clear();
941
+ console.error(`[${BRIDGE_NAME}] Live shadow: session started (${msg.sessionId})`);
942
+ // Notify MCP clients that resources changed
943
+ try {
944
+ server.sendResourceListChanged();
945
+ } catch {
946
+ // Not all transports support notifications
947
+ }
948
+ break;
949
+
950
+ case "event":
951
+ liveEvents.push({
952
+ seq: msg.seq,
953
+ ts: msg.ts,
954
+ eventType: msg.eventType,
955
+ data: msg.data,
956
+ });
957
+ if (liveEvents.length > LIVE_SHADOW_EVENT_BUFFER_SIZE) {
958
+ liveEvents.shift();
959
+ }
960
+ break;
961
+
962
+ case "question-response":
963
+ questionResponses.set(msg.questionId, {
964
+ questionId: msg.questionId,
965
+ timestamp: msg.timestamp,
966
+ voiceBlobDataUrl: msg.voiceBlobDataUrl || null,
967
+ screenshot: msg.screenshot || null,
968
+ highlightCaptures: msg.highlightCaptures || [],
969
+ });
970
+ break;
971
+
972
+ case "question-dismissed":
973
+ questionResponses.set(msg.questionId, { dismissed: true });
974
+ break;
975
+
976
+ case "screenshot-response":
977
+ // Resolve any pending screenshot waiters
978
+ for (const waiter of screenshotWaiters.splice(0)) {
979
+ clearTimeout(waiter.timeout);
980
+ waiter.resolve(msg.screenshot || null);
981
+ }
982
+ break;
983
+
984
+ case "session-end":
985
+ liveSessionActive = false;
986
+ console.error(`[${BRIDGE_NAME}] Live shadow: session ended`);
987
+ break;
988
+ }
989
+ }
990
+
991
+ // Register live shadowing MCP resource
992
+ server.resource(
993
+ "live-session-status",
994
+ "live-session://status",
995
+ { description: "Current live shadowing session status" },
996
+ async () => ({
997
+ contents: [
998
+ {
999
+ uri: "live-session://status",
1000
+ text: JSON.stringify(
1001
+ {
1002
+ active: liveSessionActive,
1003
+ session: liveSessionMeta,
1004
+ eventCount: liveEvents.length,
1005
+ pendingQuestions: pendingQuestions.length,
1006
+ },
1007
+ null,
1008
+ 2,
1009
+ ),
1010
+ mimeType: "application/json",
1011
+ },
1012
+ ],
1013
+ }),
1014
+ );
1015
+
1016
+ // Tool: watch_live_session
1017
+ server.tool(
1018
+ "watch_live_session",
1019
+ "Watch a live TraceGist shadowing session. Returns buffered events since the given sequence number. " +
1020
+ "Poll every 3-5 seconds to watch events in real-time. " +
1021
+ "Returns sessionActive: false when the session ends.",
1022
+ {
1023
+ since_seq: z
1024
+ .number()
1025
+ .optional()
1026
+ .describe("Return events after this sequence number. Omit for all buffered events."),
1027
+ },
1028
+ async ({ since_seq }) => {
1029
+ if (!liveSessionActive && liveEvents.length === 0) {
1030
+ return toolError(
1031
+ "No active live shadowing session. Ensure the TraceGist extension has Live Shadowing enabled and is recording.",
1032
+ );
1033
+ }
1034
+
1035
+ const events =
1036
+ since_seq != null ? liveEvents.filter((e) => e.seq > since_seq) : [...liveEvents];
1037
+
1038
+ const latestSeq = liveEvents.length > 0 ? liveEvents[liveEvents.length - 1].seq : 0;
1039
+
1040
+ return {
1041
+ content: [
1042
+ {
1043
+ type: "text",
1044
+ text: JSON.stringify(
1045
+ {
1046
+ sessionActive: liveSessionActive,
1047
+ session: liveSessionMeta,
1048
+ events,
1049
+ latestSeq,
1050
+ eventCount: events.length,
1051
+ },
1052
+ null,
1053
+ 2,
1054
+ ),
1055
+ },
1056
+ ],
1057
+ };
1058
+ },
1059
+ );
1060
+
1061
+ // Tool: ask_tester_question
1062
+ server.tool(
1063
+ "ask_tester_question",
1064
+ "Ask the tester a short, concise question during a live shadowing session. " +
1065
+ "The question appears as a toast notification in their browser. " +
1066
+ "The tester responds with a voice marker (Alt+Shift+M) and optional highlight captures (Alt+Shift+S). " +
1067
+ "Use get_tester_response to check for their answer.",
1068
+ {
1069
+ question: z
1070
+ .string()
1071
+ .max(200)
1072
+ .describe(
1073
+ "Short, concise question for the tester (max 200 chars). " +
1074
+ "Example: 'Can you click the Save button again?' or 'Does the error appear with a different email?'",
1075
+ ),
1076
+ },
1077
+ async ({ question }) => {
1078
+ if (!liveSessionActive || !activeConnection) {
1079
+ return toolError("No active live shadowing session or extension not connected.");
767
1080
  }
768
- const outDir = path.resolve(outputDirectory || path.dirname(zipPath));
769
- const outputPath = path.resolve(outDir, path.basename(entryName));
770
- if (!outputPath.startsWith(outDir + path.sep) && outputPath !== outDir) {
1081
+
1082
+ const questionId = crypto.randomUUID();
1083
+ try {
1084
+ activeConnection.send(
1085
+ JSON.stringify({
1086
+ type: "agent-question",
1087
+ questionId,
1088
+ question,
1089
+ }),
1090
+ );
1091
+ } catch (err) {
1092
+ return toolError(`Failed to send question to extension: ${err}`);
1093
+ }
1094
+
1095
+ pendingQuestions.push({ questionId, question, sentAt: Date.now() });
1096
+
1097
+ return {
1098
+ content: [
1099
+ {
1100
+ type: "text",
1101
+ text: [
1102
+ `Question sent to tester (ID: ${questionId}).`,
1103
+ "",
1104
+ "The tester sees the question as a notification in their browser.",
1105
+ "They can respond with:",
1106
+ "- Voice marker: Alt/Option + Shift + M (start/stop recording)",
1107
+ "- Highlight captures: Alt/Option + Shift + S (draw on screen)",
1108
+ "",
1109
+ "Use `get_tester_response` with this question ID to check for their answer.",
1110
+ ].join("\n"),
1111
+ },
1112
+ ],
1113
+ };
1114
+ },
1115
+ );
1116
+
1117
+ // Tool: get_tester_response
1118
+ server.tool(
1119
+ "get_tester_response",
1120
+ "Check for the tester's response to a question asked during live shadowing. " +
1121
+ "Returns the tester's screenshot, highlight captures, and voice note availability.",
1122
+ {
1123
+ question_id: z.string().describe("The question ID returned by ask_tester_question."),
1124
+ },
1125
+ async ({ question_id }) => {
1126
+ const response = questionResponses.get(question_id);
1127
+
1128
+ if (!response) {
771
1129
  return {
772
1130
  content: [
773
- { type: "text", text: `Refusing to write outside output directory: ${outputPath}` },
1131
+ {
1132
+ type: "text",
1133
+ text: "No response yet. The tester may still be recording their answer. Try again in a few seconds.",
1134
+ },
774
1135
  ],
775
- isError: true,
776
1136
  };
777
1137
  }
778
- await fs.mkdir(outDir, { recursive: true });
779
- const data = await entry.async("nodebuffer");
780
- await fs.writeFile(outputPath, data);
781
- return {
782
- content: [{ type: "text", text: `Extracted ${entryName} -> ${outputPath}` }],
1138
+
1139
+ if (response.dismissed) {
1140
+ questionResponses.delete(question_id);
1141
+ return {
1142
+ content: [
1143
+ {
1144
+ type: "text",
1145
+ text: "The tester dismissed this question without responding.",
1146
+ },
1147
+ ],
1148
+ };
1149
+ }
1150
+
1151
+ // Build multimodal content with images inline
1152
+ const content = [];
1153
+
1154
+ const meta = {
1155
+ questionId: question_id,
1156
+ hasVoice: !!response.voiceBlobDataUrl,
1157
+ hasScreenshot: !!response.screenshot,
1158
+ highlightCount: response.highlightCaptures?.length || 0,
783
1159
  };
1160
+ content.push({
1161
+ type: "text",
1162
+ text: JSON.stringify(meta, null, 2),
1163
+ });
1164
+
1165
+ // Include screenshot as image
1166
+ if (response.screenshot && response.screenshot.startsWith("data:image/")) {
1167
+ const match = response.screenshot.match(/^data:([^;]+);base64,(.+)$/);
1168
+ if (match) {
1169
+ content.push({
1170
+ type: "image",
1171
+ data: match[2],
1172
+ mimeType: match[1],
1173
+ });
1174
+ }
1175
+ }
1176
+
1177
+ // Include highlight captures as images
1178
+ if (response.highlightCaptures) {
1179
+ for (const hl of response.highlightCaptures) {
1180
+ if (hl.screenshot && hl.screenshot.startsWith("data:image/")) {
1181
+ const match = hl.screenshot.match(/^data:([^;]+);base64,(.+)$/);
1182
+ if (match) {
1183
+ content.push({
1184
+ type: "image",
1185
+ data: match[2],
1186
+ mimeType: match[1],
1187
+ });
1188
+ }
1189
+ }
1190
+ }
1191
+ }
1192
+
1193
+ 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
+ });
1198
+ }
1199
+
1200
+ // Clean up after retrieval
1201
+ questionResponses.delete(question_id);
1202
+
1203
+ return { content };
784
1204
  },
785
1205
  );
786
1206
 
1207
+ // Tool: get_live_screenshot
1208
+ server.tool(
1209
+ "get_live_screenshot",
1210
+ "Capture a screenshot of the tester's current browser tab during a live shadowing session.",
1211
+ {},
1212
+ async () => {
1213
+ if (!liveSessionActive || !activeConnection) {
1214
+ return toolError("No active live shadowing session or extension not connected.");
1215
+ }
1216
+
1217
+ // Request screenshot from extension and wait for response
1218
+ const screenshotPromise = new Promise((resolve, reject) => {
1219
+ const timeout = setTimeout(() => {
1220
+ const idx = screenshotWaiters.findIndex((w) => w.resolve === resolve);
1221
+ if (idx >= 0) screenshotWaiters.splice(idx, 1);
1222
+ reject(new Error("Screenshot request timed out (5s)"));
1223
+ }, 5000);
1224
+ screenshotWaiters.push({ resolve, reject, timeout });
1225
+ });
1226
+
1227
+ try {
1228
+ activeConnection.send(JSON.stringify({ type: "request-screenshot" }));
1229
+ } catch (err) {
1230
+ // Clean up waiter
1231
+ screenshotWaiters.splice(0);
1232
+ return toolError(`Failed to request screenshot: ${err}`);
1233
+ }
1234
+
1235
+ try {
1236
+ const screenshot = await screenshotPromise;
1237
+ if (!screenshot) {
1238
+ return toolError("Extension returned empty screenshot");
1239
+ }
1240
+
1241
+ const match = String(screenshot).match(/^data:([^;]+);base64,(.+)$/);
1242
+ if (match) {
1243
+ return {
1244
+ content: [
1245
+ {
1246
+ type: "image",
1247
+ data: match[2],
1248
+ mimeType: match[1],
1249
+ },
1250
+ ],
1251
+ };
1252
+ }
1253
+
1254
+ return toolError("Screenshot data format not recognized");
1255
+ } catch (err) {
1256
+ return toolError(String(err));
1257
+ }
1258
+ },
1259
+ );
1260
+
1261
+ // ---------------------------------------------------------------------------
1262
+ // Startup
1263
+ // ---------------------------------------------------------------------------
1264
+
787
1265
  whisperDependencyWarnings = await checkWhisperDependencies();
788
1266
  console.error(
789
1267
  `[${BRIDGE_NAME}] Default package directory: ${DEFAULT_DOWNLOADS_DIR}${process.env.TRACEGIST_DIR ? " (from TRACEGIST_DIR)" : ""}`,
@@ -796,5 +1274,8 @@ if (whisperDependencyWarnings.length > 0) {
796
1274
  console.error(`[${BRIDGE_NAME}] Whisper dependencies check passed.`);
797
1275
  }
798
1276
 
1277
+ // Start the live shadow WebSocket server
1278
+ startLiveShadowServer();
1279
+
799
1280
  const transport = new StdioServerTransport();
800
1281
  await server.connect(transport);