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.
- package/README.md +137 -557
- package/dist/cli/index.mjs +2118 -154
- package/dist/main/index.mjs +24 -13
- package/dist/mcp/index.mjs +1612 -69
- package/package.json +27 -8
package/dist/mcp/index.mjs
CHANGED
|
@@ -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(
|
|
251
|
-
|
|
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 += `
|
|
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 += `
|
|
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 += `
|
|
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
|
-
|
|
1940
|
-
|
|
1941
|
-
const
|
|
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,
|
|
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
|
-
|
|
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(
|
|
2268
|
-
|
|
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(
|
|
2331
|
-
|
|
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(
|
|
2428
|
-
|
|
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(
|
|
2520
|
-
|
|
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(
|
|
2572
|
-
|
|
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/
|
|
2644
|
-
import {
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
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
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
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
|
-
|
|
2663
|
-
|
|
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
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
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.
|
|
4225
|
+
var VERSION = true ? "2.6.0" : "0.0.0-dev";
|
|
2699
4226
|
function createServer() {
|
|
2700
|
-
const
|
|
4227
|
+
const server = new McpServer2({
|
|
2701
4228
|
name: "markupr",
|
|
2702
4229
|
version: VERSION
|
|
2703
4230
|
});
|
|
2704
|
-
register(
|
|
2705
|
-
register2(
|
|
2706
|
-
register3(
|
|
2707
|
-
register4(
|
|
2708
|
-
register5(
|
|
2709
|
-
register6(
|
|
2710
|
-
|
|
2711
|
-
|
|
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.
|
|
4245
|
+
var VERSION2 = true ? "2.6.0" : "0.0.0-dev";
|
|
2716
4246
|
log(`markupr MCP server v${VERSION2} starting...`);
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
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
|
+
}
|