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 +71 -0
- package/README.md +59 -6
- package/bin/lib.mjs +478 -5
- package/bin/tracegist-mcp-bridge.mjs +399 -262
- package/package.json +3 -1
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` |
|
|
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
|
-
##
|
|
67
|
+
## Voice-note transcription
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
+
}
|