markupr 2.4.0 → 2.6.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.
@@ -247,8 +247,8 @@ var SessionStore = class {
247
247
  var sessionStore = new SessionStore();
248
248
 
249
249
  // src/mcp/tools/captureScreenshot.ts
250
- function register(server2) {
251
- server2.tool(
250
+ function register(server) {
251
+ server.tool(
252
252
  "capture_screenshot",
253
253
  "Take a screenshot of the current screen, optimize it, and save to the session directory. Returns a markdown image reference.",
254
254
  {
@@ -1869,6 +1869,433 @@ var WhisperService = class extends EventEmitter {
1869
1869
  };
1870
1870
  var whisperService = new WhisperService();
1871
1871
 
1872
+ // src/main/output/templates/registry.ts
1873
+ var TemplateRegistryImpl = class {
1874
+ templates = /* @__PURE__ */ new Map();
1875
+ /**
1876
+ * Register a template. Overwrites any existing template with the same name.
1877
+ */
1878
+ register(template) {
1879
+ this.templates.set(template.name, template);
1880
+ }
1881
+ /**
1882
+ * Get a template by name. Returns undefined if not found.
1883
+ */
1884
+ get(name) {
1885
+ return this.templates.get(name);
1886
+ }
1887
+ /**
1888
+ * Check if a template with the given name exists.
1889
+ */
1890
+ has(name) {
1891
+ return this.templates.has(name);
1892
+ }
1893
+ /**
1894
+ * List all registered template names.
1895
+ */
1896
+ list() {
1897
+ return Array.from(this.templates.keys());
1898
+ }
1899
+ /**
1900
+ * List all registered templates with their descriptions.
1901
+ */
1902
+ listWithDescriptions() {
1903
+ return Array.from(this.templates.values()).map((t) => ({
1904
+ name: t.name,
1905
+ description: t.description,
1906
+ fileExtension: t.fileExtension
1907
+ }));
1908
+ }
1909
+ /**
1910
+ * Get the default template name.
1911
+ */
1912
+ getDefault() {
1913
+ return "markdown";
1914
+ }
1915
+ };
1916
+ var templateRegistry = new TemplateRegistryImpl();
1917
+
1918
+ // src/main/output/templates/helpers.ts
1919
+ import * as path3 from "path";
1920
+ function formatTimestamp(seconds) {
1921
+ const totalSeconds = Math.max(0, Math.floor(seconds));
1922
+ const mins = Math.floor(totalSeconds / 60);
1923
+ const secs = totalSeconds % 60;
1924
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
1925
+ }
1926
+ function formatDuration(ms) {
1927
+ const totalSeconds = Math.floor(ms / 1e3);
1928
+ const mins = Math.floor(totalSeconds / 60);
1929
+ const secs = totalSeconds % 60;
1930
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
1931
+ }
1932
+ function formatDate(date) {
1933
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
1934
+ const month = months[date.getMonth()];
1935
+ const day = date.getDate();
1936
+ const year = date.getFullYear();
1937
+ const rawHours = date.getHours();
1938
+ const ampm = rawHours >= 12 ? "PM" : "AM";
1939
+ const hours = rawHours % 12 || 12;
1940
+ const minutes = date.getMinutes().toString().padStart(2, "0");
1941
+ return `${month} ${day}, ${year} at ${hours}:${minutes} ${ampm}`;
1942
+ }
1943
+ function generateSegmentTitle(text) {
1944
+ const firstSentence = text.split(/[.!?]/)[0].trim();
1945
+ if (firstSentence.length <= 60) return firstSentence;
1946
+ return firstSentence.slice(0, 57) + "...";
1947
+ }
1948
+ function wrapTranscription(transcription) {
1949
+ if (!transcription.includes(".") && !transcription.includes("!") && !transcription.includes("?")) {
1950
+ return transcription;
1951
+ }
1952
+ const sentences = transcription.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
1953
+ if (sentences.length <= 1) return transcription;
1954
+ return sentences.join("\n> ");
1955
+ }
1956
+ function computeRelativeFramePath(framePath, sessionDir) {
1957
+ if (!path3.isAbsolute(framePath)) {
1958
+ return framePath;
1959
+ }
1960
+ return path3.relative(sessionDir, framePath);
1961
+ }
1962
+ function computeSessionDuration(segments) {
1963
+ if (segments.length === 0) return "0:00";
1964
+ return formatDuration(
1965
+ (segments[segments.length - 1].endTime - segments[0].startTime) * 1e3
1966
+ );
1967
+ }
1968
+ function mapFramesToSegments(segments, frames) {
1969
+ const map = /* @__PURE__ */ new Map();
1970
+ for (const frame of frames) {
1971
+ let bestIndex = 0;
1972
+ let bestDistance = Infinity;
1973
+ for (let i = 0; i < segments.length; i++) {
1974
+ const seg = segments[i];
1975
+ if (frame.timestamp >= seg.startTime && frame.timestamp <= seg.endTime) {
1976
+ bestIndex = i;
1977
+ bestDistance = 0;
1978
+ break;
1979
+ }
1980
+ const distance = Math.abs(frame.timestamp - seg.startTime);
1981
+ if (distance < bestDistance) {
1982
+ bestDistance = distance;
1983
+ bestIndex = i;
1984
+ }
1985
+ }
1986
+ const existing = map.get(bestIndex) || [];
1987
+ existing.push(frame);
1988
+ map.set(bestIndex, existing);
1989
+ }
1990
+ for (const [, frameList] of map) {
1991
+ frameList.sort((a, b) => a.timestamp - b.timestamp);
1992
+ }
1993
+ return map;
1994
+ }
1995
+
1996
+ // src/main/output/templates/markdown.ts
1997
+ var REPORT_SUPPORT_LINE2 = "*If this report saved you time, support development: [Ko-fi](https://ko-fi.com/eddiesanjuan)*";
1998
+ var markdownTemplate = {
1999
+ name: "markdown",
2000
+ description: "Default Markdown format \u2014 AI-ready, llms.txt-inspired structured document",
2001
+ fileExtension: ".md",
2002
+ render(context) {
2003
+ const { result, sessionDir, timestamp } = context;
2004
+ const { transcriptSegments, extractedFrames } = result;
2005
+ const sessionTimestamp = formatDate(new Date(timestamp ?? Date.now()));
2006
+ const sessionDuration = computeSessionDuration(transcriptSegments);
2007
+ let md = `# markupr Session \u2014 ${sessionTimestamp}
2008
+ `;
2009
+ md += `> Segments: ${transcriptSegments.length} | Frames: ${extractedFrames.length} | Duration: ${sessionDuration}
2010
+
2011
+ `;
2012
+ if (transcriptSegments.length === 0) {
2013
+ md += `_No speech was detected during this recording._
2014
+ `;
2015
+ return { content: md, fileExtension: ".md" };
2016
+ }
2017
+ md += `## Transcript
2018
+
2019
+ `;
2020
+ const segmentFrameMap = mapFramesToSegments(transcriptSegments, extractedFrames);
2021
+ for (let i = 0; i < transcriptSegments.length; i++) {
2022
+ const segment = transcriptSegments[i];
2023
+ const formattedTime = formatTimestamp(segment.startTime);
2024
+ const title = generateSegmentTitle(segment.text);
2025
+ md += `### [${formattedTime}] ${title}
2026
+ `;
2027
+ md += `> ${wrapTranscription(segment.text)}
2028
+
2029
+ `;
2030
+ const frames = segmentFrameMap.get(i);
2031
+ if (frames && frames.length > 0) {
2032
+ for (const frame of frames) {
2033
+ const frameTimestamp = formatTimestamp(frame.timestamp);
2034
+ const relativePath = computeRelativeFramePath(frame.path, sessionDir);
2035
+ md += `![Frame at ${frameTimestamp}](${relativePath})
2036
+
2037
+ `;
2038
+ }
2039
+ }
2040
+ }
2041
+ md += `---
2042
+ *Generated by [markupr](https://markupr.com)*
2043
+ ${REPORT_SUPPORT_LINE2}
2044
+ `;
2045
+ return { content: md, fileExtension: ".md" };
2046
+ }
2047
+ };
2048
+
2049
+ // src/main/output/templates/json.ts
2050
+ var jsonTemplate = {
2051
+ name: "json",
2052
+ description: "Structured JSON output for programmatic consumption",
2053
+ fileExtension: ".json",
2054
+ render(context) {
2055
+ const { result, sessionDir, timestamp } = context;
2056
+ const { transcriptSegments, extractedFrames } = result;
2057
+ const segmentFrameMap = mapFramesToSegments(transcriptSegments, extractedFrames);
2058
+ const output = {
2059
+ version: "1.0",
2060
+ generator: "markupr",
2061
+ timestamp: new Date(timestamp ?? Date.now()).toISOString(),
2062
+ summary: {
2063
+ segments: transcriptSegments.length,
2064
+ frames: extractedFrames.length,
2065
+ duration: computeSessionDuration(transcriptSegments)
2066
+ },
2067
+ segments: transcriptSegments.map((segment, i) => {
2068
+ const frames = segmentFrameMap.get(i) || [];
2069
+ return {
2070
+ text: segment.text,
2071
+ startTime: segment.startTime,
2072
+ endTime: segment.endTime,
2073
+ confidence: segment.confidence,
2074
+ frames: frames.map((f) => ({
2075
+ path: computeRelativeFramePath(f.path, sessionDir),
2076
+ timestamp: f.timestamp,
2077
+ reason: f.reason
2078
+ }))
2079
+ };
2080
+ })
2081
+ };
2082
+ return {
2083
+ content: JSON.stringify(output, null, 2),
2084
+ fileExtension: ".json"
2085
+ };
2086
+ }
2087
+ };
2088
+
2089
+ // src/main/output/templates/github-issue.ts
2090
+ var githubIssueTemplate = {
2091
+ name: "github-issue",
2092
+ description: "GitHub-flavored Markdown optimized for issue bodies with task lists and collapsible details",
2093
+ fileExtension: ".md",
2094
+ render(context) {
2095
+ const { result, sessionDir, timestamp } = context;
2096
+ const { transcriptSegments, extractedFrames } = result;
2097
+ const sessionTimestamp = formatDate(new Date(timestamp ?? Date.now()));
2098
+ const duration = computeSessionDuration(transcriptSegments);
2099
+ let md = `## Feedback Report
2100
+
2101
+ `;
2102
+ md += `> Captured by [markupr](https://markupr.com) on ${sessionTimestamp}
2103
+ `;
2104
+ md += `> ${transcriptSegments.length} segments | ${extractedFrames.length} frames | Duration: ${duration}
2105
+
2106
+ `;
2107
+ if (transcriptSegments.length === 0) {
2108
+ md += `_No feedback was captured during this recording._
2109
+ `;
2110
+ return { content: md, fileExtension: ".md" };
2111
+ }
2112
+ md += `### Action Items
2113
+
2114
+ `;
2115
+ for (const segment of transcriptSegments) {
2116
+ const title = generateSegmentTitle(segment.text);
2117
+ md += `- [ ] ${title}
2118
+ `;
2119
+ }
2120
+ md += `
2121
+ `;
2122
+ md += `### Details
2123
+
2124
+ `;
2125
+ const segmentFrameMap = mapFramesToSegments(transcriptSegments, extractedFrames);
2126
+ for (let i = 0; i < transcriptSegments.length; i++) {
2127
+ const segment = transcriptSegments[i];
2128
+ const formattedTime = formatTimestamp(segment.startTime);
2129
+ const title = generateSegmentTitle(segment.text);
2130
+ md += `<details>
2131
+ `;
2132
+ md += `<summary><strong>[${formattedTime}] ${title}</strong></summary>
2133
+
2134
+ `;
2135
+ md += `${segment.text}
2136
+
2137
+ `;
2138
+ const frames = segmentFrameMap.get(i);
2139
+ if (frames && frames.length > 0) {
2140
+ for (const frame of frames) {
2141
+ const relativePath = computeRelativeFramePath(frame.path, sessionDir);
2142
+ md += `![Screenshot](${relativePath})
2143
+
2144
+ `;
2145
+ }
2146
+ }
2147
+ md += `</details>
2148
+
2149
+ `;
2150
+ }
2151
+ md += `---
2152
+ _Generated by [markupr](https://markupr.com)_
2153
+ `;
2154
+ return { content: md, fileExtension: ".md" };
2155
+ }
2156
+ };
2157
+
2158
+ // src/main/output/templates/linear.ts
2159
+ var linearTemplate = {
2160
+ name: "linear",
2161
+ description: "Linear-compatible Markdown for issue descriptions",
2162
+ fileExtension: ".md",
2163
+ render(context) {
2164
+ const { result, sessionDir, timestamp } = context;
2165
+ const { transcriptSegments, extractedFrames } = result;
2166
+ const sessionTimestamp = formatDate(new Date(timestamp ?? Date.now()));
2167
+ const duration = computeSessionDuration(transcriptSegments);
2168
+ let md = `**Feedback Report** \u2014 ${sessionTimestamp}
2169
+ `;
2170
+ md += `${transcriptSegments.length} segments | ${extractedFrames.length} frames | Duration: ${duration}
2171
+
2172
+ `;
2173
+ if (transcriptSegments.length === 0) {
2174
+ md += `_No feedback was captured during this recording._
2175
+ `;
2176
+ return { content: md, fileExtension: ".md" };
2177
+ }
2178
+ md += `**Action Items**
2179
+
2180
+ `;
2181
+ for (const segment of transcriptSegments) {
2182
+ const title = generateSegmentTitle(segment.text);
2183
+ md += `- [ ] ${title}
2184
+ `;
2185
+ }
2186
+ md += `
2187
+ ---
2188
+
2189
+ `;
2190
+ const segmentFrameMap = mapFramesToSegments(transcriptSegments, extractedFrames);
2191
+ for (let i = 0; i < transcriptSegments.length; i++) {
2192
+ const segment = transcriptSegments[i];
2193
+ const formattedTime = formatTimestamp(segment.startTime);
2194
+ const title = generateSegmentTitle(segment.text);
2195
+ md += `### [${formattedTime}] ${title}
2196
+
2197
+ `;
2198
+ md += `> ${segment.text}
2199
+
2200
+ `;
2201
+ const frames = segmentFrameMap.get(i);
2202
+ if (frames && frames.length > 0) {
2203
+ for (const frame of frames) {
2204
+ const relativePath = computeRelativeFramePath(frame.path, sessionDir);
2205
+ md += `![Screenshot](${relativePath})
2206
+
2207
+ `;
2208
+ }
2209
+ }
2210
+ }
2211
+ md += `---
2212
+ _Captured by [markupr](https://markupr.com)_
2213
+ `;
2214
+ return { content: md, fileExtension: ".md" };
2215
+ }
2216
+ };
2217
+
2218
+ // src/main/output/templates/jira.ts
2219
+ var jiraTemplate = {
2220
+ name: "jira",
2221
+ description: "Jira wiki markup with panels, tables, and {code} blocks",
2222
+ fileExtension: ".jira",
2223
+ render(context) {
2224
+ const { result, sessionDir, timestamp } = context;
2225
+ const { transcriptSegments, extractedFrames } = result;
2226
+ const sessionTimestamp = formatDate(new Date(timestamp ?? Date.now()));
2227
+ const duration = computeSessionDuration(transcriptSegments);
2228
+ let content = `h1. Feedback Report
2229
+
2230
+ `;
2231
+ content += `{panel:title=Session Info|borderStyle=solid|borderColor=#ccc}
2232
+ `;
2233
+ content += `*Captured:* ${sessionTimestamp}
2234
+ `;
2235
+ content += `*Segments:* ${transcriptSegments.length} | *Frames:* ${extractedFrames.length} | *Duration:* ${duration}
2236
+ `;
2237
+ content += `{panel}
2238
+
2239
+ `;
2240
+ if (transcriptSegments.length === 0) {
2241
+ content += `_No feedback was captured during this recording._
2242
+ `;
2243
+ return { content, fileExtension: ".jira" };
2244
+ }
2245
+ content += `h2. Summary
2246
+
2247
+ `;
2248
+ content += `||#||Timestamp||Feedback||
2249
+ `;
2250
+ for (let i = 0; i < transcriptSegments.length; i++) {
2251
+ const segment = transcriptSegments[i];
2252
+ const formattedTime = formatTimestamp(segment.startTime);
2253
+ const title = generateSegmentTitle(segment.text);
2254
+ content += `|${i + 1}|${formattedTime}|${title}|
2255
+ `;
2256
+ }
2257
+ content += `
2258
+ `;
2259
+ content += `h2. Details
2260
+
2261
+ `;
2262
+ const segmentFrameMap = mapFramesToSegments(transcriptSegments, extractedFrames);
2263
+ for (let i = 0; i < transcriptSegments.length; i++) {
2264
+ const segment = transcriptSegments[i];
2265
+ const formattedTime = formatTimestamp(segment.startTime);
2266
+ const title = generateSegmentTitle(segment.text);
2267
+ content += `h3. \\[${formattedTime}\\] ${title}
2268
+
2269
+ `;
2270
+ content += `{quote}
2271
+ ${segment.text}
2272
+ {quote}
2273
+
2274
+ `;
2275
+ const frames = segmentFrameMap.get(i);
2276
+ if (frames && frames.length > 0) {
2277
+ for (const frame of frames) {
2278
+ const relativePath = computeRelativeFramePath(frame.path, sessionDir);
2279
+ content += `!${relativePath}|thumbnail!
2280
+
2281
+ `;
2282
+ }
2283
+ }
2284
+ }
2285
+ content += `----
2286
+ _Generated by [markupr|https://markupr.com]_
2287
+ `;
2288
+ return { content, fileExtension: ".jira" };
2289
+ }
2290
+ };
2291
+
2292
+ // src/main/output/templates/index.ts
2293
+ templateRegistry.register(markdownTemplate);
2294
+ templateRegistry.register(jsonTemplate);
2295
+ templateRegistry.register(githubIssueTemplate);
2296
+ templateRegistry.register(linearTemplate);
2297
+ templateRegistry.register(jiraTemplate);
2298
+
1872
2299
  // src/cli/CLIPipeline.ts
1873
2300
  var CLIPipeline = class _CLIPipeline {
1874
2301
  options;
@@ -1936,12 +2363,29 @@ var CLIPipeline = class _CLIPipeline {
1936
2363
  extractedFrames,
1937
2364
  reportPath: this.options.outputDir
1938
2365
  };
1939
- const generator = new MarkdownGeneratorImpl();
1940
- const markdown = generator.generateFromPostProcess(result, this.options.outputDir);
1941
- const outputFilename = this.generateOutputFilename();
2366
+ let reportContent;
2367
+ let reportExtension = ".md";
2368
+ const templateName = this.options.template;
2369
+ if (templateName && templateName !== "markdown") {
2370
+ const template = templateRegistry.get(templateName);
2371
+ if (!template) {
2372
+ const available = templateRegistry.list().join(", ");
2373
+ throw new CLIPipelineError(
2374
+ `Unknown template "${templateName}". Available: ${available}`,
2375
+ "user"
2376
+ );
2377
+ }
2378
+ const output = template.render({ result, sessionDir: this.options.outputDir });
2379
+ reportContent = output.content;
2380
+ reportExtension = output.fileExtension;
2381
+ } else {
2382
+ const generator = new MarkdownGeneratorImpl();
2383
+ reportContent = generator.generateFromPostProcess(result, this.options.outputDir);
2384
+ }
2385
+ const outputFilename = this.generateOutputFilename(reportExtension);
1942
2386
  const outputPath = join5(this.options.outputDir, outputFilename);
1943
2387
  try {
1944
- await writeFile2(outputPath, markdown, "utf-8");
2388
+ await writeFile2(outputPath, reportContent, "utf-8");
1945
2389
  } catch (error) {
1946
2390
  const code = error.code;
1947
2391
  throw new CLIPipelineError(
@@ -2238,7 +2682,7 @@ var CLIPipeline = class _CLIPipeline {
2238
2682
  /**
2239
2683
  * Generate the output filename based on the video filename and current date (UTC).
2240
2684
  */
2241
- generateOutputFilename() {
2685
+ generateOutputFilename(extension = ".md") {
2242
2686
  const videoName = basename3(this.options.videoPath).replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-");
2243
2687
  const now = /* @__PURE__ */ new Date();
2244
2688
  const dateStr = [
@@ -2251,7 +2695,8 @@ var CLIPipeline = class _CLIPipeline {
2251
2695
  String(now.getUTCMinutes()).padStart(2, "0"),
2252
2696
  String(now.getUTCSeconds()).padStart(2, "0")
2253
2697
  ].join("");
2254
- return `${videoName}-feedback-${dateStr}-${timeStr}.md`;
2698
+ const ext = extension.startsWith(".") ? extension : `.${extension}`;
2699
+ return `${videoName}-feedback-${dateStr}-${timeStr}${ext}`;
2255
2700
  }
2256
2701
  };
2257
2702
  var CLIPipelineError = class extends Error {
@@ -2264,16 +2709,19 @@ var CLIPipelineError = class extends Error {
2264
2709
  };
2265
2710
 
2266
2711
  // src/mcp/tools/captureWithVoice.ts
2267
- function register2(server2) {
2268
- server2.tool(
2712
+ function register2(server) {
2713
+ server.tool(
2269
2714
  "capture_with_voice",
2270
2715
  "Record screen and voice for a specified duration, then run the full markupr pipeline to produce a structured feedback report.",
2271
2716
  {
2272
2717
  duration: z2.number().min(3).max(300).describe("Recording duration in seconds (3-300)"),
2273
2718
  outputDir: z2.string().optional().describe("Output directory (default: session directory)"),
2274
- skipFrames: z2.boolean().optional().default(false).describe("Skip frame extraction")
2719
+ skipFrames: z2.boolean().optional().default(false).describe("Skip frame extraction"),
2720
+ template: z2.string().optional().describe(
2721
+ `Output template (default: markdown). Options: ${templateRegistry.list().join(", ")}`
2722
+ )
2275
2723
  },
2276
- async ({ duration, outputDir, skipFrames }) => {
2724
+ async ({ duration, outputDir, skipFrames, template }) => {
2277
2725
  try {
2278
2726
  const session = await sessionStore.create();
2279
2727
  const sessionDir = sessionStore.getSessionDir(session.id);
@@ -2286,6 +2734,7 @@ function register2(server2) {
2286
2734
  videoPath,
2287
2735
  outputDir: pipelineOutputDir,
2288
2736
  skipFrames,
2737
+ template,
2289
2738
  verbose: false
2290
2739
  },
2291
2740
  (msg) => log(msg)
@@ -2327,17 +2776,20 @@ function register2(server2) {
2327
2776
  // src/mcp/tools/analyzeVideo.ts
2328
2777
  import { z as z3 } from "zod";
2329
2778
  import { stat as stat4 } from "fs/promises";
2330
- function register3(server2) {
2331
- server2.tool(
2779
+ function register3(server) {
2780
+ server.tool(
2332
2781
  "analyze_video",
2333
2782
  "Process an existing video file through the markupr pipeline. Generates a structured markdown report with transcript, key moments, and extracted frames.",
2334
2783
  {
2335
2784
  videoPath: z3.string().describe("Absolute path to the video file"),
2336
2785
  audioPath: z3.string().optional().describe("Separate audio file path (if not embedded)"),
2337
2786
  outputDir: z3.string().optional().describe("Output directory (default: session directory)"),
2338
- skipFrames: z3.boolean().optional().default(false).describe("Skip frame extraction")
2787
+ skipFrames: z3.boolean().optional().default(false).describe("Skip frame extraction"),
2788
+ template: z3.string().optional().describe(
2789
+ `Output template (default: markdown). Options: ${templateRegistry.list().join(", ")}`
2790
+ )
2339
2791
  },
2340
- async ({ videoPath, audioPath, outputDir, skipFrames }) => {
2792
+ async ({ videoPath, audioPath, outputDir, skipFrames, template }) => {
2341
2793
  try {
2342
2794
  let fileStats;
2343
2795
  try {
@@ -2380,6 +2832,7 @@ function register3(server2) {
2380
2832
  audioPath,
2381
2833
  outputDir: pipelineOutputDir,
2382
2834
  skipFrames,
2835
+ template,
2383
2836
  verbose: false
2384
2837
  },
2385
2838
  (msg) => log(msg)
@@ -2424,8 +2877,8 @@ import { join as join7 } from "path";
2424
2877
  import { readFile as readFile3, unlink as unlink3 } from "fs/promises";
2425
2878
  import { tmpdir as tmpdir3 } from "os";
2426
2879
  import { randomUUID as randomUUID3 } from "crypto";
2427
- function register4(server2) {
2428
- server2.tool(
2880
+ function register4(server) {
2881
+ server.tool(
2429
2882
  "analyze_screenshot",
2430
2883
  "Take a screenshot and return it as an image for the AI to analyze visually. Returns the image data directly for vision analysis.",
2431
2884
  {
@@ -2516,8 +2969,8 @@ var ActiveRecording = class {
2516
2969
  var activeRecording = new ActiveRecording();
2517
2970
 
2518
2971
  // src/mcp/tools/startRecording.ts
2519
- function register5(server2) {
2520
- server2.tool(
2972
+ function register5(server) {
2973
+ server.tool(
2521
2974
  "start_recording",
2522
2975
  "Start a long-form screen+voice recording session. Returns a session ID that can be used with stop_recording.",
2523
2976
  {
@@ -2568,15 +3021,18 @@ function register5(server2) {
2568
3021
 
2569
3022
  // src/mcp/tools/stopRecording.ts
2570
3023
  import { z as z6 } from "zod";
2571
- function register6(server2) {
2572
- server2.tool(
3024
+ function register6(server) {
3025
+ server.tool(
2573
3026
  "stop_recording",
2574
3027
  "Stop an active recording and run the full markupr pipeline on the captured video.",
2575
3028
  {
2576
3029
  sessionId: z6.string().optional().describe("Session ID (default: current active recording)"),
2577
- skipFrames: z6.boolean().optional().default(false).describe("Skip frame extraction")
3030
+ skipFrames: z6.boolean().optional().default(false).describe("Skip frame extraction"),
3031
+ template: z6.string().optional().describe(
3032
+ `Output template (default: markdown). Options: ${templateRegistry.list().join(", ")}`
3033
+ )
2578
3034
  },
2579
- async ({ sessionId: _requestedSessionId, skipFrames }) => {
3035
+ async ({ sessionId: _requestedSessionId, skipFrames, template }) => {
2580
3036
  try {
2581
3037
  if (!activeRecording.isRecording()) {
2582
3038
  return {
@@ -2601,6 +3057,7 @@ function register6(server2) {
2601
3057
  videoPath,
2602
3058
  outputDir: sessionDir,
2603
3059
  skipFrames,
3060
+ template,
2604
3061
  verbose: false
2605
3062
  },
2606
3063
  (msg) => log(msg)
@@ -2640,43 +3097,1113 @@ function register6(server2) {
2640
3097
  );
2641
3098
  }
2642
3099
 
2643
- // src/mcp/resources/sessionResource.ts
2644
- import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2645
- function registerResources(server2) {
2646
- server2.resource(
2647
- "latest-session",
2648
- "session://latest",
2649
- { description: "Metadata for the most recent MCP recording session", mimeType: "application/json" },
2650
- async () => {
2651
- const session = await sessionStore.getLatest();
2652
- if (!session) {
3100
+ // src/mcp/tools/pushToLinear.ts
3101
+ import { z as z7 } from "zod";
3102
+ import { stat as stat5 } from "fs/promises";
3103
+
3104
+ // src/integrations/linear/LinearIssueCreator.ts
3105
+ import { readFile as readFile4 } from "fs/promises";
3106
+
3107
+ // src/integrations/linear/types.ts
3108
+ var SEVERITY_TO_PRIORITY = {
3109
+ Critical: 1,
3110
+ High: 2,
3111
+ Medium: 3,
3112
+ Low: 4
3113
+ };
3114
+ var CATEGORY_TO_LABEL = {
3115
+ "Bug": "Bug",
3116
+ "UX Issue": "Improvement",
3117
+ "Suggestion": "Feature",
3118
+ "Performance": "Bug",
3119
+ "Question": "Feature",
3120
+ "General": "Feature"
3121
+ };
3122
+
3123
+ // src/integrations/linear/LinearIssueCreator.ts
3124
+ var LINEAR_API_URL = "https://api.linear.app/graphql";
3125
+ var LinearIssueCreator = class {
3126
+ token;
3127
+ constructor(token) {
3128
+ this.token = token;
3129
+ }
3130
+ /**
3131
+ * Push a markupr report to Linear, creating one issue per feedback item.
3132
+ */
3133
+ async pushReport(reportPath, options) {
3134
+ const markdown = await readFile4(reportPath, "utf-8");
3135
+ const items = parseMarkdownReport(markdown);
3136
+ const team = await this.resolveTeam(options.teamKey);
3137
+ const labels = await this.getTeamLabels(team.id);
3138
+ const result = {
3139
+ teamKey: options.teamKey,
3140
+ totalItems: items.length,
3141
+ created: 0,
3142
+ failed: 0,
3143
+ issues: [],
3144
+ dryRun: options.dryRun ?? false
3145
+ };
3146
+ for (const item of items) {
3147
+ const labelName = CATEGORY_TO_LABEL[item.category] ?? "Feature";
3148
+ const matchingLabel = labels.find(
3149
+ (l) => l.name.toLowerCase() === labelName.toLowerCase()
3150
+ );
3151
+ const issueInput = {
3152
+ title: `[${item.id}] ${item.title}`,
3153
+ description: this.buildIssueDescription(item),
3154
+ teamId: team.id,
3155
+ priority: SEVERITY_TO_PRIORITY[item.severity] ?? 3,
3156
+ labelIds: matchingLabel ? [matchingLabel.id] : void 0,
3157
+ projectId: options.projectName ? await this.resolveProjectId(team.id, options.projectName) : void 0
3158
+ };
3159
+ if (options.dryRun) {
3160
+ result.issues.push({
3161
+ success: true,
3162
+ issueId: `dry-run-${item.id}`,
3163
+ identifier: `DRY-${item.id}`,
3164
+ issueUrl: `https://linear.app/dry-run/${item.id}`
3165
+ });
3166
+ result.created++;
3167
+ continue;
3168
+ }
3169
+ const issueResult = await this.createIssue(issueInput);
3170
+ result.issues.push(issueResult);
3171
+ if (issueResult.success) {
3172
+ result.created++;
3173
+ } else {
3174
+ result.failed++;
3175
+ }
3176
+ }
3177
+ return result;
3178
+ }
3179
+ /**
3180
+ * Create a single Linear issue via GraphQL.
3181
+ */
3182
+ async createIssue(input) {
3183
+ const mutation = `
3184
+ mutation IssueCreate($input: IssueCreateInput!) {
3185
+ issueCreate(input: $input) {
3186
+ success
3187
+ issue {
3188
+ id
3189
+ url
3190
+ identifier
3191
+ }
3192
+ }
3193
+ }
3194
+ `;
3195
+ const variables = {
3196
+ input: {
3197
+ title: input.title,
3198
+ description: input.description,
3199
+ teamId: input.teamId,
3200
+ priority: input.priority,
3201
+ ...input.labelIds && { labelIds: input.labelIds },
3202
+ ...input.projectId && { projectId: input.projectId }
3203
+ }
3204
+ };
3205
+ try {
3206
+ const data = await this.graphql(mutation, variables);
3207
+ if (data.issueCreate.success) {
2653
3208
  return {
2654
- contents: [{
2655
- uri: "session://latest",
2656
- mimeType: "application/json",
2657
- text: JSON.stringify({ error: "No sessions found" })
2658
- }]
3209
+ success: true,
3210
+ issueId: data.issueCreate.issue.id,
3211
+ issueUrl: data.issueCreate.issue.url,
3212
+ identifier: data.issueCreate.issue.identifier
2659
3213
  };
2660
3214
  }
3215
+ return { success: false, error: "Linear API returned success: false" };
3216
+ } catch (error) {
2661
3217
  return {
2662
- contents: [{
2663
- uri: "session://latest",
2664
- mimeType: "application/json",
2665
- text: JSON.stringify(session, null, 2)
2666
- }]
3218
+ success: false,
3219
+ error: error instanceof Error ? error.message : String(error)
2667
3220
  };
2668
3221
  }
2669
- );
2670
- server2.resource(
2671
- "session-by-id",
2672
- new ResourceTemplate("session://{id}", { list: void 0 }),
2673
- { description: "Metadata for a specific MCP recording session", mimeType: "application/json" },
2674
- async (uri, variables) => {
2675
- const id = variables.id;
2676
- const session = await sessionStore.get(id);
2677
- if (!session) {
2678
- return {
2679
- contents: [{
3222
+ }
3223
+ /**
3224
+ * Resolve a team key (e.g., "ENG") to a team ID.
3225
+ */
3226
+ async resolveTeam(teamKey) {
3227
+ const query = `
3228
+ query Teams {
3229
+ teams {
3230
+ nodes {
3231
+ id
3232
+ key
3233
+ name
3234
+ }
3235
+ }
3236
+ }
3237
+ `;
3238
+ const data = await this.graphql(query);
3239
+ const team = data.teams.nodes.find(
3240
+ (t) => t.key.toLowerCase() === teamKey.toLowerCase()
3241
+ );
3242
+ if (!team) {
3243
+ const available = data.teams.nodes.map((t) => t.key).join(", ");
3244
+ throw new Error(
3245
+ `Team "${teamKey}" not found. Available teams: ${available}`
3246
+ );
3247
+ }
3248
+ return team;
3249
+ }
3250
+ /**
3251
+ * Get all labels for a team.
3252
+ */
3253
+ async getTeamLabels(teamId) {
3254
+ const query = `
3255
+ query TeamLabels($teamId: String!) {
3256
+ team(id: $teamId) {
3257
+ labels {
3258
+ nodes {
3259
+ id
3260
+ name
3261
+ }
3262
+ }
3263
+ }
3264
+ }
3265
+ `;
3266
+ const data = await this.graphql(query, { teamId });
3267
+ return data.team.labels.nodes;
3268
+ }
3269
+ /**
3270
+ * Resolve a project name to a project ID within a team.
3271
+ */
3272
+ async resolveProjectId(teamId, projectName) {
3273
+ const query = `
3274
+ query Projects($teamId: String!) {
3275
+ team(id: $teamId) {
3276
+ projects {
3277
+ nodes {
3278
+ id
3279
+ name
3280
+ }
3281
+ }
3282
+ }
3283
+ }
3284
+ `;
3285
+ const data = await this.graphql(query, { teamId });
3286
+ const project = data.team.projects.nodes.find(
3287
+ (p) => p.name.toLowerCase() === projectName.toLowerCase()
3288
+ );
3289
+ return project?.id;
3290
+ }
3291
+ /**
3292
+ * Build markdown description for a Linear issue from a feedback item.
3293
+ */
3294
+ buildIssueDescription(item) {
3295
+ let desc = `## markupr Feedback: ${item.id}
3296
+
3297
+ `;
3298
+ desc += `**Severity:** ${item.severity}
3299
+ `;
3300
+ desc += `**Category:** ${item.category}
3301
+ `;
3302
+ desc += `**Timestamp:** ${item.timestamp}
3303
+
3304
+ `;
3305
+ desc += `### Description
3306
+
3307
+ ${item.description}
3308
+
3309
+ `;
3310
+ if (item.suggestedAction) {
3311
+ desc += `### Suggested Action
3312
+
3313
+ ${item.suggestedAction}
3314
+
3315
+ `;
3316
+ }
3317
+ if (item.screenshotPaths.length > 0) {
3318
+ desc += `### Screenshots
3319
+
3320
+ `;
3321
+ desc += `_${item.screenshotPaths.length} screenshot(s) captured during session._
3322
+ `;
3323
+ for (const path4 of item.screenshotPaths) {
3324
+ desc += `- \`${path4}\`
3325
+ `;
3326
+ }
3327
+ }
3328
+ desc += `
3329
+ ---
3330
+ *Created by [markupr](https://markupr.com)*`;
3331
+ return desc;
3332
+ }
3333
+ /**
3334
+ * Execute a GraphQL request against the Linear API.
3335
+ */
3336
+ async graphql(query, variables) {
3337
+ const response = await fetch(LINEAR_API_URL, {
3338
+ method: "POST",
3339
+ headers: {
3340
+ "Content-Type": "application/json",
3341
+ Authorization: this.token
3342
+ },
3343
+ body: JSON.stringify({ query, variables })
3344
+ });
3345
+ if (!response.ok) {
3346
+ throw new Error(
3347
+ `Linear API error: ${response.status} ${response.statusText}`
3348
+ );
3349
+ }
3350
+ const json = await response.json();
3351
+ if (json.errors && json.errors.length > 0) {
3352
+ throw new Error(`Linear GraphQL error: ${json.errors[0].message}`);
3353
+ }
3354
+ if (!json.data) {
3355
+ throw new Error("Linear API returned no data");
3356
+ }
3357
+ return json.data;
3358
+ }
3359
+ };
3360
+ function parseMarkdownReport(markdown) {
3361
+ const items = [];
3362
+ const itemPattern = /^### (FB-\d+): (.+)$/gm;
3363
+ const matches = [];
3364
+ let match;
3365
+ while ((match = itemPattern.exec(markdown)) !== null) {
3366
+ matches.push({ index: match.index, id: match[1], title: match[2] });
3367
+ }
3368
+ for (let i = 0; i < matches.length; i++) {
3369
+ const start2 = matches[i].index;
3370
+ const end = i + 1 < matches.length ? matches[i + 1].index : markdown.length;
3371
+ const section = markdown.slice(start2, end);
3372
+ const severity = extractField(section, "Severity") || "Medium";
3373
+ const category = extractField(section, "Type") || "General";
3374
+ const timestamp = extractField(section, "Timestamp") || "00:00";
3375
+ const description = extractBlockquote(section);
3376
+ const screenshotPaths = extractScreenshots(section);
3377
+ const suggestedAction = extractSuggestedAction(section);
3378
+ items.push({
3379
+ id: matches[i].id,
3380
+ title: matches[i].title,
3381
+ severity,
3382
+ category,
3383
+ timestamp,
3384
+ description,
3385
+ screenshotPaths,
3386
+ suggestedAction
3387
+ });
3388
+ }
3389
+ return items;
3390
+ }
3391
+ function extractField(section, fieldName) {
3392
+ const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, "m");
3393
+ const match = section.match(pattern);
3394
+ return match ? match[1].trim() : "";
3395
+ }
3396
+ function extractBlockquote(section) {
3397
+ const whatHappened = section.match(/#### What Happened\s*\n([\s\S]*?)(?=\n####|\n---)/);
3398
+ if (!whatHappened) return "";
3399
+ const lines = whatHappened[1].split("\n").filter((line) => line.startsWith(">")).map((line) => line.replace(/^>\s*/, "").trim());
3400
+ return lines.join(" ").trim();
3401
+ }
3402
+ function extractScreenshots(section) {
3403
+ const paths = [];
3404
+ const pattern = /!\[.*?\]\((.+?)\)/g;
3405
+ let match;
3406
+ while ((match = pattern.exec(section)) !== null) {
3407
+ paths.push(match[1]);
3408
+ }
3409
+ return paths;
3410
+ }
3411
+ function extractSuggestedAction(section) {
3412
+ const actionSection = section.match(/#### Suggested Next Step\s*\n-\s*(.+)/);
3413
+ return actionSection ? actionSection[1].trim() : "";
3414
+ }
3415
+
3416
+ // src/mcp/tools/pushToLinear.ts
3417
+ function register7(server) {
3418
+ server.tool(
3419
+ "push_to_linear",
3420
+ "Push a markupr feedback report to Linear. Creates one issue per feedback item with priority mapping, labels, and full context.",
3421
+ {
3422
+ reportPath: z7.string().describe("Absolute path to the markupr markdown report"),
3423
+ teamKey: z7.string().describe('Linear team key (e.g., "ENG", "DES")'),
3424
+ token: z7.string().optional().describe("Linear API key (or set LINEAR_API_KEY env var)"),
3425
+ projectName: z7.string().optional().describe("Linear project name to assign issues to"),
3426
+ dryRun: z7.boolean().optional().default(false).describe("Preview what would be created without actually creating issues")
3427
+ },
3428
+ async ({ reportPath, teamKey, token, projectName, dryRun }) => {
3429
+ try {
3430
+ const apiToken = token || process.env.LINEAR_API_KEY;
3431
+ if (!apiToken) {
3432
+ return {
3433
+ content: [
3434
+ {
3435
+ type: "text",
3436
+ text: "Error: No Linear API token provided. Pass via `token` parameter or set LINEAR_API_KEY env var."
3437
+ }
3438
+ ],
3439
+ isError: true
3440
+ };
3441
+ }
3442
+ try {
3443
+ const stats = await stat5(reportPath);
3444
+ if (!stats.isFile() || stats.size === 0) {
3445
+ return {
3446
+ content: [
3447
+ {
3448
+ type: "text",
3449
+ text: `Error: Report file is empty or not a regular file: ${reportPath}`
3450
+ }
3451
+ ],
3452
+ isError: true
3453
+ };
3454
+ }
3455
+ } catch {
3456
+ return {
3457
+ content: [
3458
+ {
3459
+ type: "text",
3460
+ text: `Error: Report file not found: ${reportPath}`
3461
+ }
3462
+ ],
3463
+ isError: true
3464
+ };
3465
+ }
3466
+ log(`Pushing report to Linear: ${reportPath} \u2192 team ${teamKey}`);
3467
+ const creator = new LinearIssueCreator(apiToken);
3468
+ const result = await creator.pushReport(reportPath, {
3469
+ token: apiToken,
3470
+ teamKey,
3471
+ projectName,
3472
+ dryRun
3473
+ });
3474
+ const lines = [
3475
+ dryRun ? "DRY RUN \u2014 no issues created" : "Push to Linear complete",
3476
+ "",
3477
+ `Team: ${teamKey}`,
3478
+ `Total items: ${result.totalItems}`,
3479
+ `Created: ${result.created}`,
3480
+ `Failed: ${result.failed}`,
3481
+ ""
3482
+ ];
3483
+ for (const issue of result.issues) {
3484
+ if (issue.success) {
3485
+ lines.push(
3486
+ ` ${issue.identifier}: ${issue.issueUrl}`
3487
+ );
3488
+ } else {
3489
+ lines.push(` FAILED: ${issue.error}`);
3490
+ }
3491
+ }
3492
+ return {
3493
+ content: [{ type: "text", text: lines.join("\n") }]
3494
+ };
3495
+ } catch (error) {
3496
+ return {
3497
+ content: [
3498
+ {
3499
+ type: "text",
3500
+ text: `Error: ${error.message}`
3501
+ }
3502
+ ],
3503
+ isError: true
3504
+ };
3505
+ }
3506
+ }
3507
+ );
3508
+ }
3509
+
3510
+ // src/mcp/tools/pushToGitHub.ts
3511
+ import { z as z8 } from "zod";
3512
+ import { stat as stat6 } from "fs/promises";
3513
+
3514
+ // src/integrations/github/GitHubIssueCreator.ts
3515
+ import { readFile as readFile5 } from "fs/promises";
3516
+
3517
+ // src/integrations/github/types.ts
3518
+ var CATEGORY_LABELS = {
3519
+ Bug: { name: "bug", color: "d73a4a", description: "Something isn't working" },
3520
+ "UX Issue": { name: "ux", color: "e4e669", description: "User experience issue" },
3521
+ Suggestion: { name: "enhancement", color: "a2eeef", description: "New feature or request" },
3522
+ Performance: { name: "performance", color: "f9d0c4", description: "Performance issue" },
3523
+ Question: { name: "question", color: "d876e3", description: "Further information is requested" },
3524
+ General: { name: "feedback", color: "c5def5", description: "General feedback" }
3525
+ };
3526
+ var SEVERITY_LABELS = {
3527
+ Critical: { name: "priority: critical", color: "b60205", description: "Critical priority" },
3528
+ High: { name: "priority: high", color: "d93f0b", description: "High priority" },
3529
+ Medium: { name: "priority: medium", color: "fbca04", description: "Medium priority" },
3530
+ Low: { name: "priority: low", color: "0e8a16", description: "Low priority" }
3531
+ };
3532
+ var MARKUPR_LABEL = {
3533
+ name: "markupr",
3534
+ color: "6f42c1",
3535
+ description: "Created from markupr feedback session"
3536
+ };
3537
+
3538
+ // src/integrations/github/GitHubIssueCreator.ts
3539
+ var GITHUB_API = "https://api.github.com";
3540
+ async function resolveAuth(explicitToken) {
3541
+ if (explicitToken) {
3542
+ return { token: explicitToken, source: "flag" };
3543
+ }
3544
+ const envToken = process.env.GITHUB_TOKEN;
3545
+ if (envToken) {
3546
+ return { token: envToken, source: "env" };
3547
+ }
3548
+ try {
3549
+ const { execSync } = await import("child_process");
3550
+ const ghToken = execSync("gh auth token", { encoding: "utf-8", timeout: 5e3 }).trim();
3551
+ if (ghToken) {
3552
+ return { token: ghToken, source: "gh-cli" };
3553
+ }
3554
+ } catch {
3555
+ }
3556
+ throw new Error(
3557
+ "No GitHub token found. Provide one via:\n --token <token>\n GITHUB_TOKEN environment variable\n gh auth login (GitHub CLI)"
3558
+ );
3559
+ }
3560
+ function parseMarkuprReport(markdown) {
3561
+ const items = [];
3562
+ const itemPattern = /### (FB-\d{3}): (.+?)(?=\n)/g;
3563
+ let match;
3564
+ while ((match = itemPattern.exec(markdown)) !== null) {
3565
+ const id = match[1];
3566
+ const title = match[2].trim();
3567
+ const startIndex = match.index;
3568
+ const rest = markdown.slice(startIndex + match[0].length);
3569
+ const nextSectionMatch = rest.match(/\n### FB-\d{3}:|(?=\n## [A-Z])/);
3570
+ const itemBlock = nextSectionMatch ? rest.slice(0, nextSectionMatch.index) : rest;
3571
+ const severity = extractField2(itemBlock, "Severity") || "Medium";
3572
+ const category = extractField2(itemBlock, "Type") || "General";
3573
+ const timestamp = extractField2(itemBlock, "Timestamp") || "00:00";
3574
+ const transcription = extractTranscription(itemBlock);
3575
+ const screenshotPaths = extractScreenshots2(itemBlock);
3576
+ const suggestedAction = extractSuggestedAction2(itemBlock);
3577
+ items.push({
3578
+ id,
3579
+ title,
3580
+ category,
3581
+ severity,
3582
+ timestamp,
3583
+ transcription,
3584
+ screenshotPaths,
3585
+ suggestedAction
3586
+ });
3587
+ }
3588
+ return items;
3589
+ }
3590
+ function extractField2(block, fieldName) {
3591
+ const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`);
3592
+ const match = block.match(pattern);
3593
+ return match ? match[1].trim() : void 0;
3594
+ }
3595
+ function extractTranscription(block) {
3596
+ const whatHappenedIdx = block.indexOf("#### What Happened");
3597
+ if (whatHappenedIdx === -1) return "";
3598
+ const afterHeading = block.slice(whatHappenedIdx);
3599
+ const nextHeading = afterHeading.indexOf("\n####", 5);
3600
+ const section = nextHeading !== -1 ? afterHeading.slice(0, nextHeading) : afterHeading;
3601
+ const lines = section.split("\n");
3602
+ const quotedLines = [];
3603
+ for (const line of lines) {
3604
+ const trimmed = line.trim();
3605
+ if (trimmed.startsWith("> ")) {
3606
+ quotedLines.push(trimmed.slice(2));
3607
+ } else if (trimmed === ">") {
3608
+ quotedLines.push("");
3609
+ }
3610
+ }
3611
+ return quotedLines.join(" ").trim();
3612
+ }
3613
+ function extractScreenshots2(block) {
3614
+ const paths = [];
3615
+ const pattern = /!\[.*?\]\((.+?)\)/g;
3616
+ let match;
3617
+ while ((match = pattern.exec(block)) !== null) {
3618
+ paths.push(match[1]);
3619
+ }
3620
+ return paths;
3621
+ }
3622
+ function extractSuggestedAction2(block) {
3623
+ const idx = block.indexOf("#### Suggested Next Step");
3624
+ if (idx === -1) return "";
3625
+ const afterHeading = block.slice(idx + "#### Suggested Next Step".length);
3626
+ const nextSection = afterHeading.indexOf("\n---");
3627
+ const section = nextSection !== -1 ? afterHeading.slice(0, nextSection) : afterHeading;
3628
+ const lines = section.split("\n");
3629
+ for (const line of lines) {
3630
+ const trimmed = line.trim();
3631
+ if (trimmed.startsWith("- ")) {
3632
+ return trimmed.slice(2);
3633
+ }
3634
+ }
3635
+ return "";
3636
+ }
3637
+ function formatIssueBody(item, reportPath) {
3638
+ let body = `## ${item.id}: ${item.title}
3639
+
3640
+ `;
3641
+ body += `| Field | Value |
3642
+ |-------|-------|
3643
+ `;
3644
+ body += `| **Severity** | ${item.severity} |
3645
+ `;
3646
+ body += `| **Category** | ${item.category} |
3647
+ `;
3648
+ body += `| **Timestamp** | ${item.timestamp} |
3649
+
3650
+ `;
3651
+ body += `### What Happened
3652
+
3653
+ `;
3654
+ body += `> ${item.transcription}
3655
+
3656
+ `;
3657
+ if (item.screenshotPaths.length > 0) {
3658
+ body += `### Screenshots
3659
+
3660
+ `;
3661
+ body += `_${item.screenshotPaths.length} screenshot(s) captured \u2014 see the markupr report for images._
3662
+
3663
+ `;
3664
+ }
3665
+ if (item.suggestedAction) {
3666
+ body += `### Suggested Action
3667
+
3668
+ `;
3669
+ body += `${item.suggestedAction}
3670
+
3671
+ `;
3672
+ }
3673
+ body += `---
3674
+ `;
3675
+ if (reportPath) {
3676
+ body += `_Source: \`${reportPath}\`_
3677
+ `;
3678
+ }
3679
+ body += `_Created by [markupr](https://markupr.com)_
3680
+ `;
3681
+ return body;
3682
+ }
3683
+ function getLabelsForItem(item) {
3684
+ const labels = [MARKUPR_LABEL.name];
3685
+ const categoryLabel = CATEGORY_LABELS[item.category];
3686
+ if (categoryLabel) {
3687
+ labels.push(categoryLabel.name);
3688
+ }
3689
+ const severityLabel = SEVERITY_LABELS[item.severity];
3690
+ if (severityLabel) {
3691
+ labels.push(severityLabel.name);
3692
+ }
3693
+ return labels;
3694
+ }
3695
+ function collectRequiredLabels(items) {
3696
+ const seen = /* @__PURE__ */ new Set();
3697
+ const labels = [];
3698
+ seen.add(MARKUPR_LABEL.name);
3699
+ labels.push(MARKUPR_LABEL);
3700
+ for (const item of items) {
3701
+ const catLabel = CATEGORY_LABELS[item.category];
3702
+ if (catLabel && !seen.has(catLabel.name)) {
3703
+ seen.add(catLabel.name);
3704
+ labels.push(catLabel);
3705
+ }
3706
+ const sevLabel = SEVERITY_LABELS[item.severity];
3707
+ if (sevLabel && !seen.has(sevLabel.name)) {
3708
+ seen.add(sevLabel.name);
3709
+ labels.push(sevLabel);
3710
+ }
3711
+ }
3712
+ return labels;
3713
+ }
3714
+ var GitHubAPIClient = class {
3715
+ baseUrl;
3716
+ headers;
3717
+ constructor(auth, baseUrl = GITHUB_API) {
3718
+ this.baseUrl = baseUrl;
3719
+ this.headers = {
3720
+ Authorization: `Bearer ${auth.token}`,
3721
+ Accept: "application/vnd.github+json",
3722
+ "X-GitHub-Api-Version": "2022-11-28",
3723
+ "Content-Type": "application/json",
3724
+ "User-Agent": "markupr-github-integration"
3725
+ };
3726
+ }
3727
+ async createIssue(repo, input) {
3728
+ const url = `${this.baseUrl}/repos/${repo.owner}/${repo.repo}/issues`;
3729
+ const response = await fetch(url, {
3730
+ method: "POST",
3731
+ headers: this.headers,
3732
+ body: JSON.stringify({
3733
+ title: input.title,
3734
+ body: input.body,
3735
+ labels: input.labels
3736
+ })
3737
+ });
3738
+ if (!response.ok) {
3739
+ const text = await response.text();
3740
+ throw new Error(`GitHub API error (${response.status}): ${text}`);
3741
+ }
3742
+ const data = await response.json();
3743
+ return {
3744
+ number: data.number,
3745
+ url: data.html_url,
3746
+ title: data.title
3747
+ };
3748
+ }
3749
+ async ensureLabel(repo, label) {
3750
+ const url = `${this.baseUrl}/repos/${repo.owner}/${repo.repo}/labels`;
3751
+ const checkUrl = `${url}/${encodeURIComponent(label.name)}`;
3752
+ const checkResponse = await fetch(checkUrl, {
3753
+ method: "GET",
3754
+ headers: this.headers
3755
+ });
3756
+ if (checkResponse.ok) {
3757
+ return false;
3758
+ }
3759
+ const createResponse = await fetch(url, {
3760
+ method: "POST",
3761
+ headers: this.headers,
3762
+ body: JSON.stringify({
3763
+ name: label.name,
3764
+ color: label.color,
3765
+ description: label.description
3766
+ })
3767
+ });
3768
+ if (!createResponse.ok) {
3769
+ if (createResponse.status === 422) {
3770
+ return false;
3771
+ }
3772
+ const text = await createResponse.text();
3773
+ throw new Error(`Failed to create label "${label.name}": ${text}`);
3774
+ }
3775
+ return true;
3776
+ }
3777
+ async verifyAccess(repo) {
3778
+ const url = `${this.baseUrl}/repos/${repo.owner}/${repo.repo}`;
3779
+ const response = await fetch(url, {
3780
+ method: "GET",
3781
+ headers: this.headers
3782
+ });
3783
+ if (!response.ok) {
3784
+ if (response.status === 404) {
3785
+ throw new Error(`Repository ${repo.owner}/${repo.repo} not found (or no access)`);
3786
+ }
3787
+ if (response.status === 401) {
3788
+ throw new Error("GitHub token is invalid or expired");
3789
+ }
3790
+ throw new Error(`Failed to access repository (${response.status})`);
3791
+ }
3792
+ }
3793
+ };
3794
+ async function pushToGitHub(options) {
3795
+ const { repo, auth, reportPath, dryRun = false, items: filterIds } = options;
3796
+ const markdown = await readFile5(reportPath, "utf-8");
3797
+ let items = parseMarkuprReport(markdown);
3798
+ if (items.length === 0) {
3799
+ throw new Error("No feedback items found in the report. Is this a valid markupr report?");
3800
+ }
3801
+ if (filterIds && filterIds.length > 0) {
3802
+ const filterSet = new Set(filterIds.map((id) => id.toUpperCase()));
3803
+ items = items.filter((item) => filterSet.has(item.id));
3804
+ if (items.length === 0) {
3805
+ throw new Error(`None of the specified items (${filterIds.join(", ")}) found in the report`);
3806
+ }
3807
+ }
3808
+ const result = {
3809
+ created: [],
3810
+ labelsCreated: [],
3811
+ errors: [],
3812
+ dryRun
3813
+ };
3814
+ if (dryRun) {
3815
+ for (const item of items) {
3816
+ const labels = getLabelsForItem(item);
3817
+ result.created.push({
3818
+ number: 0,
3819
+ url: "",
3820
+ title: `[${item.id}] ${item.title}`
3821
+ });
3822
+ }
3823
+ result.labelsCreated = collectRequiredLabels(items).map((l) => l.name);
3824
+ return result;
3825
+ }
3826
+ const client = new GitHubAPIClient(auth);
3827
+ await client.verifyAccess(repo);
3828
+ const requiredLabels = collectRequiredLabels(items);
3829
+ for (const label of requiredLabels) {
3830
+ try {
3831
+ const created = await client.ensureLabel(repo, label);
3832
+ if (created) {
3833
+ result.labelsCreated.push(label.name);
3834
+ }
3835
+ } catch (err) {
3836
+ const message = err instanceof Error ? err.message : String(err);
3837
+ result.errors.push({ itemId: "labels", error: message });
3838
+ }
3839
+ }
3840
+ for (const item of items) {
3841
+ try {
3842
+ const labels = getLabelsForItem(item);
3843
+ const body = formatIssueBody(item, reportPath);
3844
+ const issueResult = await client.createIssue(repo, {
3845
+ title: `[${item.id}] ${item.title}`,
3846
+ body,
3847
+ labels
3848
+ });
3849
+ result.created.push(issueResult);
3850
+ } catch (err) {
3851
+ const message = err instanceof Error ? err.message : String(err);
3852
+ result.errors.push({ itemId: item.id, error: message });
3853
+ }
3854
+ }
3855
+ return result;
3856
+ }
3857
+ function parseRepoString(repoStr) {
3858
+ const parts = repoStr.split("/");
3859
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
3860
+ throw new Error(`Invalid repository format: "${repoStr}". Expected "owner/repo".`);
3861
+ }
3862
+ return { owner: parts[0], repo: parts[1] };
3863
+ }
3864
+
3865
+ // src/mcp/tools/pushToGitHub.ts
3866
+ function register8(server) {
3867
+ server.tool(
3868
+ "push_to_github",
3869
+ "Create GitHub issues from a markupr feedback report. Each feedback item becomes a separate issue with labels and structured markdown.",
3870
+ {
3871
+ reportPath: z8.string().describe("Absolute path to the markupr markdown report"),
3872
+ repo: z8.string().describe('Target GitHub repository in "owner/repo" format'),
3873
+ token: z8.string().optional().describe("GitHub token (falls back to GITHUB_TOKEN env or gh CLI)"),
3874
+ items: z8.array(z8.string()).optional().describe("Specific FB-XXX item IDs to push (default: all)"),
3875
+ dryRun: z8.boolean().optional().default(false).describe("Preview what would be created without creating")
3876
+ },
3877
+ async ({ reportPath, repo, token, items, dryRun }) => {
3878
+ try {
3879
+ try {
3880
+ const stats = await stat6(reportPath);
3881
+ if (!stats.isFile()) {
3882
+ return {
3883
+ content: [{ type: "text", text: `Error: Not a file: ${reportPath}` }],
3884
+ isError: true
3885
+ };
3886
+ }
3887
+ } catch {
3888
+ return {
3889
+ content: [{ type: "text", text: `Error: Report not found: ${reportPath}` }],
3890
+ isError: true
3891
+ };
3892
+ }
3893
+ let parsedRepo;
3894
+ try {
3895
+ parsedRepo = parseRepoString(repo);
3896
+ } catch (err) {
3897
+ return {
3898
+ content: [{ type: "text", text: `Error: ${err.message}` }],
3899
+ isError: true
3900
+ };
3901
+ }
3902
+ let auth;
3903
+ try {
3904
+ auth = await resolveAuth(token);
3905
+ } catch (err) {
3906
+ return {
3907
+ content: [{ type: "text", text: `Error: ${err.message}` }],
3908
+ isError: true
3909
+ };
3910
+ }
3911
+ log(`Pushing to GitHub: ${repo} (auth: ${auth.source}, dryRun: ${dryRun})`);
3912
+ const result = await pushToGitHub({
3913
+ repo: parsedRepo,
3914
+ auth,
3915
+ reportPath,
3916
+ dryRun,
3917
+ items
3918
+ });
3919
+ const lines = [];
3920
+ if (dryRun) {
3921
+ lines.push(`Dry run \u2014 ${result.created.length} issue(s) would be created:`);
3922
+ lines.push("");
3923
+ for (const issue of result.created) {
3924
+ lines.push(` - ${issue.title}`);
3925
+ }
3926
+ if (result.labelsCreated.length > 0) {
3927
+ lines.push("");
3928
+ lines.push(`Labels to create: ${result.labelsCreated.join(", ")}`);
3929
+ }
3930
+ } else {
3931
+ lines.push(`Created ${result.created.length} issue(s):`);
3932
+ lines.push("");
3933
+ for (const issue of result.created) {
3934
+ lines.push(` - #${issue.number}: ${issue.title}`);
3935
+ lines.push(` ${issue.url}`);
3936
+ }
3937
+ if (result.labelsCreated.length > 0) {
3938
+ lines.push("");
3939
+ lines.push(`Labels created: ${result.labelsCreated.join(", ")}`);
3940
+ }
3941
+ }
3942
+ if (result.errors.length > 0) {
3943
+ lines.push("");
3944
+ lines.push(`Errors (${result.errors.length}):`);
3945
+ for (const err of result.errors) {
3946
+ lines.push(` - ${err.itemId}: ${err.error}`);
3947
+ }
3948
+ }
3949
+ return {
3950
+ content: [{ type: "text", text: lines.join("\n") }]
3951
+ };
3952
+ } catch (error) {
3953
+ return {
3954
+ content: [{ type: "text", text: `Error: ${error.message}` }],
3955
+ isError: true
3956
+ };
3957
+ }
3958
+ }
3959
+ );
3960
+ }
3961
+
3962
+ // src/mcp/tools/describeScreen.ts
3963
+ import { z as z9 } from "zod";
3964
+ import { join as join9 } from "path";
3965
+ import { readFile as readFile6, unlink as unlink4, stat as stat7 } from "fs/promises";
3966
+ import { tmpdir as tmpdir4 } from "os";
3967
+ import { randomUUID as randomUUID4 } from "crypto";
3968
+ import Anthropic from "@anthropic-ai/sdk";
3969
+ var DESCRIBE_SCREEN_PROMPT = `You are a screen description engine for AI coding agents. You receive a screenshot of a developer's screen and must describe what is visible in a structured, actionable way.
3970
+
3971
+ ## Output Structure
3972
+
3973
+ Return a structured description with the following sections:
3974
+
3975
+ ### Active Window
3976
+ Identify the primary/focused application and its state (e.g., "VS Code with main.ts open", "Chrome showing localhost:3000").
3977
+
3978
+ ### Visible UI Elements
3979
+ List the key UI elements visible: buttons, inputs, navigation, modals, dialogs, sidebars, tabs, toolbars, etc. Be specific about labels and state (enabled/disabled, selected, etc.).
3980
+
3981
+ ### Text Content
3982
+ Extract any readable text: error messages, code snippets, terminal output, form content, headings, notifications, toasts, etc. Quote verbatim when possible.
3983
+
3984
+ ### Layout Structure
3985
+ Briefly describe the spatial layout: panels, split views, columns, overlays, etc.
3986
+
3987
+ ### Notable Issues
3988
+ Flag anything that looks like a problem: error dialogs, red indicators, broken layouts, console errors, failed builds, stack traces, etc. If nothing looks wrong, say "None observed."
3989
+
3990
+ ## Rules
3991
+ 1. Be factual and precise. Describe what you SEE, do not speculate about intent.
3992
+ 2. Use developer-friendly terminology (e.g., "modal dialog" not "popup box").
3993
+ 3. If text is partially obscured, note what is readable and indicate truncation with [...].
3994
+ 4. Keep the description concise but thorough. Every line should be useful to an AI agent trying to understand the screen context.
3995
+ 5. Do not wrap your response in markdown code fences. Return plain structured text.`;
3996
+ function register9(server) {
3997
+ server.tool(
3998
+ "describe_screen",
3999
+ "Capture a screenshot (or read an existing image) and return a structured text description of what is visible on screen. Useful for giving AI agents visual context about UI state, errors, layout, and text content.",
4000
+ {
4001
+ imagePath: z9.string().optional().describe(
4002
+ "Absolute path to an existing screenshot/image file. If omitted, a fresh screenshot is captured."
4003
+ ),
4004
+ display: z9.number().optional().default(1).describe("Display number to capture (1-indexed). Ignored when imagePath is provided."),
4005
+ apiKey: z9.string().optional().describe(
4006
+ "Anthropic API key. Falls back to ANTHROPIC_API_KEY env var."
4007
+ ),
4008
+ focus: z9.string().optional().describe(
4009
+ 'Optional focus area to pay extra attention to (e.g., "the error dialog", "the terminal output", "the sidebar navigation").'
4010
+ )
4011
+ },
4012
+ async ({ imagePath, display, apiKey, focus }) => {
4013
+ const resolvedKey = apiKey || process.env.ANTHROPIC_API_KEY;
4014
+ if (!resolvedKey) {
4015
+ return {
4016
+ content: [
4017
+ {
4018
+ type: "text",
4019
+ text: "Error: No Anthropic API key provided. Pass via `apiKey` parameter or set ANTHROPIC_API_KEY env var."
4020
+ }
4021
+ ],
4022
+ isError: true
4023
+ };
4024
+ }
4025
+ let screenshotPath;
4026
+ let tempPath;
4027
+ try {
4028
+ if (imagePath) {
4029
+ let fileStats;
4030
+ try {
4031
+ fileStats = await stat7(imagePath);
4032
+ } catch {
4033
+ return {
4034
+ content: [
4035
+ {
4036
+ type: "text",
4037
+ text: `Error: Image file not found: ${imagePath}`
4038
+ }
4039
+ ],
4040
+ isError: true
4041
+ };
4042
+ }
4043
+ if (!fileStats.isFile() || fileStats.size === 0) {
4044
+ return {
4045
+ content: [
4046
+ {
4047
+ type: "text",
4048
+ text: `Error: Image file is empty or not a regular file: ${imagePath}`
4049
+ }
4050
+ ],
4051
+ isError: true
4052
+ };
4053
+ }
4054
+ screenshotPath = imagePath;
4055
+ } else {
4056
+ tempPath = join9(
4057
+ tmpdir4(),
4058
+ `markupr-mcp-describe-${randomUUID4()}.png`
4059
+ );
4060
+ log(`Capturing screenshot for describe_screen: display=${display}`);
4061
+ await capture({ display, outputPath: tempPath });
4062
+ await optimize(tempPath);
4063
+ screenshotPath = tempPath;
4064
+ }
4065
+ const imageBuffer = await readFile6(screenshotPath);
4066
+ const base64Data = imageBuffer.toString("base64");
4067
+ const ext = screenshotPath.split(".").pop()?.toLowerCase();
4068
+ const mediaType = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : ext === "gif" ? "image/gif" : "image/png";
4069
+ log("Calling Claude API for screen description");
4070
+ const client = new Anthropic({ apiKey: resolvedKey });
4071
+ const userPrompt = focus ? `Describe what is visible on this screen. Pay special attention to: ${focus}` : "Describe what is visible on this screen.";
4072
+ const response = await client.messages.create({
4073
+ model: "claude-sonnet-4-5-20250929",
4074
+ max_tokens: 2048,
4075
+ temperature: 0.2,
4076
+ system: DESCRIBE_SCREEN_PROMPT,
4077
+ messages: [
4078
+ {
4079
+ role: "user",
4080
+ content: [
4081
+ {
4082
+ type: "image",
4083
+ source: {
4084
+ type: "base64",
4085
+ media_type: mediaType,
4086
+ data: base64Data
4087
+ }
4088
+ },
4089
+ {
4090
+ type: "text",
4091
+ text: userPrompt
4092
+ }
4093
+ ]
4094
+ }
4095
+ ]
4096
+ });
4097
+ const textBlock = response.content.find(
4098
+ (block) => block.type === "text"
4099
+ );
4100
+ if (!textBlock || textBlock.type !== "text") {
4101
+ return {
4102
+ content: [
4103
+ {
4104
+ type: "text",
4105
+ text: "Error: No text content in Claude response."
4106
+ }
4107
+ ],
4108
+ isError: true
4109
+ };
4110
+ }
4111
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
4112
+ const source = imagePath ? `image file: ${imagePath}` : `display ${display} capture`;
4113
+ log("Screen description complete");
4114
+ return {
4115
+ content: [
4116
+ {
4117
+ type: "text",
4118
+ text: [
4119
+ `# Screen Description`,
4120
+ `_Source: ${source} | ${timestamp}_`,
4121
+ "",
4122
+ textBlock.text
4123
+ ].join("\n")
4124
+ }
4125
+ ]
4126
+ };
4127
+ } catch (error) {
4128
+ const message = error.message;
4129
+ if (message.includes("401") || message.includes("authentication")) {
4130
+ return {
4131
+ content: [
4132
+ {
4133
+ type: "text",
4134
+ text: "Error: Anthropic API authentication failed. Check your API key."
4135
+ }
4136
+ ],
4137
+ isError: true
4138
+ };
4139
+ }
4140
+ if (message.includes("429") || message.includes("rate")) {
4141
+ return {
4142
+ content: [
4143
+ {
4144
+ type: "text",
4145
+ text: "Error: Anthropic API rate limit exceeded. Try again shortly."
4146
+ }
4147
+ ],
4148
+ isError: true
4149
+ };
4150
+ }
4151
+ return {
4152
+ content: [
4153
+ {
4154
+ type: "text",
4155
+ text: `Error: ${message}`
4156
+ }
4157
+ ],
4158
+ isError: true
4159
+ };
4160
+ } finally {
4161
+ if (tempPath) {
4162
+ await unlink4(tempPath).catch(() => {
4163
+ });
4164
+ }
4165
+ }
4166
+ }
4167
+ );
4168
+ }
4169
+
4170
+ // src/mcp/resources/sessionResource.ts
4171
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
4172
+ function registerResources(server) {
4173
+ server.resource(
4174
+ "latest-session",
4175
+ "session://latest",
4176
+ { description: "Metadata for the most recent MCP recording session", mimeType: "application/json" },
4177
+ async () => {
4178
+ const session = await sessionStore.getLatest();
4179
+ if (!session) {
4180
+ return {
4181
+ contents: [{
4182
+ uri: "session://latest",
4183
+ mimeType: "application/json",
4184
+ text: JSON.stringify({ error: "No sessions found" })
4185
+ }]
4186
+ };
4187
+ }
4188
+ return {
4189
+ contents: [{
4190
+ uri: "session://latest",
4191
+ mimeType: "application/json",
4192
+ text: JSON.stringify(session, null, 2)
4193
+ }]
4194
+ };
4195
+ }
4196
+ );
4197
+ server.resource(
4198
+ "session-by-id",
4199
+ new ResourceTemplate("session://{id}", { list: void 0 }),
4200
+ { description: "Metadata for a specific MCP recording session", mimeType: "application/json" },
4201
+ async (uri, variables) => {
4202
+ const id = variables.id;
4203
+ const session = await sessionStore.get(id);
4204
+ if (!session) {
4205
+ return {
4206
+ contents: [{
2680
4207
  uri: uri.href,
2681
4208
  mimeType: "application/json",
2682
4209
  text: JSON.stringify({ error: `Session not found: ${id}` })
@@ -2695,25 +4222,41 @@ function registerResources(server2) {
2695
4222
  }
2696
4223
 
2697
4224
  // src/mcp/server.ts
2698
- var VERSION = true ? "2.4.0" : "0.0.0-dev";
4225
+ var VERSION = true ? "2.6.0" : "0.0.0-dev";
2699
4226
  function createServer() {
2700
- const server2 = new McpServer2({
4227
+ const server = new McpServer2({
2701
4228
  name: "markupr",
2702
4229
  version: VERSION
2703
4230
  });
2704
- register(server2);
2705
- register2(server2);
2706
- register3(server2);
2707
- register4(server2);
2708
- register5(server2);
2709
- register6(server2);
2710
- registerResources(server2);
2711
- return server2;
4231
+ register(server);
4232
+ register2(server);
4233
+ register3(server);
4234
+ register4(server);
4235
+ register5(server);
4236
+ register6(server);
4237
+ register7(server);
4238
+ register8(server);
4239
+ register9(server);
4240
+ registerResources(server);
4241
+ return server;
2712
4242
  }
2713
4243
 
2714
4244
  // src/mcp/index.ts
2715
- var VERSION2 = true ? "2.4.0" : "0.0.0-dev";
4245
+ var VERSION2 = true ? "2.6.0" : "0.0.0-dev";
2716
4246
  log(`markupr MCP server v${VERSION2} starting...`);
2717
- var server = createServer();
2718
- var transport = new StdioServerTransport();
2719
- await server.connect(transport);
4247
+ process.on("uncaughtException", (error) => {
4248
+ log(`Uncaught exception: ${error instanceof Error ? error.message : String(error)}`);
4249
+ process.exit(1);
4250
+ });
4251
+ process.on("unhandledRejection", (reason) => {
4252
+ log(`Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
4253
+ process.exit(1);
4254
+ });
4255
+ try {
4256
+ const server = createServer();
4257
+ const transport = new StdioServerTransport();
4258
+ await server.connect(transport);
4259
+ } catch (error) {
4260
+ log(`Failed to start MCP server: ${error instanceof Error ? error.message : String(error)}`);
4261
+ process.exit(1);
4262
+ }