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.
- package/bin/lib.mjs +153 -0
- package/bin/tracegist-mcp-bridge.mjs +829 -348
- package/package.json +2 -1
|
@@ -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
|
|
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
|
-
"##
|
|
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
|
-
|
|
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
|
-
"
|
|
423
|
-
"
|
|
424
|
-
"
|
|
425
|
-
"
|
|
426
|
-
"
|
|
427
|
-
"
|
|
428
|
-
"
|
|
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
|
|
433
|
-
"
|
|
434
|
-
"
|
|
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
|
|
460
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
|
574
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
|
591
|
-
"
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
zip,
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
617
|
-
|
|
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
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
return
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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: `
|
|
765
|
-
|
|
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
|
-
|
|
769
|
-
const
|
|
770
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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);
|