tracegist-mcp-bridge 0.2.14 → 0.3.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,71 @@
1
+ # Changelog
2
+
3
+ All notable changes to `tracegist-mcp-bridge`.
4
+
5
+ ## 0.3.0
6
+
7
+ **Breaking changes:**
8
+
9
+ - Local Whisper now runs via the bundled
10
+ [`nodejs-whisper`](https://www.npmjs.com/package/nodejs-whisper) (whisper.cpp).
11
+ The bridge no longer requires Python 3, `openai-whisper`, or system `ffmpeg`.
12
+ - An install-time C/C++ toolchain is required so `nodejs-whisper` can compile
13
+ whisper.cpp. macOS Xcode CLI tools and Linux `build-essential` are commonly
14
+ already installed; Windows users typically need to install Visual Studio
15
+ Build Tools (the "Desktop development with C++" workload) explicitly.
16
+ - **Legacy session zips with `.webm` voice notes will no longer transcribe
17
+ out of the box** on the new bridge. nodejs-whisper falls back to ffmpeg for
18
+ non-wav inputs; if ffmpeg isn't on PATH, transcription fails with a hint
19
+ pointing at the fix. Re-export the affected sessions with the matching
20
+ TraceGist extension version (which now records 16 kHz mono wav directly),
21
+ or install ffmpeg.
22
+
23
+ **Other changes:**
24
+
25
+ - The default Whisper model is `base.en`, auto-downloaded on first use
26
+ (~150 MB). Override via the `TRACEGIST_WHISPER_MODEL` env var (e.g.
27
+ `tiny.en`, `small.en`, `large-v3`).
28
+ - Removed the legacy `formatExecError` helper (unused after the Python /
29
+ ffmpeg removal).
30
+ - The local Whisper path now surfaces a re-export hint when given a non-wav
31
+ voice note, matching the existing OpenRouter error message.
32
+ - Fixed a crash in the live-shadow WebSocket server: when port 19384 (and the
33
+ retry range) was already in use, `EADDRINUSE` propagated from the http server
34
+ onto the `WebSocketServer` instance, which had no `error` listener and tore
35
+ the bridge down before the retry path could run. Now swallowed so the retry
36
+ fallback (and "all ports in use, live shadowing disabled" log) actually
37
+ surface.
38
+ - Live-shadow WebSocket now rejects upgrades from `http://` / `https://`
39
+ origins. Only `chrome-extension://` / `moz-extension://` and origin-less
40
+ clients (CLI, extensions that don't send the header) are accepted. CORS does
41
+ not apply to WebSockets, so without this any web page the user visits could
42
+ connect to `ws://127.0.0.1:19384` and read live session events / screenshots.
43
+ - `watch_live_session` now paginates: `limit` (default 100, max 500), and
44
+ responses include `nextSeq` + `hasMore`. Long sessions no longer return
45
+ thousands of events in a single response.
46
+ - `extract_tracegist_package_file` now declares an `outputSchema` and emits
47
+ `structuredContent` (`{ zipPath, entryName, outputPath, bytesWritten }`).
48
+ - New env var `TRACEGIST_DISABLE_LIVE=1` skips the live-shadow WebSocket
49
+ server. Useful when running the bridge under MCP Inspector (which spawns
50
+ many parallel children) or in CI / non-interactive contexts where live
51
+ shadowing is not needed.
52
+
53
+ ## 0.2.18
54
+
55
+ - Added `transcribeViaOpenRouter` and a unified `transcribeAudio` path that
56
+ tries OpenRouter (when `OPENROUTER_API_KEY` is set) before falling back to
57
+ local Whisper.
58
+ - Added `get_tracegist_issue_draft` MCP tool for building tracker-agnostic
59
+ issue payloads from a session package.
60
+ - Added structured handoff parsing in `lib.mjs` (`parseTier0Table`,
61
+ `parseEnvironmentLines`, `parseMarkerBlocks`, `pickPrimaryMarker`,
62
+ `severityFromSignals`, `computeIssueFingerprint`, `buildIssueDraft`).
63
+ - Fixed transcript parsing to handle both bridge-injected and
64
+ exporter-emitted formats.
65
+ - Closed a severity-classification gap for uncaught JS errors.
66
+
67
+ ## 0.2.x and earlier
68
+
69
+ Initial public release of the MCP bridge: package listing, handoff markdown
70
+ retrieval, file extraction, and Python-Whisper-based voice-note
71
+ transcription. See git history for individual changes.
package/README.md CHANGED
@@ -41,10 +41,11 @@ Set `TRACEGIST_DIR` to change where the bridge looks for packages (defaults to `
41
41
  | ------------------------------------------ | --------------------------------------------------------------- |
42
42
  | `list_tracegist_packages` | List package ZIPs in Downloads |
43
43
  | `get_tracegist_package_overview` | Manifest + truncated handoff preview |
44
- | `get_tracegist_handoff_markdown` | Full handoff markdown |
44
+ | `get_tracegist_handoff_markdown` | Full handoff markdown (with voice transcripts injected) |
45
45
  | `read_tracegist_package_file` | Read any file from the ZIP (e.g. `network/api-requests.jsonl`) |
46
46
  | `extract_tracegist_package_file` | Extract a file to disk |
47
- | `transcribe_tracegist_package_voice_notes` | Local Whisper transcription |
47
+ | `transcribe_tracegist_package_voice_notes` | Voice-note transcription (OpenRouter or local Whisper) |
48
+ | `get_tracegist_issue_draft` | Tracker-agnostic JSON for filing a Linear / GitHub / Jira issue |
48
49
  | `watch_live_session` | Poll real-time session events while recording is active |
49
50
  | `get_live_screenshot` | Request and retrieve a screenshot of the current tab |
50
51
  | `ask_tester_question` | Send a question to the tester (shown as a browser toast) |
@@ -63,8 +64,60 @@ Each session ZIP may include:
63
64
  | `network/api-requests.jsonl` | API request/response bodies (opt-in, requires Deep Diagnostics + body capture enabled) |
64
65
  | `markers/marker-NN-*/` | Per-marker screenshots, voice notes, highlight captures |
65
66
 
66
- ## Prerequisites for transcription
67
+ ## Voice-note transcription
67
68
 
68
- - Python 3
69
- - `pip install openai-whisper`
70
- - `ffmpeg`
69
+ Local Whisper is bundled — no Python, no system ffmpeg. The TraceGist extension
70
+ records voice notes as 16 kHz mono wav, which whisper.cpp accepts directly.
71
+
72
+ ### Default — local Whisper (bundled, private, no external calls)
73
+
74
+ `tracegist-mcp-bridge` depends on
75
+ [`nodejs-whisper`](https://www.npmjs.com/package/nodejs-whisper), which compiles
76
+ whisper.cpp at install time. The first transcription auto-downloads the model
77
+ (~150 MB for the default `base.en`) into the package's cache.
78
+
79
+ Override the model via env var:
80
+
81
+ ```json
82
+ {
83
+ "mcpServers": {
84
+ "tracegist": {
85
+ "command": "npx",
86
+ "args": ["-y", "tracegist-mcp-bridge"],
87
+ "env": {
88
+ "TRACEGIST_WHISPER_MODEL": "small.en"
89
+ }
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ Common models: `tiny.en`, `base.en` (default), `small.en`, `medium.en`,
96
+ `large-v3`. English-only `*.en` variants are smaller and faster for English
97
+ voice notes. Drop the `.en` suffix for multilingual.
98
+
99
+ **Install-time requirement:** a C/C++ toolchain. Xcode CLI tools (macOS) and
100
+ `build-essential` (Linux) are commonly already installed; on Windows you'll
101
+ typically need to install Visual Studio Build Tools (the "Desktop
102
+ development with C++" workload) explicitly.
103
+
104
+ ### Optional — OpenRouter (cloud, fastest first response)
105
+
106
+ Set `OPENROUTER_API_KEY` when launching the bridge. When set, OpenRouter is
107
+ tried first and local Whisper acts as fallback:
108
+
109
+ ```json
110
+ {
111
+ "mcpServers": {
112
+ "tracegist": {
113
+ "command": "npx",
114
+ "args": ["-y", "tracegist-mcp-bridge"],
115
+ "env": {
116
+ "OPENROUTER_API_KEY": "sk-or-..."
117
+ }
118
+ }
119
+ }
120
+ }
121
+ ```
122
+
123
+ The bridge logs the active paths at startup (`Voice transcription path(s): ...`).
package/bin/lib.mjs CHANGED
@@ -1,10 +1,14 @@
1
1
  import path from "node:path";
2
+ import crypto from "node:crypto";
2
3
 
3
4
  const PACKAGE_SUFFIX = "-package.zip";
4
5
  const HANDOFF_SUFFIXES = ["-coding-agent-handoff.md", "-cursor-handoff.md"];
5
6
 
6
7
  export function isTraceGistPackageFile(fileName) {
7
- return fileName.startsWith("tracegist-") && fileName.endsWith(PACKAGE_SUFFIX);
8
+ return (
9
+ (fileName.startsWith("tracegistcom-") || fileName.startsWith("tracegist-")) &&
10
+ fileName.endsWith(PACKAGE_SUFFIX)
11
+ );
8
12
  }
9
13
 
10
14
  export function normalizeZipBaseName(entryName) {
@@ -21,7 +25,7 @@ export function renderPackagesText(
21
25
  if (packages.length === 0) {
22
26
  return [
23
27
  `No TraceGist package zips found in ${directory}.`,
24
- "Expected naming pattern: tracegist-...-package.zip",
28
+ "Expected naming pattern: tracegistcom-...-package.zip",
25
29
  ].join("\n");
26
30
  }
27
31
  const countLabel =
@@ -197,9 +201,478 @@ export function toolError(err) {
197
201
  };
198
202
  }
199
203
 
200
- export function formatExecError(err) {
201
- if (err instanceof Error && err.message) return err.message;
202
- return String(err);
204
+ /**
205
+ * Extract a known audio format token from a MIME type or filename.
206
+ * Uses non-alphanumeric boundaries so substrings like "webmaster" don't match.
207
+ */
208
+ export function audioFormatFromMime(mimeType, fallback = "webm") {
209
+ const m = String(mimeType || "")
210
+ .toLowerCase()
211
+ .match(/(?:^|[^a-z0-9])(wav|mp3|m4a|ogg|flac|webm)(?:[^a-z0-9]|$)/);
212
+ return m ? m[1] : fallback;
213
+ }
214
+
215
+ /**
216
+ * Parse a boolean-ish env-var value. Treats "0", "false", "no", "off"
217
+ * (case-insensitive) and empty/undefined as falsy; everything else as truthy.
218
+ * Avoids the JS footgun where `Boolean("0") === true`, so users who set
219
+ * `MY_FLAG=0` get the "off" behavior they expect.
220
+ */
221
+ export function envFlagEnabled(value) {
222
+ if (value === undefined || value === null) return false;
223
+ const trimmed = String(value).trim();
224
+ if (trimmed === "") return false;
225
+ return !["0", "false", "no", "off"].includes(trimmed.toLowerCase());
226
+ }
227
+
228
+ /**
229
+ * Returns true if a WebSocket upgrade request should be accepted on the
230
+ * live-shadow server. Rejects http(s) page origins to prevent a malicious
231
+ * web page from connecting via DNS-rebinding-style attacks (CORS does not
232
+ * apply to WebSockets, so the page-level same-origin policy alone is not
233
+ * sufficient). Allows chrome-extension:// / moz-extension:// origins and
234
+ * missing/empty origins (the TraceGist extension and CLI clients).
235
+ *
236
+ * Note: non-browser callers can spoof the Origin header, so this defends
237
+ * specifically against the "malicious web page" threat model — local code
238
+ * with arbitrary HTTP capability is out of scope.
239
+ */
240
+ export function isAllowedLiveShadowOrigin(originHeader) {
241
+ if (!originHeader) return true;
242
+ const lower = String(originHeader).toLowerCase();
243
+ if (lower.startsWith("chrome-extension://")) return true;
244
+ if (lower.startsWith("moz-extension://")) return true;
245
+ return false;
203
246
  }
204
247
 
205
248
  export { HANDOFF_SUFFIXES };
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Issue-draft extraction
252
+ //
253
+ // The bridge's structured access to a TraceGist package is split between the
254
+ // manifest JSON (typed counts, file paths, marker metadata) and the handoff
255
+ // markdown (free-form text including AI-generated title/summary/transcripts).
256
+ // The helpers below parse the handoff back into the fields a downstream issue
257
+ // tracker (Linear / GitHub / Jira via their MCPs) needs.
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /** Parse the `## Tier 0: Quick Summary` markdown table into a key→value map. */
261
+ export function parseTier0Table(tier0Content) {
262
+ if (!tier0Content) return {};
263
+ const out = {};
264
+ for (const rawLine of tier0Content.split("\n")) {
265
+ const line = rawLine.trim();
266
+ if (!line.startsWith("|") || line.startsWith("|---") || /^\|\s*Field\s*\|/i.test(line)) {
267
+ continue;
268
+ }
269
+ const cells = line
270
+ .split("|")
271
+ .slice(1, -1)
272
+ .map((c) => c.trim());
273
+ if (cells.length === 2 && cells[0] && cells[1]) {
274
+ out[cells[0]] = cells[1];
275
+ }
276
+ }
277
+ return out;
278
+ }
279
+
280
+ /**
281
+ * Parse the `## Session Environment` bullet list into structured fields.
282
+ * Lines come from formatEnvironmentSnapshot in lib/export-agent.ts.
283
+ */
284
+ export function parseEnvironmentLines(envContent) {
285
+ if (!envContent) return null;
286
+ const env = {};
287
+ for (const rawLine of envContent.split("\n")) {
288
+ const line = rawLine.trim();
289
+ let m;
290
+ if ((m = line.match(/^-\s+Viewport:\s+(\d+)\D+(\d+)px/i))) {
291
+ env.viewport = { width: Number(m[1]), height: Number(m[2]) };
292
+ } else if ((m = line.match(/^-\s+Device pixel ratio:\s+([\d.]+)/i))) {
293
+ env.devicePixelRatio = Number(m[1]);
294
+ } else if ((m = line.match(/^-\s+Screen:\s+(\d+)\D+(\d+)px/i))) {
295
+ env.screen = { width: Number(m[1]), height: Number(m[2]) };
296
+ } else if ((m = line.match(/^-\s+User agent:\s+(.+)$/i))) {
297
+ env.userAgent = m[1].trim();
298
+ } else if ((m = line.match(/^-\s+Scroll position:\s+\((-?\d+),\s*(-?\d+)\)px/i))) {
299
+ env.scroll = { x: Number(m[1]), y: Number(m[2]) };
300
+ }
301
+ }
302
+ return Object.keys(env).length > 0 ? env : null;
303
+ }
304
+
305
+ /**
306
+ * Split a Marker Timeline section into individual marker blocks and parse the
307
+ * known `- Field: value` lines emitted by lib/export-agent.ts.
308
+ */
309
+ export function parseMarkerBlocks(markerTimelineContent) {
310
+ if (!markerTimelineContent) return [];
311
+ const lines = markerTimelineContent.split("\n");
312
+ const blocks = [];
313
+ let current = null;
314
+
315
+ const flush = () => {
316
+ if (current) blocks.push(current);
317
+ };
318
+
319
+ for (const line of lines) {
320
+ const headerMatch = line.match(/^###\s+Marker\s+(\d+)\s*\(([^)]+)\)(.*)$/i);
321
+ if (headerMatch) {
322
+ flush();
323
+ current = {
324
+ order: Number(headerMatch[1]),
325
+ timestampLabel: headerMatch[2].trim(),
326
+ flags: headerMatch[3].trim(),
327
+ isSessionContextNote: /session-context-note/i.test(headerMatch[3] || ""),
328
+ rawLines: [],
329
+ };
330
+ continue;
331
+ }
332
+ if (!current) continue;
333
+ current.rawLines.push(line);
334
+
335
+ const fieldMatch = line.match(/^-\s+([^:]+):\s*(.*)$/);
336
+ if (!fieldMatch) continue;
337
+ const key = fieldMatch[1].trim();
338
+ const value = fieldMatch[2].trim();
339
+ switch (key) {
340
+ case "Marker ID":
341
+ current.markerId = value;
342
+ break;
343
+ case "Type":
344
+ current.type = value;
345
+ break;
346
+ case "Trigger type":
347
+ current.triggerType = value;
348
+ break;
349
+ case "Trigger":
350
+ current.triggerMessage = value;
351
+ break;
352
+ case "Trigger status code":
353
+ current.triggerStatusCode = Number(value);
354
+ break;
355
+ case "Trigger URL":
356
+ current.triggerUrl = value;
357
+ break;
358
+ case "URL":
359
+ current.url = value;
360
+ break;
361
+ case "Page title":
362
+ current.pageTitle = value;
363
+ break;
364
+ case "Has transcript":
365
+ current.hasTranscript = /^yes\b/i.test(value);
366
+ break;
367
+ case "Has AI assessment":
368
+ current.hasAiAssessment = /^yes\b/i.test(value);
369
+ break;
370
+ case "AI title":
371
+ current.aiTitle = value;
372
+ break;
373
+ case "AI summary":
374
+ current.aiSummary = value;
375
+ break;
376
+ case "AI technical context":
377
+ current.aiTechnicalContext = value;
378
+ break;
379
+ case "Voice note transcript": {
380
+ // Bridge-injected format: `- Voice note transcript: "<text>"`
381
+ current.voiceTranscript = value.replace(/^"(.*)"$/, "$1");
382
+ break;
383
+ }
384
+ case "Transcript": {
385
+ // Exporter-emitted format (lib/export-agent.ts): `- Transcript:` followed
386
+ // by ` - <text>` on the next line. value is empty here; capture the
387
+ // continuation by recording a flag and reading on the next iteration.
388
+ current._awaitingTranscriptContinuation = true;
389
+ break;
390
+ }
391
+ default:
392
+ break;
393
+ }
394
+ }
395
+ flush();
396
+
397
+ for (const b of blocks) {
398
+ // Resolve `- Transcript:` continuation lines now that rawLines is complete.
399
+ if (b._awaitingTranscriptContinuation && !b.voiceTranscript) {
400
+ const idx = b.rawLines.findIndex((l) => /^-\s+Transcript:\s*$/.test(l));
401
+ if (idx >= 0 && idx + 1 < b.rawLines.length) {
402
+ const next = b.rawLines[idx + 1];
403
+ const m = next.match(/^\s{2,}-\s+(.+)$/);
404
+ if (m) b.voiceTranscript = m[1].trim();
405
+ }
406
+ }
407
+ delete b._awaitingTranscriptContinuation;
408
+ const errorIdx = b.rawLines.findIndex((l) => /^-\s+Errors\/failures in window/i.test(l));
409
+ if (errorIdx >= 0) {
410
+ const errs = [];
411
+ for (let i = errorIdx + 1; i < b.rawLines.length; i++) {
412
+ const l = b.rawLines[i];
413
+ if (/^\s{2}-\s+/.test(l)) {
414
+ errs.push(l.replace(/^\s{2}-\s+/, "").trim());
415
+ } else if (/^-\s+/.test(l)) {
416
+ break;
417
+ }
418
+ }
419
+ b.windowErrors = errs;
420
+ } else {
421
+ b.windowErrors = [];
422
+ }
423
+ }
424
+ return blocks;
425
+ }
426
+
427
+ /**
428
+ * Pick the marker most likely to drive the issue title/body.
429
+ * Preference order: manual + AI assessment + window errors → manual + AI →
430
+ * manual → first marker. Session-context-notes are deprioritized.
431
+ */
432
+ export function pickPrimaryMarker(markers) {
433
+ if (!markers || markers.length === 0) return null;
434
+ const eligible = markers.filter((m) => !m.isSessionContextNote);
435
+ const pool = eligible.length > 0 ? eligible : markers;
436
+ const score = (m) => {
437
+ let s = 0;
438
+ if (m.type === "manual") s += 4;
439
+ if (m.hasAiAssessment) s += 2;
440
+ if (m.hasTranscript) s += 1;
441
+ if (m.windowErrors && m.windowErrors.length > 0) s += 3;
442
+ if (m.triggerStatusCode && m.triggerStatusCode >= 500) s += 2;
443
+ return s;
444
+ };
445
+ return [...pool].sort((a, b) => {
446
+ const ds = score(b) - score(a);
447
+ if (ds !== 0) return ds;
448
+ return (a.order || 0) - (b.order || 0);
449
+ })[0];
450
+ }
451
+
452
+ /**
453
+ * Heuristic severity from notableEvents counts and primary-marker triggers.
454
+ * Returns { severity: "high"|"medium"|"low", rationale: string[] }.
455
+ */
456
+ export function severityFromSignals({ notableEvents, primaryMarker, markers }) {
457
+ const rationale = [];
458
+ const networkErrors = notableEvents?.networkErrors || [];
459
+ const consoleErrors = notableEvents?.consoleErrors || [];
460
+ const windowErrors = primaryMarker?.windowErrors || [];
461
+ const has5xx = networkErrors.some((e) => / 5\d{2}\b/.test(String(e.message || "")));
462
+ const hasAuto5xx = (markers || []).some(
463
+ (m) => m.type === "auto" && m.triggerStatusCode && m.triggerStatusCode >= 500,
464
+ );
465
+ const SEVERE_JS_ERROR =
466
+ /uncaught|unhandled|jserror|js-error|TypeError|ReferenceError|SyntaxError/i;
467
+ const hasUncaughtMarker = (markers || []).some(
468
+ (m) => m.type === "auto" && SEVERE_JS_ERROR.test(m.triggerType || ""),
469
+ );
470
+ const hasSevereConsole = consoleErrors.some((e) =>
471
+ SEVERE_JS_ERROR.test(String(e?.message || e?.type || "")),
472
+ );
473
+
474
+ let severity = "low";
475
+ if (has5xx || hasAuto5xx) {
476
+ severity = "high";
477
+ rationale.push("server error (5xx) observed");
478
+ }
479
+ if (hasUncaughtMarker) {
480
+ severity = "high";
481
+ rationale.push("uncaught JS error triggered an auto-marker");
482
+ }
483
+ if (hasSevereConsole) {
484
+ severity = "high";
485
+ rationale.push("severe JS error in console (TypeError/ReferenceError/uncaught/etc.)");
486
+ }
487
+ if (severity !== "high" && windowErrors.length > 0) {
488
+ // Errors observed in the marker's ±5s window are direct evidence the marker
489
+ // captured a real failure, even if notableEvents looks empty.
490
+ severity = "medium";
491
+ }
492
+ // A single benign console error alone stays "low" — they're often noise
493
+ // (deprecation warnings, third-party scripts). networkErrors of any count and
494
+ // >1 console errors bump to medium.
495
+ if (severity !== "high" && (networkErrors.length > 0 || consoleErrors.length > 1)) {
496
+ severity = "medium";
497
+ }
498
+ if (severity !== "low") {
499
+ rationale.push(
500
+ `${networkErrors.length} notable network error(s), ${consoleErrors.length} console error(s)`,
501
+ );
502
+ }
503
+ if (windowErrors.length > 0) {
504
+ rationale.push(`primary marker window contains ${windowErrors.length} error(s)`);
505
+ }
506
+ if (rationale.length === 0) {
507
+ rationale.push("no errors detected in notable events or primary marker window");
508
+ }
509
+ return { severity, rationale };
510
+ }
511
+
512
+ // Normalize numeric and hex-id segments so cosmetically-different inputs
513
+ // (e.g. /users/123 vs /users/456, "request 7d3f4a8b" vs "request a1b2c3d4")
514
+ // hash the same.
515
+ function normalizeForFingerprint(s) {
516
+ return String(s || "")
517
+ .toLowerCase()
518
+ .replace(/[a-f0-9]{8,}/g, "ID")
519
+ .replace(/\b\d+\b/g, "N")
520
+ .replace(/\s+/g, " ")
521
+ .trim();
522
+ }
523
+
524
+ /** Stable 16-hex fingerprint for dedup against existing tracker issues. */
525
+ export function computeIssueFingerprint(url, errorMessage) {
526
+ let urlPath = "";
527
+ try {
528
+ if (url) urlPath = new URL(url).pathname.replace(/\/+$/, "") || "/";
529
+ } catch {
530
+ urlPath = String(url || "").trim();
531
+ }
532
+ const normalizedPath = normalizeForFingerprint(urlPath);
533
+ const normalizedError = normalizeForFingerprint(errorMessage).slice(0, 200);
534
+ const input = `${normalizedPath}|${normalizedError}`;
535
+ return crypto.createHash("sha1").update(input).digest("hex").slice(0, 16);
536
+ }
537
+
538
+ /**
539
+ * Build a tracker-agnostic issue draft from a manifest + parsed handoff.
540
+ * Pure: no I/O, no zip access. Caller supplies the inputs.
541
+ */
542
+ export function buildIssueDraft({ zipPath, manifest, handoffMarkdown }) {
543
+ const sections = parseMarkdownSections(handoffMarkdown || "");
544
+ const sectionByName = new Map(sections.map((s) => [s.name.toLowerCase(), s]));
545
+
546
+ const tier0 = sectionByName.get("tier 0: quick summary");
547
+ const tier0Fields = parseTier0Table(tier0?.content || "");
548
+
549
+ const markerSection = sectionByName.get("marker timeline");
550
+ const markers = parseMarkerBlocks(markerSection?.content || "");
551
+ const primaryMarker = pickPrimaryMarker(markers);
552
+
553
+ const envSection = sectionByName.get("session environment");
554
+ const environment = parseEnvironmentLines(envSection?.content || "");
555
+
556
+ const sessionUrl = tier0Fields.URL || primaryMarker?.url || null;
557
+ const notableEvents = manifest?.notableEvents || { consoleErrors: [], networkErrors: [] };
558
+ const { severity, rationale } = severityFromSignals({
559
+ notableEvents,
560
+ primaryMarker,
561
+ markers,
562
+ });
563
+
564
+ const topError =
565
+ notableEvents.networkErrors[0] ||
566
+ notableEvents.consoleErrors[0] ||
567
+ (primaryMarker?.triggerMessage
568
+ ? {
569
+ type: primaryMarker.triggerType || "trigger",
570
+ message: primaryMarker.triggerMessage,
571
+ timestampLabel: primaryMarker.timestampLabel,
572
+ }
573
+ : null) ||
574
+ (primaryMarker?.windowErrors?.[0]
575
+ ? { type: "window-error", message: primaryMarker.windowErrors[0] }
576
+ : null);
577
+
578
+ let title = primaryMarker?.aiTitle?.trim();
579
+ if (!title && primaryMarker?.voiceTranscript) {
580
+ title = primaryMarker.voiceTranscript.split(/[.?!]/)[0].slice(0, 100).trim();
581
+ }
582
+ if (!title && topError?.message) {
583
+ title = `${topError.type ? `${topError.type}: ` : ""}${topError.message}`.slice(0, 120);
584
+ }
585
+ if (!title) {
586
+ const path = (() => {
587
+ try {
588
+ return sessionUrl ? new URL(sessionUrl).pathname : "";
589
+ } catch {
590
+ return "";
591
+ }
592
+ })();
593
+ title = `TraceGist session bug${path ? ` on ${path}` : ""}`;
594
+ }
595
+
596
+ const expected = primaryMarker?.aiSummary || null;
597
+ const actual =
598
+ primaryMarker?.aiTechnicalContext ||
599
+ (primaryMarker?.windowErrors?.length ? primaryMarker.windowErrors[0] : null) ||
600
+ topError?.message ||
601
+ null;
602
+
603
+ const reproductionSteps = [];
604
+ if (manifest?.pythonPlaywrightScriptPath) {
605
+ reproductionSteps.push(
606
+ `Run the included Python Playwright repro: \`python ${manifest.pythonPlaywrightScriptPath}\``,
607
+ );
608
+ }
609
+ if (manifest?.playwrightScriptPath) {
610
+ reproductionSteps.push(
611
+ `Or run the TypeScript repro: \`npx playwright test ${manifest.playwrightScriptPath}\``,
612
+ );
613
+ }
614
+ if (primaryMarker?.voiceTranscript) {
615
+ reproductionSteps.push(`Tester voice note: "${primaryMarker.voiceTranscript}"`);
616
+ }
617
+
618
+ const evidencePaths = [];
619
+ if (primaryMarker?.markerId && manifest?.markers) {
620
+ const manifestMarker = manifest.markers.find((m) => m.markerId === primaryMarker.markerId);
621
+ if (manifestMarker) {
622
+ const dir = manifestMarker.markerDirectory;
623
+ for (const f of manifestMarker.files || []) {
624
+ if (f.filename) evidencePaths.push(`${dir}/${f.filename}`);
625
+ }
626
+ }
627
+ }
628
+
629
+ const fingerprint = computeIssueFingerprint(sessionUrl, topError?.message);
630
+
631
+ const notes = [];
632
+ if (!handoffMarkdown) notes.push("handoff markdown was not present in the package");
633
+ if (!primaryMarker) notes.push("no markers detected; title/body fall back to URL only");
634
+ if (primaryMarker && !primaryMarker.hasAiAssessment) {
635
+ notes.push(
636
+ "primary marker has no AI assessment — expected/actual are best-effort from logs and transcript",
637
+ );
638
+ }
639
+ if (!environment) notes.push("session environment section missing; environment field is null");
640
+
641
+ return {
642
+ zipPath: zipPath || null,
643
+ title,
644
+ severity,
645
+ severityRationale: rationale,
646
+ sessionUrl,
647
+ pageTitle: primaryMarker?.pageTitle || null,
648
+ sessionStartIso: manifest?.sessionStartTimeIso || null,
649
+ duration: tier0Fields.Duration || null,
650
+ environment,
651
+ expected,
652
+ actual,
653
+ topError,
654
+ reproductionSteps,
655
+ evidencePaths,
656
+ primaryMarker: primaryMarker
657
+ ? {
658
+ markerId: primaryMarker.markerId,
659
+ order: primaryMarker.order,
660
+ type: primaryMarker.type,
661
+ timestampLabel: primaryMarker.timestampLabel,
662
+ hasAiAssessment: !!primaryMarker.hasAiAssessment,
663
+ hasTranscript: !!primaryMarker.hasTranscript,
664
+ }
665
+ : null,
666
+ counts: {
667
+ markers: manifest?.markerCount ?? markers.length,
668
+ networkErrors: notableEvents.networkErrors.length,
669
+ consoleErrors: notableEvents.consoleErrors.length,
670
+ },
671
+ playwrightScripts: {
672
+ typescript: manifest?.playwrightScriptPath || null,
673
+ python: manifest?.pythonPlaywrightScriptPath || null,
674
+ },
675
+ fingerprint,
676
+ notes,
677
+ };
678
+ }