markupr 2.1.8 → 2.5.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.
Files changed (300) hide show
  1. package/README.md +292 -15
  2. package/dist/cli/index.mjs +3593 -0
  3. package/dist/main/index.mjs +743 -220
  4. package/dist/mcp/index.mjs +4053 -0
  5. package/package.json +32 -7
  6. package/.claude/commands/review-feedback.md +0 -47
  7. package/.eslintrc.json +0 -35
  8. package/.github/CODEOWNERS +0 -16
  9. package/.github/FUNDING.yml +0 -1
  10. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -56
  11. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -54
  12. package/.github/PULL_REQUEST_TEMPLATE.md +0 -89
  13. package/.github/dependabot.yml +0 -70
  14. package/.github/workflows/ci.yml +0 -184
  15. package/.github/workflows/deploy-landing.yml +0 -134
  16. package/.github/workflows/nightly.yml +0 -288
  17. package/.github/workflows/release.yml +0 -318
  18. package/CHANGELOG.md +0 -127
  19. package/CLAUDE.md +0 -137
  20. package/CODE_OF_CONDUCT.md +0 -9
  21. package/CONTRIBUTING.md +0 -390
  22. package/PRODUCT_VISION.md +0 -277
  23. package/SECURITY.md +0 -51
  24. package/SIGNING_INSTRUCTIONS.md +0 -284
  25. package/assets/DMG_BACKGROUND_INSTRUCTIONS.md +0 -130
  26. package/assets/svg-source/dmg-background.svg +0 -70
  27. package/assets/svg-source/icon.svg +0 -20
  28. package/assets/svg-source/tray-icon-processing.svg +0 -7
  29. package/assets/svg-source/tray-icon-recording.svg +0 -7
  30. package/assets/svg-source/tray-icon.svg +0 -6
  31. package/assets/tray-complete.png +0 -0
  32. package/assets/tray-complete@2x.png +0 -0
  33. package/assets/tray-completeTemplate.png +0 -0
  34. package/assets/tray-completeTemplate@2x.png +0 -0
  35. package/assets/tray-error.png +0 -0
  36. package/assets/tray-error@2x.png +0 -0
  37. package/assets/tray-errorTemplate.png +0 -0
  38. package/assets/tray-errorTemplate@2x.png +0 -0
  39. package/assets/tray-icon-processing.png +0 -0
  40. package/assets/tray-icon-processing@2x.png +0 -0
  41. package/assets/tray-icon-processingTemplate.png +0 -0
  42. package/assets/tray-icon-processingTemplate@2x.png +0 -0
  43. package/assets/tray-icon-recording.png +0 -0
  44. package/assets/tray-icon-recording@2x.png +0 -0
  45. package/assets/tray-icon-recordingTemplate.png +0 -0
  46. package/assets/tray-icon-recordingTemplate@2x.png +0 -0
  47. package/assets/tray-icon.png +0 -0
  48. package/assets/tray-icon@2x.png +0 -0
  49. package/assets/tray-iconTemplate.png +0 -0
  50. package/assets/tray-iconTemplate@2x.png +0 -0
  51. package/assets/tray-idle.png +0 -0
  52. package/assets/tray-idle@2x.png +0 -0
  53. package/assets/tray-idleTemplate.png +0 -0
  54. package/assets/tray-idleTemplate@2x.png +0 -0
  55. package/assets/tray-processing-0.png +0 -0
  56. package/assets/tray-processing-0@2x.png +0 -0
  57. package/assets/tray-processing-0Template.png +0 -0
  58. package/assets/tray-processing-0Template@2x.png +0 -0
  59. package/assets/tray-processing-1.png +0 -0
  60. package/assets/tray-processing-1@2x.png +0 -0
  61. package/assets/tray-processing-1Template.png +0 -0
  62. package/assets/tray-processing-1Template@2x.png +0 -0
  63. package/assets/tray-processing-2.png +0 -0
  64. package/assets/tray-processing-2@2x.png +0 -0
  65. package/assets/tray-processing-2Template.png +0 -0
  66. package/assets/tray-processing-2Template@2x.png +0 -0
  67. package/assets/tray-processing-3.png +0 -0
  68. package/assets/tray-processing-3@2x.png +0 -0
  69. package/assets/tray-processing-3Template.png +0 -0
  70. package/assets/tray-processing-3Template@2x.png +0 -0
  71. package/assets/tray-processing.png +0 -0
  72. package/assets/tray-processing@2x.png +0 -0
  73. package/assets/tray-processingTemplate.png +0 -0
  74. package/assets/tray-processingTemplate@2x.png +0 -0
  75. package/assets/tray-recording.png +0 -0
  76. package/assets/tray-recording@2x.png +0 -0
  77. package/assets/tray-recordingTemplate.png +0 -0
  78. package/assets/tray-recordingTemplate@2x.png +0 -0
  79. package/build/DMG_BACKGROUND_SPEC.md +0 -50
  80. package/build/dmg-background.png +0 -0
  81. package/build/dmg-background@2x.png +0 -0
  82. package/build/entitlements.mac.inherit.plist +0 -27
  83. package/build/entitlements.mac.plist +0 -41
  84. package/build/favicon-16.png +0 -0
  85. package/build/favicon-180.png +0 -0
  86. package/build/favicon-192.png +0 -0
  87. package/build/favicon-32.png +0 -0
  88. package/build/favicon-48.png +0 -0
  89. package/build/favicon-512.png +0 -0
  90. package/build/favicon-64.png +0 -0
  91. package/build/icon-128.png +0 -0
  92. package/build/icon-16.png +0 -0
  93. package/build/icon-24.png +0 -0
  94. package/build/icon-256.png +0 -0
  95. package/build/icon-32.png +0 -0
  96. package/build/icon-48.png +0 -0
  97. package/build/icon-64.png +0 -0
  98. package/build/icon.icns +0 -0
  99. package/build/icon.ico +0 -0
  100. package/build/icon.iconset/icon_128x128.png +0 -0
  101. package/build/icon.iconset/icon_128x128@2x.png +0 -0
  102. package/build/icon.iconset/icon_16x16.png +0 -0
  103. package/build/icon.iconset/icon_16x16@2x.png +0 -0
  104. package/build/icon.iconset/icon_256x256.png +0 -0
  105. package/build/icon.iconset/icon_256x256@2x.png +0 -0
  106. package/build/icon.iconset/icon_32x32.png +0 -0
  107. package/build/icon.iconset/icon_32x32@2x.png +0 -0
  108. package/build/icon.iconset/icon_512x512.png +0 -0
  109. package/build/icon.iconset/icon_512x512@2x.png +0 -0
  110. package/build/icon.png +0 -0
  111. package/build/installer-header.bmp +0 -0
  112. package/build/installer-header.png +0 -0
  113. package/build/installer-sidebar.bmp +0 -0
  114. package/build/installer-sidebar.png +0 -0
  115. package/build/installer.nsh +0 -45
  116. package/build/overlay-processing.png +0 -0
  117. package/build/overlay-recording.png +0 -0
  118. package/build/toolbar-record.png +0 -0
  119. package/build/toolbar-screenshot.png +0 -0
  120. package/build/toolbar-settings.png +0 -0
  121. package/build/toolbar-stop.png +0 -0
  122. package/dist/preload/index.mjs +0 -907
  123. package/dist/renderer/assets/index-CCmUjl9K.js +0 -19495
  124. package/dist/renderer/assets/index-CUqz_Gs6.css +0 -2270
  125. package/dist/renderer/index.html +0 -27
  126. package/docs/AI_AGENT_QUICKSTART.md +0 -42
  127. package/docs/AI_PIPELINE_DESIGN.md +0 -595
  128. package/docs/API.md +0 -514
  129. package/docs/ARCHITECTURE.md +0 -460
  130. package/docs/CONFIGURATION.md +0 -336
  131. package/docs/DEVELOPMENT.md +0 -508
  132. package/docs/EXPORT_FORMATS.md +0 -451
  133. package/docs/GETTING_STARTED.md +0 -236
  134. package/docs/KEYBOARD_SHORTCUTS.md +0 -334
  135. package/docs/TROUBLESHOOTING.md +0 -418
  136. package/docs/landing/index.html +0 -672
  137. package/docs/landing/script.js +0 -342
  138. package/docs/landing/styles.css +0 -1543
  139. package/electron-builder.yml +0 -140
  140. package/electron.vite.config.ts +0 -63
  141. package/railway.json +0 -12
  142. package/scripts/build.mjs +0 -51
  143. package/scripts/generate-icons.mjs +0 -314
  144. package/scripts/generate-installer-images.cjs +0 -253
  145. package/scripts/generate-tray-icons.mjs +0 -258
  146. package/scripts/notarize.cjs +0 -180
  147. package/scripts/one-click-clean-test.sh +0 -147
  148. package/scripts/postinstall.mjs +0 -36
  149. package/scripts/setup-markupr.sh +0 -55
  150. package/setup +0 -17
  151. package/site/index.html +0 -1835
  152. package/site/package.json +0 -11
  153. package/site/railway.json +0 -12
  154. package/site/server.js +0 -31
  155. package/src/main/AutoUpdater.ts +0 -392
  156. package/src/main/CrashRecovery.ts +0 -655
  157. package/src/main/ErrorHandler.ts +0 -703
  158. package/src/main/HotkeyManager.ts +0 -399
  159. package/src/main/MenuManager.ts +0 -529
  160. package/src/main/PermissionManager.ts +0 -420
  161. package/src/main/SessionController.ts +0 -1465
  162. package/src/main/TrayManager.ts +0 -540
  163. package/src/main/ai/AIPipelineManager.ts +0 -199
  164. package/src/main/ai/ClaudeAnalyzer.ts +0 -339
  165. package/src/main/ai/ImageOptimizer.ts +0 -176
  166. package/src/main/ai/StructuredMarkdownBuilder.ts +0 -379
  167. package/src/main/ai/index.ts +0 -16
  168. package/src/main/ai/types.ts +0 -258
  169. package/src/main/analysis/ClarificationGenerator.ts +0 -385
  170. package/src/main/analysis/FeedbackAnalyzer.ts +0 -531
  171. package/src/main/analysis/index.ts +0 -19
  172. package/src/main/audio/AudioCapture.ts +0 -978
  173. package/src/main/audio/audioUtils.ts +0 -100
  174. package/src/main/audio/index.ts +0 -20
  175. package/src/main/capture/index.ts +0 -1
  176. package/src/main/index.ts +0 -1693
  177. package/src/main/ipc/captureHandlers.ts +0 -272
  178. package/src/main/ipc/index.ts +0 -45
  179. package/src/main/ipc/outputHandlers.ts +0 -302
  180. package/src/main/ipc/sessionHandlers.ts +0 -56
  181. package/src/main/ipc/settingsHandlers.ts +0 -471
  182. package/src/main/ipc/types.ts +0 -56
  183. package/src/main/ipc/windowHandlers.ts +0 -277
  184. package/src/main/output/ClipboardService.ts +0 -369
  185. package/src/main/output/ExportService.ts +0 -539
  186. package/src/main/output/FileManager.ts +0 -416
  187. package/src/main/output/MarkdownGenerator.ts +0 -791
  188. package/src/main/output/MarkdownPatcher.ts +0 -299
  189. package/src/main/output/index.ts +0 -186
  190. package/src/main/output/sessionAdapter.ts +0 -207
  191. package/src/main/output/templates/html-template.ts +0 -553
  192. package/src/main/pipeline/FrameExtractor.ts +0 -330
  193. package/src/main/pipeline/PostProcessor.ts +0 -399
  194. package/src/main/pipeline/TranscriptAnalyzer.ts +0 -226
  195. package/src/main/pipeline/index.ts +0 -36
  196. package/src/main/platform/WindowsTaskbar.ts +0 -600
  197. package/src/main/platform/index.ts +0 -16
  198. package/src/main/settings/SettingsManager.ts +0 -730
  199. package/src/main/settings/index.ts +0 -19
  200. package/src/main/transcription/ModelDownloadManager.ts +0 -494
  201. package/src/main/transcription/TierManager.ts +0 -219
  202. package/src/main/transcription/TranscriptionRecoveryService.ts +0 -340
  203. package/src/main/transcription/WhisperService.ts +0 -748
  204. package/src/main/transcription/index.ts +0 -56
  205. package/src/main/transcription/types.ts +0 -135
  206. package/src/main/windows/PopoverManager.ts +0 -284
  207. package/src/main/windows/TaskbarIntegration.ts +0 -452
  208. package/src/main/windows/index.ts +0 -23
  209. package/src/preload/index.ts +0 -1047
  210. package/src/renderer/App.tsx +0 -515
  211. package/src/renderer/AppWrapper.tsx +0 -28
  212. package/src/renderer/assets/logo-dark.svg +0 -7
  213. package/src/renderer/assets/logo.svg +0 -7
  214. package/src/renderer/audio/AudioCaptureRenderer.ts +0 -454
  215. package/src/renderer/capture/ScreenRecordingRenderer.ts +0 -492
  216. package/src/renderer/components/AnnotationOverlay.tsx +0 -836
  217. package/src/renderer/components/AudioWaveform.tsx +0 -811
  218. package/src/renderer/components/ClarificationQuestions.tsx +0 -656
  219. package/src/renderer/components/CountdownTimer.tsx +0 -495
  220. package/src/renderer/components/CrashRecoveryDialog.tsx +0 -632
  221. package/src/renderer/components/DonateButton.tsx +0 -127
  222. package/src/renderer/components/ErrorBoundary.tsx +0 -308
  223. package/src/renderer/components/ExportDialog.tsx +0 -872
  224. package/src/renderer/components/HotkeyHint.tsx +0 -261
  225. package/src/renderer/components/KeyboardShortcuts.tsx +0 -787
  226. package/src/renderer/components/ModelDownloadDialog.tsx +0 -844
  227. package/src/renderer/components/Onboarding.tsx +0 -1830
  228. package/src/renderer/components/ProcessingOverlay.tsx +0 -157
  229. package/src/renderer/components/RecordingOverlay.tsx +0 -423
  230. package/src/renderer/components/SessionHistory.tsx +0 -1746
  231. package/src/renderer/components/SessionReview.tsx +0 -1321
  232. package/src/renderer/components/SettingsPanel.tsx +0 -217
  233. package/src/renderer/components/Skeleton.tsx +0 -347
  234. package/src/renderer/components/StatusIndicator.tsx +0 -86
  235. package/src/renderer/components/ThemeProvider.tsx +0 -429
  236. package/src/renderer/components/Tooltip.tsx +0 -370
  237. package/src/renderer/components/TranscriptionPreview.tsx +0 -183
  238. package/src/renderer/components/TranscriptionTierSelector.tsx +0 -640
  239. package/src/renderer/components/UpdateNotification.tsx +0 -377
  240. package/src/renderer/components/WindowSelector.tsx +0 -947
  241. package/src/renderer/components/index.ts +0 -99
  242. package/src/renderer/components/primitives/ApiKeyInput.tsx +0 -98
  243. package/src/renderer/components/primitives/ColorPicker.tsx +0 -65
  244. package/src/renderer/components/primitives/DangerButton.tsx +0 -45
  245. package/src/renderer/components/primitives/DirectoryPicker.tsx +0 -41
  246. package/src/renderer/components/primitives/Dropdown.tsx +0 -34
  247. package/src/renderer/components/primitives/KeyRecorder.tsx +0 -117
  248. package/src/renderer/components/primitives/SettingsSection.tsx +0 -32
  249. package/src/renderer/components/primitives/Slider.tsx +0 -43
  250. package/src/renderer/components/primitives/Toggle.tsx +0 -36
  251. package/src/renderer/components/primitives/index.ts +0 -10
  252. package/src/renderer/components/settings/AdvancedTab.tsx +0 -174
  253. package/src/renderer/components/settings/AppearanceTab.tsx +0 -77
  254. package/src/renderer/components/settings/GeneralTab.tsx +0 -40
  255. package/src/renderer/components/settings/HotkeysTab.tsx +0 -79
  256. package/src/renderer/components/settings/RecordingTab.tsx +0 -84
  257. package/src/renderer/components/settings/index.ts +0 -9
  258. package/src/renderer/components/settings/settingsStyles.ts +0 -673
  259. package/src/renderer/components/settings/tabConfig.tsx +0 -85
  260. package/src/renderer/components/settings/useSettingsPanel.ts +0 -447
  261. package/src/renderer/contexts/ProcessingContext.tsx +0 -227
  262. package/src/renderer/contexts/RecordingContext.tsx +0 -683
  263. package/src/renderer/contexts/UIContext.tsx +0 -326
  264. package/src/renderer/contexts/index.ts +0 -24
  265. package/src/renderer/donateMessages.ts +0 -69
  266. package/src/renderer/hooks/index.ts +0 -75
  267. package/src/renderer/hooks/useAnimation.tsx +0 -544
  268. package/src/renderer/hooks/useTheme.ts +0 -313
  269. package/src/renderer/index.html +0 -26
  270. package/src/renderer/main.tsx +0 -52
  271. package/src/renderer/styles/animations.css +0 -1093
  272. package/src/renderer/styles/app-shell.css +0 -662
  273. package/src/renderer/styles/globals.css +0 -515
  274. package/src/renderer/styles/theme.ts +0 -578
  275. package/src/renderer/types/electron.d.ts +0 -385
  276. package/src/shared/hotkeys.ts +0 -283
  277. package/src/shared/types.ts +0 -809
  278. package/tests/clipboard.test.ts +0 -228
  279. package/tests/e2e/criticalPaths.test.ts +0 -594
  280. package/tests/feedbackAnalyzer.test.ts +0 -303
  281. package/tests/integration/sessionFlow.test.ts +0 -583
  282. package/tests/markdownGenerator.test.ts +0 -418
  283. package/tests/output.test.ts +0 -96
  284. package/tests/setup.ts +0 -486
  285. package/tests/unit/appIntegration.test.ts +0 -676
  286. package/tests/unit/appViewState.test.ts +0 -281
  287. package/tests/unit/audioIpcChannels.test.ts +0 -17
  288. package/tests/unit/exportService.test.ts +0 -492
  289. package/tests/unit/hotkeys.test.ts +0 -92
  290. package/tests/unit/navigationPreload.test.ts +0 -94
  291. package/tests/unit/onboardingFlow.test.ts +0 -345
  292. package/tests/unit/permissionManager.test.ts +0 -175
  293. package/tests/unit/permissionManagerExpanded.test.ts +0 -296
  294. package/tests/unit/screenRecordingRenderer.test.ts +0 -368
  295. package/tests/unit/sessionController.test.ts +0 -515
  296. package/tests/unit/tierManager.test.ts +0 -61
  297. package/tests/unit/tierManagerExpanded.test.ts +0 -142
  298. package/tests/unit/transcriptAnalyzer.test.ts +0 -64
  299. package/tsconfig.json +0 -25
  300. package/vitest.config.ts +0 -46
@@ -0,0 +1,3593 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
5
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
6
+ }) : x)(function(x) {
7
+ if (typeof require !== "undefined") return require.apply(this, arguments);
8
+ throw Error('Dynamic require of "' + x + '" is not supported');
9
+ });
10
+ var __esm = (fn, res) => function __init() {
11
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
12
+ };
13
+ var __export = (target, all) => {
14
+ for (var name in all)
15
+ __defProp(target, name, { get: all[name], enumerable: true });
16
+ };
17
+
18
+ // src/integrations/linear/types.ts
19
+ var SEVERITY_TO_PRIORITY, CATEGORY_TO_LABEL;
20
+ var init_types = __esm({
21
+ "src/integrations/linear/types.ts"() {
22
+ "use strict";
23
+ SEVERITY_TO_PRIORITY = {
24
+ Critical: 1,
25
+ High: 2,
26
+ Medium: 3,
27
+ Low: 4
28
+ };
29
+ CATEGORY_TO_LABEL = {
30
+ "Bug": "Bug",
31
+ "UX Issue": "Improvement",
32
+ "Suggestion": "Feature",
33
+ "Performance": "Bug",
34
+ "Question": "Feature",
35
+ "General": "Feature"
36
+ };
37
+ }
38
+ });
39
+
40
+ // src/integrations/linear/LinearIssueCreator.ts
41
+ var LinearIssueCreator_exports = {};
42
+ __export(LinearIssueCreator_exports, {
43
+ LinearIssueCreator: () => LinearIssueCreator,
44
+ parseMarkdownReport: () => parseMarkdownReport
45
+ });
46
+ import { readFile as readFile2 } from "fs/promises";
47
+ function parseMarkdownReport(markdown) {
48
+ const items = [];
49
+ const itemPattern = /^### (FB-\d+): (.+)$/gm;
50
+ const matches = [];
51
+ let match;
52
+ while ((match = itemPattern.exec(markdown)) !== null) {
53
+ matches.push({ index: match.index, id: match[1], title: match[2] });
54
+ }
55
+ for (let i = 0; i < matches.length; i++) {
56
+ const start = matches[i].index;
57
+ const end = i + 1 < matches.length ? matches[i + 1].index : markdown.length;
58
+ const section = markdown.slice(start, end);
59
+ const severity = extractField(section, "Severity") || "Medium";
60
+ const category = extractField(section, "Type") || "General";
61
+ const timestamp = extractField(section, "Timestamp") || "00:00";
62
+ const description = extractBlockquote(section);
63
+ const screenshotPaths = extractScreenshots(section);
64
+ const suggestedAction = extractSuggestedAction(section);
65
+ items.push({
66
+ id: matches[i].id,
67
+ title: matches[i].title,
68
+ severity,
69
+ category,
70
+ timestamp,
71
+ description,
72
+ screenshotPaths,
73
+ suggestedAction
74
+ });
75
+ }
76
+ return items;
77
+ }
78
+ function extractField(section, fieldName) {
79
+ const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, "m");
80
+ const match = section.match(pattern);
81
+ return match ? match[1].trim() : "";
82
+ }
83
+ function extractBlockquote(section) {
84
+ const whatHappened = section.match(/#### What Happened\s*\n([\s\S]*?)(?=\n####|\n---)/);
85
+ if (!whatHappened) return "";
86
+ const lines = whatHappened[1].split("\n").filter((line) => line.startsWith(">")).map((line) => line.replace(/^>\s*/, "").trim());
87
+ return lines.join(" ").trim();
88
+ }
89
+ function extractScreenshots(section) {
90
+ const paths = [];
91
+ const pattern = /!\[.*?\]\((.+?)\)/g;
92
+ let match;
93
+ while ((match = pattern.exec(section)) !== null) {
94
+ paths.push(match[1]);
95
+ }
96
+ return paths;
97
+ }
98
+ function extractSuggestedAction(section) {
99
+ const actionSection = section.match(/#### Suggested Next Step\s*\n-\s*(.+)/);
100
+ return actionSection ? actionSection[1].trim() : "";
101
+ }
102
+ var LINEAR_API_URL, LinearIssueCreator;
103
+ var init_LinearIssueCreator = __esm({
104
+ "src/integrations/linear/LinearIssueCreator.ts"() {
105
+ "use strict";
106
+ init_types();
107
+ LINEAR_API_URL = "https://api.linear.app/graphql";
108
+ LinearIssueCreator = class {
109
+ token;
110
+ constructor(token) {
111
+ this.token = token;
112
+ }
113
+ /**
114
+ * Push a markupr report to Linear, creating one issue per feedback item.
115
+ */
116
+ async pushReport(reportPath, options) {
117
+ const markdown = await readFile2(reportPath, "utf-8");
118
+ const items = parseMarkdownReport(markdown);
119
+ const team = await this.resolveTeam(options.teamKey);
120
+ const labels = await this.getTeamLabels(team.id);
121
+ const result = {
122
+ teamKey: options.teamKey,
123
+ totalItems: items.length,
124
+ created: 0,
125
+ failed: 0,
126
+ issues: [],
127
+ dryRun: options.dryRun ?? false
128
+ };
129
+ for (const item of items) {
130
+ const labelName = CATEGORY_TO_LABEL[item.category] ?? "Feature";
131
+ const matchingLabel = labels.find(
132
+ (l) => l.name.toLowerCase() === labelName.toLowerCase()
133
+ );
134
+ const issueInput = {
135
+ title: `[${item.id}] ${item.title}`,
136
+ description: this.buildIssueDescription(item),
137
+ teamId: team.id,
138
+ priority: SEVERITY_TO_PRIORITY[item.severity] ?? 3,
139
+ labelIds: matchingLabel ? [matchingLabel.id] : void 0,
140
+ projectId: options.projectName ? await this.resolveProjectId(team.id, options.projectName) : void 0
141
+ };
142
+ if (options.dryRun) {
143
+ result.issues.push({
144
+ success: true,
145
+ issueId: `dry-run-${item.id}`,
146
+ identifier: `DRY-${item.id}`,
147
+ issueUrl: `https://linear.app/dry-run/${item.id}`
148
+ });
149
+ result.created++;
150
+ continue;
151
+ }
152
+ const issueResult = await this.createIssue(issueInput);
153
+ result.issues.push(issueResult);
154
+ if (issueResult.success) {
155
+ result.created++;
156
+ } else {
157
+ result.failed++;
158
+ }
159
+ }
160
+ return result;
161
+ }
162
+ /**
163
+ * Create a single Linear issue via GraphQL.
164
+ */
165
+ async createIssue(input) {
166
+ const mutation = `
167
+ mutation IssueCreate($input: IssueCreateInput!) {
168
+ issueCreate(input: $input) {
169
+ success
170
+ issue {
171
+ id
172
+ url
173
+ identifier
174
+ }
175
+ }
176
+ }
177
+ `;
178
+ const variables = {
179
+ input: {
180
+ title: input.title,
181
+ description: input.description,
182
+ teamId: input.teamId,
183
+ priority: input.priority,
184
+ ...input.labelIds && { labelIds: input.labelIds },
185
+ ...input.projectId && { projectId: input.projectId }
186
+ }
187
+ };
188
+ try {
189
+ const data = await this.graphql(mutation, variables);
190
+ if (data.issueCreate.success) {
191
+ return {
192
+ success: true,
193
+ issueId: data.issueCreate.issue.id,
194
+ issueUrl: data.issueCreate.issue.url,
195
+ identifier: data.issueCreate.issue.identifier
196
+ };
197
+ }
198
+ return { success: false, error: "Linear API returned success: false" };
199
+ } catch (error) {
200
+ return {
201
+ success: false,
202
+ error: error instanceof Error ? error.message : String(error)
203
+ };
204
+ }
205
+ }
206
+ /**
207
+ * Resolve a team key (e.g., "ENG") to a team ID.
208
+ */
209
+ async resolveTeam(teamKey) {
210
+ const query = `
211
+ query Teams {
212
+ teams {
213
+ nodes {
214
+ id
215
+ key
216
+ name
217
+ }
218
+ }
219
+ }
220
+ `;
221
+ const data = await this.graphql(query);
222
+ const team = data.teams.nodes.find(
223
+ (t) => t.key.toLowerCase() === teamKey.toLowerCase()
224
+ );
225
+ if (!team) {
226
+ const available = data.teams.nodes.map((t) => t.key).join(", ");
227
+ throw new Error(
228
+ `Team "${teamKey}" not found. Available teams: ${available}`
229
+ );
230
+ }
231
+ return team;
232
+ }
233
+ /**
234
+ * Get all labels for a team.
235
+ */
236
+ async getTeamLabels(teamId) {
237
+ const query = `
238
+ query TeamLabels($teamId: String!) {
239
+ team(id: $teamId) {
240
+ labels {
241
+ nodes {
242
+ id
243
+ name
244
+ }
245
+ }
246
+ }
247
+ }
248
+ `;
249
+ const data = await this.graphql(query, { teamId });
250
+ return data.team.labels.nodes;
251
+ }
252
+ /**
253
+ * Resolve a project name to a project ID within a team.
254
+ */
255
+ async resolveProjectId(teamId, projectName) {
256
+ const query = `
257
+ query Projects($teamId: String!) {
258
+ team(id: $teamId) {
259
+ projects {
260
+ nodes {
261
+ id
262
+ name
263
+ }
264
+ }
265
+ }
266
+ }
267
+ `;
268
+ const data = await this.graphql(query, { teamId });
269
+ const project = data.team.projects.nodes.find(
270
+ (p) => p.name.toLowerCase() === projectName.toLowerCase()
271
+ );
272
+ return project?.id;
273
+ }
274
+ /**
275
+ * Build markdown description for a Linear issue from a feedback item.
276
+ */
277
+ buildIssueDescription(item) {
278
+ let desc = `## markupr Feedback: ${item.id}
279
+
280
+ `;
281
+ desc += `**Severity:** ${item.severity}
282
+ `;
283
+ desc += `**Category:** ${item.category}
284
+ `;
285
+ desc += `**Timestamp:** ${item.timestamp}
286
+
287
+ `;
288
+ desc += `### Description
289
+
290
+ ${item.description}
291
+
292
+ `;
293
+ if (item.suggestedAction) {
294
+ desc += `### Suggested Action
295
+
296
+ ${item.suggestedAction}
297
+
298
+ `;
299
+ }
300
+ if (item.screenshotPaths.length > 0) {
301
+ desc += `### Screenshots
302
+
303
+ `;
304
+ desc += `_${item.screenshotPaths.length} screenshot(s) captured during session._
305
+ `;
306
+ for (const path3 of item.screenshotPaths) {
307
+ desc += `- \`${path3}\`
308
+ `;
309
+ }
310
+ }
311
+ desc += `
312
+ ---
313
+ *Created by [markupr](https://markupr.com)*`;
314
+ return desc;
315
+ }
316
+ /**
317
+ * Execute a GraphQL request against the Linear API.
318
+ */
319
+ async graphql(query, variables) {
320
+ const response = await fetch(LINEAR_API_URL, {
321
+ method: "POST",
322
+ headers: {
323
+ "Content-Type": "application/json",
324
+ Authorization: this.token
325
+ },
326
+ body: JSON.stringify({ query, variables })
327
+ });
328
+ if (!response.ok) {
329
+ throw new Error(
330
+ `Linear API error: ${response.status} ${response.statusText}`
331
+ );
332
+ }
333
+ const json = await response.json();
334
+ if (json.errors && json.errors.length > 0) {
335
+ throw new Error(`Linear GraphQL error: ${json.errors[0].message}`);
336
+ }
337
+ if (!json.data) {
338
+ throw new Error("Linear API returned no data");
339
+ }
340
+ return json.data;
341
+ }
342
+ };
343
+ }
344
+ });
345
+
346
+ // src/integrations/github/types.ts
347
+ var CATEGORY_LABELS, SEVERITY_LABELS, MARKUPR_LABEL;
348
+ var init_types2 = __esm({
349
+ "src/integrations/github/types.ts"() {
350
+ "use strict";
351
+ CATEGORY_LABELS = {
352
+ Bug: { name: "bug", color: "d73a4a", description: "Something isn't working" },
353
+ "UX Issue": { name: "ux", color: "e4e669", description: "User experience issue" },
354
+ Suggestion: { name: "enhancement", color: "a2eeef", description: "New feature or request" },
355
+ Performance: { name: "performance", color: "f9d0c4", description: "Performance issue" },
356
+ Question: { name: "question", color: "d876e3", description: "Further information is requested" },
357
+ General: { name: "feedback", color: "c5def5", description: "General feedback" }
358
+ };
359
+ SEVERITY_LABELS = {
360
+ Critical: { name: "priority: critical", color: "b60205", description: "Critical priority" },
361
+ High: { name: "priority: high", color: "d93f0b", description: "High priority" },
362
+ Medium: { name: "priority: medium", color: "fbca04", description: "Medium priority" },
363
+ Low: { name: "priority: low", color: "0e8a16", description: "Low priority" }
364
+ };
365
+ MARKUPR_LABEL = {
366
+ name: "markupr",
367
+ color: "6f42c1",
368
+ description: "Created from markupr feedback session"
369
+ };
370
+ }
371
+ });
372
+
373
+ // src/integrations/github/GitHubIssueCreator.ts
374
+ var GitHubIssueCreator_exports = {};
375
+ __export(GitHubIssueCreator_exports, {
376
+ GitHubAPIClient: () => GitHubAPIClient,
377
+ collectRequiredLabels: () => collectRequiredLabels,
378
+ formatIssueBody: () => formatIssueBody,
379
+ getLabelsForItem: () => getLabelsForItem,
380
+ parseMarkuprReport: () => parseMarkuprReport,
381
+ parseRepoString: () => parseRepoString,
382
+ pushToGitHub: () => pushToGitHub,
383
+ resolveAuth: () => resolveAuth
384
+ });
385
+ import { readFile as readFile3 } from "fs/promises";
386
+ async function resolveAuth(explicitToken) {
387
+ if (explicitToken) {
388
+ return { token: explicitToken, source: "flag" };
389
+ }
390
+ const envToken = process.env.GITHUB_TOKEN;
391
+ if (envToken) {
392
+ return { token: envToken, source: "env" };
393
+ }
394
+ try {
395
+ const { execSync } = await import("child_process");
396
+ const ghToken = execSync("gh auth token", { encoding: "utf-8", timeout: 5e3 }).trim();
397
+ if (ghToken) {
398
+ return { token: ghToken, source: "gh-cli" };
399
+ }
400
+ } catch {
401
+ }
402
+ throw new Error(
403
+ "No GitHub token found. Provide one via:\n --token <token>\n GITHUB_TOKEN environment variable\n gh auth login (GitHub CLI)"
404
+ );
405
+ }
406
+ function parseMarkuprReport(markdown) {
407
+ const items = [];
408
+ const itemPattern = /### (FB-\d{3}): (.+?)(?=\n)/g;
409
+ let match;
410
+ while ((match = itemPattern.exec(markdown)) !== null) {
411
+ const id = match[1];
412
+ const title = match[2].trim();
413
+ const startIndex = match.index;
414
+ const rest = markdown.slice(startIndex + match[0].length);
415
+ const nextSectionMatch = rest.match(/\n### FB-\d{3}:|(?=\n## [A-Z])/);
416
+ const itemBlock = nextSectionMatch ? rest.slice(0, nextSectionMatch.index) : rest;
417
+ const severity = extractField2(itemBlock, "Severity") || "Medium";
418
+ const category = extractField2(itemBlock, "Type") || "General";
419
+ const timestamp = extractField2(itemBlock, "Timestamp") || "00:00";
420
+ const transcription = extractTranscription(itemBlock);
421
+ const screenshotPaths = extractScreenshots2(itemBlock);
422
+ const suggestedAction = extractSuggestedAction2(itemBlock);
423
+ items.push({
424
+ id,
425
+ title,
426
+ category,
427
+ severity,
428
+ timestamp,
429
+ transcription,
430
+ screenshotPaths,
431
+ suggestedAction
432
+ });
433
+ }
434
+ return items;
435
+ }
436
+ function extractField2(block, fieldName) {
437
+ const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`);
438
+ const match = block.match(pattern);
439
+ return match ? match[1].trim() : void 0;
440
+ }
441
+ function extractTranscription(block) {
442
+ const whatHappenedIdx = block.indexOf("#### What Happened");
443
+ if (whatHappenedIdx === -1) return "";
444
+ const afterHeading = block.slice(whatHappenedIdx);
445
+ const nextHeading = afterHeading.indexOf("\n####", 5);
446
+ const section = nextHeading !== -1 ? afterHeading.slice(0, nextHeading) : afterHeading;
447
+ const lines = section.split("\n");
448
+ const quotedLines = [];
449
+ for (const line of lines) {
450
+ const trimmed = line.trim();
451
+ if (trimmed.startsWith("> ")) {
452
+ quotedLines.push(trimmed.slice(2));
453
+ } else if (trimmed === ">") {
454
+ quotedLines.push("");
455
+ }
456
+ }
457
+ return quotedLines.join(" ").trim();
458
+ }
459
+ function extractScreenshots2(block) {
460
+ const paths = [];
461
+ const pattern = /!\[.*?\]\((.+?)\)/g;
462
+ let match;
463
+ while ((match = pattern.exec(block)) !== null) {
464
+ paths.push(match[1]);
465
+ }
466
+ return paths;
467
+ }
468
+ function extractSuggestedAction2(block) {
469
+ const idx = block.indexOf("#### Suggested Next Step");
470
+ if (idx === -1) return "";
471
+ const afterHeading = block.slice(idx + "#### Suggested Next Step".length);
472
+ const nextSection = afterHeading.indexOf("\n---");
473
+ const section = nextSection !== -1 ? afterHeading.slice(0, nextSection) : afterHeading;
474
+ const lines = section.split("\n");
475
+ for (const line of lines) {
476
+ const trimmed = line.trim();
477
+ if (trimmed.startsWith("- ")) {
478
+ return trimmed.slice(2);
479
+ }
480
+ }
481
+ return "";
482
+ }
483
+ function formatIssueBody(item, reportPath) {
484
+ let body = `## ${item.id}: ${item.title}
485
+
486
+ `;
487
+ body += `| Field | Value |
488
+ |-------|-------|
489
+ `;
490
+ body += `| **Severity** | ${item.severity} |
491
+ `;
492
+ body += `| **Category** | ${item.category} |
493
+ `;
494
+ body += `| **Timestamp** | ${item.timestamp} |
495
+
496
+ `;
497
+ body += `### What Happened
498
+
499
+ `;
500
+ body += `> ${item.transcription}
501
+
502
+ `;
503
+ if (item.screenshotPaths.length > 0) {
504
+ body += `### Screenshots
505
+
506
+ `;
507
+ body += `_${item.screenshotPaths.length} screenshot(s) captured \u2014 see the markupr report for images._
508
+
509
+ `;
510
+ }
511
+ if (item.suggestedAction) {
512
+ body += `### Suggested Action
513
+
514
+ `;
515
+ body += `${item.suggestedAction}
516
+
517
+ `;
518
+ }
519
+ body += `---
520
+ `;
521
+ if (reportPath) {
522
+ body += `_Source: \`${reportPath}\`_
523
+ `;
524
+ }
525
+ body += `_Created by [markupr](https://markupr.com)_
526
+ `;
527
+ return body;
528
+ }
529
+ function getLabelsForItem(item) {
530
+ const labels = [MARKUPR_LABEL.name];
531
+ const categoryLabel = CATEGORY_LABELS[item.category];
532
+ if (categoryLabel) {
533
+ labels.push(categoryLabel.name);
534
+ }
535
+ const severityLabel = SEVERITY_LABELS[item.severity];
536
+ if (severityLabel) {
537
+ labels.push(severityLabel.name);
538
+ }
539
+ return labels;
540
+ }
541
+ function collectRequiredLabels(items) {
542
+ const seen = /* @__PURE__ */ new Set();
543
+ const labels = [];
544
+ seen.add(MARKUPR_LABEL.name);
545
+ labels.push(MARKUPR_LABEL);
546
+ for (const item of items) {
547
+ const catLabel = CATEGORY_LABELS[item.category];
548
+ if (catLabel && !seen.has(catLabel.name)) {
549
+ seen.add(catLabel.name);
550
+ labels.push(catLabel);
551
+ }
552
+ const sevLabel = SEVERITY_LABELS[item.severity];
553
+ if (sevLabel && !seen.has(sevLabel.name)) {
554
+ seen.add(sevLabel.name);
555
+ labels.push(sevLabel);
556
+ }
557
+ }
558
+ return labels;
559
+ }
560
+ async function pushToGitHub(options) {
561
+ const { repo, auth, reportPath, dryRun = false, items: filterIds } = options;
562
+ const markdown = await readFile3(reportPath, "utf-8");
563
+ let items = parseMarkuprReport(markdown);
564
+ if (items.length === 0) {
565
+ throw new Error("No feedback items found in the report. Is this a valid markupr report?");
566
+ }
567
+ if (filterIds && filterIds.length > 0) {
568
+ const filterSet = new Set(filterIds.map((id) => id.toUpperCase()));
569
+ items = items.filter((item) => filterSet.has(item.id));
570
+ if (items.length === 0) {
571
+ throw new Error(`None of the specified items (${filterIds.join(", ")}) found in the report`);
572
+ }
573
+ }
574
+ const result = {
575
+ created: [],
576
+ labelsCreated: [],
577
+ errors: [],
578
+ dryRun
579
+ };
580
+ if (dryRun) {
581
+ for (const item of items) {
582
+ const labels = getLabelsForItem(item);
583
+ result.created.push({
584
+ number: 0,
585
+ url: "",
586
+ title: `[${item.id}] ${item.title}`
587
+ });
588
+ }
589
+ result.labelsCreated = collectRequiredLabels(items).map((l) => l.name);
590
+ return result;
591
+ }
592
+ const client = new GitHubAPIClient(auth);
593
+ await client.verifyAccess(repo);
594
+ const requiredLabels = collectRequiredLabels(items);
595
+ for (const label of requiredLabels) {
596
+ try {
597
+ const created = await client.ensureLabel(repo, label);
598
+ if (created) {
599
+ result.labelsCreated.push(label.name);
600
+ }
601
+ } catch (err) {
602
+ const message = err instanceof Error ? err.message : String(err);
603
+ result.errors.push({ itemId: "labels", error: message });
604
+ }
605
+ }
606
+ for (const item of items) {
607
+ try {
608
+ const labels = getLabelsForItem(item);
609
+ const body = formatIssueBody(item, reportPath);
610
+ const issueResult = await client.createIssue(repo, {
611
+ title: `[${item.id}] ${item.title}`,
612
+ body,
613
+ labels
614
+ });
615
+ result.created.push(issueResult);
616
+ } catch (err) {
617
+ const message = err instanceof Error ? err.message : String(err);
618
+ result.errors.push({ itemId: item.id, error: message });
619
+ }
620
+ }
621
+ return result;
622
+ }
623
+ function parseRepoString(repoStr) {
624
+ const parts = repoStr.split("/");
625
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
626
+ throw new Error(`Invalid repository format: "${repoStr}". Expected "owner/repo".`);
627
+ }
628
+ return { owner: parts[0], repo: parts[1] };
629
+ }
630
+ var GITHUB_API, GitHubAPIClient;
631
+ var init_GitHubIssueCreator = __esm({
632
+ "src/integrations/github/GitHubIssueCreator.ts"() {
633
+ "use strict";
634
+ init_types2();
635
+ GITHUB_API = "https://api.github.com";
636
+ GitHubAPIClient = class {
637
+ baseUrl;
638
+ headers;
639
+ constructor(auth, baseUrl = GITHUB_API) {
640
+ this.baseUrl = baseUrl;
641
+ this.headers = {
642
+ Authorization: `Bearer ${auth.token}`,
643
+ Accept: "application/vnd.github+json",
644
+ "X-GitHub-Api-Version": "2022-11-28",
645
+ "Content-Type": "application/json",
646
+ "User-Agent": "markupr-github-integration"
647
+ };
648
+ }
649
+ async createIssue(repo, input) {
650
+ const url = `${this.baseUrl}/repos/${repo.owner}/${repo.repo}/issues`;
651
+ const response = await fetch(url, {
652
+ method: "POST",
653
+ headers: this.headers,
654
+ body: JSON.stringify({
655
+ title: input.title,
656
+ body: input.body,
657
+ labels: input.labels
658
+ })
659
+ });
660
+ if (!response.ok) {
661
+ const text = await response.text();
662
+ throw new Error(`GitHub API error (${response.status}): ${text}`);
663
+ }
664
+ const data = await response.json();
665
+ return {
666
+ number: data.number,
667
+ url: data.html_url,
668
+ title: data.title
669
+ };
670
+ }
671
+ async ensureLabel(repo, label) {
672
+ const url = `${this.baseUrl}/repos/${repo.owner}/${repo.repo}/labels`;
673
+ const checkUrl = `${url}/${encodeURIComponent(label.name)}`;
674
+ const checkResponse = await fetch(checkUrl, {
675
+ method: "GET",
676
+ headers: this.headers
677
+ });
678
+ if (checkResponse.ok) {
679
+ return false;
680
+ }
681
+ const createResponse = await fetch(url, {
682
+ method: "POST",
683
+ headers: this.headers,
684
+ body: JSON.stringify({
685
+ name: label.name,
686
+ color: label.color,
687
+ description: label.description
688
+ })
689
+ });
690
+ if (!createResponse.ok) {
691
+ if (createResponse.status === 422) {
692
+ return false;
693
+ }
694
+ const text = await createResponse.text();
695
+ throw new Error(`Failed to create label "${label.name}": ${text}`);
696
+ }
697
+ return true;
698
+ }
699
+ async verifyAccess(repo) {
700
+ const url = `${this.baseUrl}/repos/${repo.owner}/${repo.repo}`;
701
+ const response = await fetch(url, {
702
+ method: "GET",
703
+ headers: this.headers
704
+ });
705
+ if (!response.ok) {
706
+ if (response.status === 404) {
707
+ throw new Error(`Repository ${repo.owner}/${repo.repo} not found (or no access)`);
708
+ }
709
+ if (response.status === 401) {
710
+ throw new Error("GitHub token is invalid or expired");
711
+ }
712
+ throw new Error(`Failed to access repository (${response.status})`);
713
+ }
714
+ }
715
+ };
716
+ }
717
+ });
718
+
719
+ // src/cli/index.ts
720
+ import { existsSync as existsSync5 } from "fs";
721
+ import { resolve as resolve2 } from "path";
722
+ import { Command } from "commander";
723
+
724
+ // src/cli/CLIPipeline.ts
725
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2 } from "fs";
726
+ import { stat, unlink as unlink2, writeFile, chmod as chmod2 } from "fs/promises";
727
+ import { join as join3, basename as basename2 } from "path";
728
+ import { execFile as execFileCb2 } from "child_process";
729
+ import { tmpdir as tmpdir2 } from "os";
730
+ import { randomUUID as randomUUID2 } from "crypto";
731
+
732
+ // src/main/pipeline/TranscriptAnalyzer.ts
733
+ var PAUSE_THRESHOLD_SECONDS = 1.5;
734
+ var PERIODIC_INTERVAL_SECONDS = 15;
735
+ var MAX_PERIODIC_INTERVAL_SECONDS = 20;
736
+ var MAX_KEY_MOMENTS = 20;
737
+ var FRAME_EDGE_MARGIN_SECONDS = 0.35;
738
+ var TranscriptAnalyzer = class {
739
+ /**
740
+ * Analyze transcript segments and return key moments where frames
741
+ * should be extracted from the video recording.
742
+ *
743
+ * @param segments - Array of transcript segments with timing info
744
+ * @param aiHints - Optional AI-informed key-moment hints to merge
745
+ * @returns Array of key moments sorted by timestamp, capped at 20
746
+ */
747
+ analyze(segments, aiHints = []) {
748
+ if (segments.length === 0) {
749
+ return [];
750
+ }
751
+ const moments = [];
752
+ const firstSegment = segments[0];
753
+ const lastSegment = segments[segments.length - 1];
754
+ const sessionDuration = lastSegment.endTime - firstSegment.startTime;
755
+ const startOffset = sessionDuration > FRAME_EDGE_MARGIN_SECONDS ? FRAME_EDGE_MARGIN_SECONDS : 0;
756
+ moments.push({
757
+ timestamp: firstSegment.startTime + startOffset,
758
+ reason: "Session start",
759
+ confidence: 1
760
+ });
761
+ for (let i = 1; i < segments.length; i++) {
762
+ const prev = segments[i - 1];
763
+ const curr = segments[i];
764
+ const gap = curr.startTime - prev.endTime;
765
+ if (gap >= PAUSE_THRESHOLD_SECONDS) {
766
+ moments.push({
767
+ timestamp: prev.endTime,
768
+ reason: "Natural pause in narration",
769
+ confidence: Math.min(1, gap / 3)
770
+ // Longer pauses = higher confidence
771
+ });
772
+ }
773
+ }
774
+ if (lastSegment.endTime > firstSegment.startTime + startOffset) {
775
+ moments.push({
776
+ timestamp: lastSegment.endTime,
777
+ reason: "Session end",
778
+ confidence: 1
779
+ });
780
+ }
781
+ if (moments.length < 3 && aiHints.length === 0) {
782
+ if (sessionDuration > PERIODIC_INTERVAL_SECONDS) {
783
+ const rawCount = Math.floor(sessionDuration / PERIODIC_INTERVAL_SECONDS);
784
+ const interval = Math.min(
785
+ sessionDuration / rawCount,
786
+ MAX_PERIODIC_INTERVAL_SECONDS
787
+ );
788
+ for (let t = firstSegment.startTime + interval; t < lastSegment.endTime; t += interval) {
789
+ moments.push({
790
+ timestamp: t,
791
+ reason: "Periodic capture",
792
+ confidence: 0.5
793
+ });
794
+ }
795
+ }
796
+ }
797
+ for (const hint of aiHints) {
798
+ if (!Number.isFinite(hint.timestamp)) {
799
+ continue;
800
+ }
801
+ moments.push({
802
+ timestamp: Math.max(0, hint.timestamp),
803
+ reason: hint.reason?.trim() || "AI-highlighted context",
804
+ confidence: Math.max(0, Math.min(1, Number.isFinite(hint.confidence) ? hint.confidence : 0.8))
805
+ });
806
+ }
807
+ const deduped = this.deduplicateMoments(moments);
808
+ deduped.sort((a, b) => a.timestamp - b.timestamp);
809
+ if (deduped.length > MAX_KEY_MOMENTS) {
810
+ const first = deduped[0];
811
+ const last = deduped[deduped.length - 1];
812
+ const middle = deduped.slice(1, -1).sort((a, b) => {
813
+ const priorityDelta = this.momentPriority(b) - this.momentPriority(a);
814
+ if (priorityDelta !== 0) {
815
+ return priorityDelta;
816
+ }
817
+ return b.confidence - a.confidence;
818
+ }).slice(0, MAX_KEY_MOMENTS - 2);
819
+ const capped = [first, ...middle, last];
820
+ capped.sort((a, b) => a.timestamp - b.timestamp);
821
+ return capped;
822
+ }
823
+ return deduped;
824
+ }
825
+ /**
826
+ * Remove moments that are within 1 second of each other,
827
+ * keeping the one with higher confidence.
828
+ */
829
+ deduplicateMoments(moments) {
830
+ if (moments.length <= 1) {
831
+ return moments;
832
+ }
833
+ const sorted = [...moments].sort((a, b) => a.timestamp - b.timestamp);
834
+ const result = [sorted[0]];
835
+ for (let i = 1; i < sorted.length; i++) {
836
+ const prev = result[result.length - 1];
837
+ const curr = sorted[i];
838
+ if (curr.timestamp - prev.timestamp < 1) {
839
+ const currPriority = this.momentPriority(curr);
840
+ const prevPriority = this.momentPriority(prev);
841
+ if (currPriority > prevPriority || currPriority === prevPriority && curr.confidence > prev.confidence) {
842
+ result[result.length - 1] = curr;
843
+ }
844
+ } else {
845
+ result.push(curr);
846
+ }
847
+ }
848
+ return result;
849
+ }
850
+ momentPriority(moment) {
851
+ const reason = (moment.reason || "").toLowerCase();
852
+ if (reason.includes("session start") || reason.includes("session end")) {
853
+ return 4;
854
+ }
855
+ if (reason.includes("ai-") || reason.includes("ai ")) {
856
+ return 3;
857
+ }
858
+ if (reason.includes("natural pause")) {
859
+ return 2;
860
+ }
861
+ if (reason.includes("periodic")) {
862
+ return 0;
863
+ }
864
+ return 1;
865
+ }
866
+ };
867
+ var transcriptAnalyzer = new TranscriptAnalyzer();
868
+
869
+ // src/main/pipeline/FrameExtractor.ts
870
+ import { execFile as execFileCb } from "child_process";
871
+ import { promisify } from "util";
872
+ import { existsSync, mkdirSync } from "fs";
873
+ import { stat as statFile } from "fs/promises";
874
+ import { join } from "path";
875
+ var execFile = promisify(execFileCb);
876
+ var DEFAULT_MAX_FRAMES = 20;
877
+ var FFMPEG_ACCURATE_FRAME_TIMEOUT_MS = 2e4;
878
+ var FFMPEG_FAST_FRAME_TIMEOUT_MS = 1e4;
879
+ var FFMPEG_CHECK_TIMEOUT_MS = 5e3;
880
+ var FRAME_EDGE_MARGIN_SECONDS2 = 0.35;
881
+ var TIMESTAMP_DEDUPE_WINDOW_SECONDS = 0.15;
882
+ var SAFE_CHILD_ENV = {
883
+ PATH: process.env.PATH,
884
+ HOME: process.env.HOME || process.env.USERPROFILE,
885
+ USERPROFILE: process.env.USERPROFILE,
886
+ LANG: process.env.LANG,
887
+ TMPDIR: process.env.TMPDIR || process.env.TEMP,
888
+ TEMP: process.env.TEMP
889
+ };
890
+ var FrameExtractor = class {
891
+ ffmpegPath = "ffmpeg";
892
+ ffprobePath = "ffprobe";
893
+ ffmpegChecked = false;
894
+ ffmpegAvailable = false;
895
+ /**
896
+ * Check if ffmpeg is installed and accessible on the system PATH.
897
+ * Result is cached after the first successful check.
898
+ */
899
+ async checkFfmpeg() {
900
+ if (this.ffmpegChecked) {
901
+ return this.ffmpegAvailable;
902
+ }
903
+ try {
904
+ await execFile(this.ffmpegPath, ["-version"], {
905
+ timeout: FFMPEG_CHECK_TIMEOUT_MS,
906
+ env: SAFE_CHILD_ENV
907
+ });
908
+ this.ffmpegAvailable = true;
909
+ this.log("ffmpeg is available");
910
+ } catch {
911
+ this.ffmpegAvailable = false;
912
+ this.log("ffmpeg is not available - frame extraction will be skipped");
913
+ }
914
+ this.ffmpegChecked = true;
915
+ return this.ffmpegAvailable;
916
+ }
917
+ /**
918
+ * Extract frames from a video file at the specified timestamps.
919
+ *
920
+ * @param request - Extraction parameters (video path, timestamps, output dir)
921
+ * @returns Result with extracted frame paths and ffmpeg availability status
922
+ */
923
+ async extract(request) {
924
+ const available = await this.checkFfmpeg();
925
+ if (!available) {
926
+ return { frames: [], ffmpegAvailable: false };
927
+ }
928
+ const maxFrames = request.maxFrames ?? DEFAULT_MAX_FRAMES;
929
+ let timestamps = [...request.timestamps].sort((a, b) => a - b);
930
+ if (timestamps.length > maxFrames) {
931
+ timestamps = this.selectDistributed(timestamps, maxFrames);
932
+ }
933
+ const videoDurationSeconds = await this.getVideoDurationSeconds(request.videoPath);
934
+ timestamps = this.normalizeTimestamps(timestamps, videoDurationSeconds);
935
+ if (timestamps.length === 0) {
936
+ return { frames: [], ffmpegAvailable: true };
937
+ }
938
+ const screenshotsDir = join(request.outputDir, "screenshots");
939
+ if (!existsSync(screenshotsDir)) {
940
+ mkdirSync(screenshotsDir, { recursive: true });
941
+ }
942
+ const frames = [];
943
+ for (let i = 0; i < timestamps.length; i++) {
944
+ const timestamp = timestamps[i];
945
+ const frameNumber = String(i + 1).padStart(3, "0");
946
+ const outputPath = join(screenshotsDir, `frame-${frameNumber}.png`);
947
+ try {
948
+ await this.extractSingleFrame(request.videoPath, timestamp, outputPath);
949
+ const stats = await statFile(outputPath).catch(() => null);
950
+ if (!stats || stats.size <= 0) {
951
+ throw new Error(`ffmpeg did not produce a frame file at timestamp ${timestamp.toFixed(1)}s. The video may be shorter than expected or the codec may not support seeking.`);
952
+ }
953
+ frames.push({
954
+ path: outputPath,
955
+ timestamp,
956
+ success: true
957
+ });
958
+ this.log(`Extracted frame ${frameNumber} at ${timestamp.toFixed(2)}s`);
959
+ } catch (error) {
960
+ const message = error instanceof Error ? error.message : String(error);
961
+ this.log(`Failed to extract frame at ${timestamp.toFixed(2)}s: ${message}`);
962
+ frames.push({
963
+ path: outputPath,
964
+ timestamp,
965
+ success: false
966
+ });
967
+ }
968
+ }
969
+ return { frames, ffmpegAvailable: true };
970
+ }
971
+ // ============================================================================
972
+ // Private Methods
973
+ // ============================================================================
974
+ /**
975
+ * Extract a single frame from the video at the given timestamp.
976
+ */
977
+ async extractSingleFrame(videoPath, timestamp, outputPath) {
978
+ try {
979
+ await this.extractSingleFrameAccurate(videoPath, timestamp, outputPath);
980
+ return;
981
+ } catch (accurateError) {
982
+ this.log(
983
+ `Accurate extraction failed at ${timestamp.toFixed(2)}s, retrying fast seek: ${accurateError instanceof Error ? accurateError.message : String(accurateError)}`
984
+ );
985
+ }
986
+ await this.extractSingleFrameFast(videoPath, timestamp, outputPath);
987
+ }
988
+ async extractSingleFrameAccurate(videoPath, timestamp, outputPath) {
989
+ const args = [
990
+ "-i",
991
+ videoPath,
992
+ "-ss",
993
+ String(timestamp),
994
+ "-frames:v",
995
+ "1",
996
+ "-vf",
997
+ "format=rgb24",
998
+ "-q:v",
999
+ "2",
1000
+ "-y",
1001
+ outputPath
1002
+ ];
1003
+ await execFile(this.ffmpegPath, args, {
1004
+ timeout: FFMPEG_ACCURATE_FRAME_TIMEOUT_MS,
1005
+ env: SAFE_CHILD_ENV
1006
+ });
1007
+ }
1008
+ async extractSingleFrameFast(videoPath, timestamp, outputPath) {
1009
+ const args = [
1010
+ "-ss",
1011
+ String(timestamp),
1012
+ "-i",
1013
+ videoPath,
1014
+ "-frames:v",
1015
+ "1",
1016
+ "-vf",
1017
+ "format=rgb24",
1018
+ "-q:v",
1019
+ "2",
1020
+ "-y",
1021
+ // overwrite output file if it exists
1022
+ outputPath
1023
+ ];
1024
+ await execFile(this.ffmpegPath, args, {
1025
+ timeout: FFMPEG_FAST_FRAME_TIMEOUT_MS,
1026
+ env: SAFE_CHILD_ENV
1027
+ });
1028
+ }
1029
+ /**
1030
+ * Select evenly distributed timestamps from a sorted array.
1031
+ * Always includes the first and last timestamp.
1032
+ */
1033
+ selectDistributed(sorted, count) {
1034
+ if (sorted.length <= count) {
1035
+ return sorted;
1036
+ }
1037
+ if (count <= 0) {
1038
+ return [];
1039
+ }
1040
+ if (count === 1) {
1041
+ return [sorted[0]];
1042
+ }
1043
+ const result = [sorted[0]];
1044
+ const step2 = (sorted.length - 1) / (count - 1);
1045
+ for (let i = 1; i < count - 1; i++) {
1046
+ const index = Math.round(i * step2);
1047
+ result.push(sorted[index]);
1048
+ }
1049
+ result.push(sorted[sorted.length - 1]);
1050
+ return result;
1051
+ }
1052
+ async getVideoDurationSeconds(videoPath) {
1053
+ try {
1054
+ const { stdout } = await execFile(
1055
+ this.ffprobePath,
1056
+ [
1057
+ "-v",
1058
+ "error",
1059
+ "-show_entries",
1060
+ "format=duration",
1061
+ "-of",
1062
+ "default=noprint_wrappers=1:nokey=1",
1063
+ videoPath
1064
+ ],
1065
+ { timeout: FFMPEG_CHECK_TIMEOUT_MS, env: SAFE_CHILD_ENV }
1066
+ );
1067
+ const parsed = Number.parseFloat(String(stdout).trim());
1068
+ if (Number.isFinite(parsed) && parsed > 0) {
1069
+ return parsed;
1070
+ }
1071
+ return null;
1072
+ } catch (error) {
1073
+ this.log(`ffprobe duration probe failed: ${error instanceof Error ? error.message : String(error)}`);
1074
+ return null;
1075
+ }
1076
+ }
1077
+ normalizeTimestamps(timestamps, durationSeconds) {
1078
+ const cleaned = timestamps.map((timestamp) => Number.isFinite(timestamp) ? Math.max(0, timestamp) : 0).sort((a, b) => a - b);
1079
+ if (cleaned.length === 0) {
1080
+ return [];
1081
+ }
1082
+ let clamped = cleaned;
1083
+ if (durationSeconds && durationSeconds > 0) {
1084
+ const minTs = Math.min(FRAME_EDGE_MARGIN_SECONDS2, Math.max(0, durationSeconds - 0.05));
1085
+ const maxTs = Math.max(minTs, durationSeconds - FRAME_EDGE_MARGIN_SECONDS2);
1086
+ clamped = cleaned.map((timestamp) => Math.max(minTs, Math.min(timestamp, maxTs)));
1087
+ }
1088
+ const deduped = [];
1089
+ for (const timestamp of clamped) {
1090
+ const previous = deduped[deduped.length - 1];
1091
+ if (previous === void 0 || Math.abs(timestamp - previous) >= TIMESTAMP_DEDUPE_WINDOW_SECONDS) {
1092
+ deduped.push(timestamp);
1093
+ }
1094
+ }
1095
+ return deduped;
1096
+ }
1097
+ /**
1098
+ * Log helper with consistent prefix.
1099
+ */
1100
+ log(message) {
1101
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1102
+ console.log(`[FrameExtractor ${timestamp}] ${message}`);
1103
+ }
1104
+ };
1105
+ var frameExtractor = new FrameExtractor();
1106
+
1107
+ // src/main/output/MarkdownGenerator.ts
1108
+ import * as path from "path";
1109
+ var REPORT_SUPPORT_LINE = "*If this report saved you time, support development: [Ko-fi](https://ko-fi.com/eddiesanjuan)*";
1110
+ var MarkdownGeneratorImpl = class {
1111
+ /**
1112
+ * Generate a full markdown document with all feedback items and metadata.
1113
+ * Follows llms.txt-inspired format for AI readability.
1114
+ */
1115
+ generateFullDocument(session, options) {
1116
+ const { projectName, screenshotDir } = options;
1117
+ const items = session.feedbackItems;
1118
+ const duration = session.endTime ? this.formatDuration(session.endTime - session.startTime) : "In Progress";
1119
+ const timestamp = this.formatTimestamp(session.endTime || Date.now());
1120
+ const filename = this.generateFilename(projectName, session.startTime);
1121
+ if (items.length === 0) {
1122
+ const content2 = `# ${projectName} Feedback Report
1123
+ > Generated by markupr on ${timestamp}
1124
+ > Duration: ${duration}
1125
+
1126
+ _No feedback items were captured during this session._
1127
+
1128
+ ---
1129
+ *Generated by [markupr](https://markupr.com)*
1130
+ ${REPORT_SUPPORT_LINE}
1131
+ `;
1132
+ return {
1133
+ content: content2,
1134
+ filename,
1135
+ metadata: {
1136
+ itemCount: 0,
1137
+ screenshotCount: 0,
1138
+ duration: session.endTime ? session.endTime - session.startTime : 0,
1139
+ types: {}
1140
+ }
1141
+ };
1142
+ }
1143
+ const typeCounts = this.countTypes(items);
1144
+ const severityCounts = this.countSeverities(items);
1145
+ const screenshotCount = this.countScreenshots(items);
1146
+ const topThemes = this.extractTopThemes(items);
1147
+ const highImpactCount = (severityCounts.Critical || 0) + (severityCounts.High || 0);
1148
+ const platform = session.metadata?.os || process?.platform || "Unknown";
1149
+ let content = `# ${projectName} Feedback Report
1150
+ > Generated by markupr on ${timestamp}
1151
+ > Duration: ${duration} | Items: ${items.length} | Screenshots: ${screenshotCount}
1152
+
1153
+ ## Session Overview
1154
+ - **Session ID:** \`${session.id}\`
1155
+ - **Source:** ${session.metadata?.sourceName || "Unknown"} (${session.metadata?.sourceType || "screen"})
1156
+ - **Platform:** ${platform}
1157
+ - **Segments:** ${items.length}
1158
+ - **High-impact items:** ${highImpactCount}
1159
+
1160
+ ---
1161
+
1162
+ ## Executive Summary
1163
+
1164
+ - ${items.length} total feedback items were captured.
1165
+ - ${highImpactCount} items are categorized as **Critical** or **High** priority.
1166
+ - ${screenshotCount} screenshots were aligned to spoken context.
1167
+ `;
1168
+ if (topThemes.length > 0) {
1169
+ content += `- Top themes: ${topThemes.join(", ")}.
1170
+ `;
1171
+ }
1172
+ content += `
1173
+ ---
1174
+
1175
+ ## Actionable Feedback
1176
+
1177
+ `;
1178
+ items.forEach((item, index) => {
1179
+ const id = this.generateFeedbackItemId(index);
1180
+ const title = item.title || this.generateTitle(item.transcription);
1181
+ const itemTimestamp = this.formatItemTimestamp(item.timestamp - session.startTime);
1182
+ const category = item.category || "General";
1183
+ const severity = item.severity || this.defaultSeverityForCategory(category);
1184
+ const signals = item.keywords?.slice(0, 5) || [];
1185
+ const suggestedAction = this.suggestAction(category, severity, item.transcription);
1186
+ content += `### ${id}: ${title}
1187
+ - **Severity:** ${severity}
1188
+ - **Type:** ${category}
1189
+ - **Timestamp:** ${itemTimestamp}
1190
+ `;
1191
+ if (signals.length > 0) {
1192
+ content += `- **Signals:** ${signals.join(", ")}
1193
+ `;
1194
+ }
1195
+ content += `
1196
+ #### What Happened
1197
+
1198
+ > ${this.wrapTranscription(item.transcription)}
1199
+
1200
+ `;
1201
+ if (item.screenshots.length > 0) {
1202
+ content += `#### Evidence
1203
+ `;
1204
+ item.screenshots.forEach((ss, ssIndex) => {
1205
+ const screenshotFilename = this.generateScreenshotFilename(index, ssIndex, item.screenshots.length);
1206
+ content += `![${id}${item.screenshots.length > 1 ? `-${ssIndex + 1}` : ""}](${screenshotDir}/${screenshotFilename})
1207
+
1208
+ `;
1209
+ });
1210
+ } else {
1211
+ content += `#### Evidence
1212
+ _No screenshot captured for this item._
1213
+
1214
+ `;
1215
+ }
1216
+ content += `#### Suggested Next Step
1217
+ - ${suggestedAction}
1218
+
1219
+ `;
1220
+ content += `---
1221
+
1222
+ `;
1223
+ });
1224
+ content += `## Summary
1225
+
1226
+ | Type | Count |
1227
+ |------|-------|
1228
+ `;
1229
+ Object.entries(typeCounts).forEach(([type, count]) => {
1230
+ content += `| ${type} | ${count} |
1231
+ `;
1232
+ });
1233
+ content += `| **Total** | **${items.length}** |
1234
+ `;
1235
+ content += `
1236
+ | Severity | Count |
1237
+ |----------|-------|
1238
+ `;
1239
+ Object.entries(severityCounts).forEach(([severity, count]) => {
1240
+ content += `| ${severity} | ${count} |
1241
+ `;
1242
+ });
1243
+ content += `| **Total** | **${items.length}** |
1244
+ `;
1245
+ content += `
1246
+ ---
1247
+ *Generated by [markupr](https://markupr.com)*
1248
+ ${REPORT_SUPPORT_LINE}
1249
+ `;
1250
+ return {
1251
+ content,
1252
+ filename,
1253
+ metadata: {
1254
+ itemCount: items.length,
1255
+ screenshotCount,
1256
+ duration: session.endTime ? session.endTime - session.startTime : 0,
1257
+ types: typeCounts
1258
+ }
1259
+ };
1260
+ }
1261
+ /**
1262
+ * Generate markdown from a PostProcessResult (post-recording pipeline output).
1263
+ *
1264
+ * Produces a clean, AI-readable document with:
1265
+ * - Session header with human-readable timestamp
1266
+ * - Each transcript segment as a heading with [M:SS] timestamp
1267
+ * - Blockquoted transcript text
1268
+ * - Associated frame images referenced as relative paths
1269
+ *
1270
+ * @param result - The combined transcript + frame output from PostProcessor
1271
+ * @param sessionDir - Absolute path to the session directory (used to compute relative frame paths)
1272
+ * @returns The generated markdown string
1273
+ */
1274
+ generateFromPostProcess(result, sessionDir) {
1275
+ const { transcriptSegments, extractedFrames } = result;
1276
+ const sessionTimestamp = this.formatDateDeterministic(/* @__PURE__ */ new Date());
1277
+ const sessionDuration = transcriptSegments.length > 0 ? this.formatDuration(
1278
+ (transcriptSegments[transcriptSegments.length - 1].endTime - transcriptSegments[0].startTime) * 1e3
1279
+ ) : "0:00";
1280
+ let md = `# markupr Session \u2014 ${sessionTimestamp}
1281
+ `;
1282
+ md += `> Segments: ${transcriptSegments.length} | Frames: ${extractedFrames.length} | Duration: ${sessionDuration}
1283
+
1284
+ `;
1285
+ if (transcriptSegments.length === 0) {
1286
+ md += `_No speech was detected during this recording._
1287
+ `;
1288
+ return md;
1289
+ }
1290
+ md += `## Transcript
1291
+
1292
+ `;
1293
+ const segmentFrameMap = this.mapFramesToSegments(transcriptSegments, extractedFrames);
1294
+ for (let i = 0; i < transcriptSegments.length; i++) {
1295
+ const segment = transcriptSegments[i];
1296
+ const formattedTime = this.formatPostProcessTimestamp(segment.startTime);
1297
+ const title = this.generateSegmentTitle(segment.text);
1298
+ md += `### [${formattedTime}] ${title}
1299
+ `;
1300
+ md += `> ${this.wrapTranscription(segment.text)}
1301
+
1302
+ `;
1303
+ const frames = segmentFrameMap.get(i);
1304
+ if (frames && frames.length > 0) {
1305
+ for (const frame of frames) {
1306
+ const frameTimestamp = this.formatPostProcessTimestamp(frame.timestamp);
1307
+ const relativePath = this.computeRelativeFramePath(frame.path, sessionDir);
1308
+ md += `![Frame at ${frameTimestamp}](${relativePath})
1309
+
1310
+ `;
1311
+ }
1312
+ }
1313
+ }
1314
+ md += `---
1315
+ *Generated by [markupr](https://markupr.com)*
1316
+ ${REPORT_SUPPORT_LINE}
1317
+ `;
1318
+ return md;
1319
+ }
1320
+ /**
1321
+ * Map extracted frames to their closest transcript segments.
1322
+ * Returns a Map from segment index to an array of frames.
1323
+ */
1324
+ mapFramesToSegments(segments, frames) {
1325
+ const map = /* @__PURE__ */ new Map();
1326
+ for (const frame of frames) {
1327
+ let bestIndex = 0;
1328
+ let bestDistance = Infinity;
1329
+ for (let i = 0; i < segments.length; i++) {
1330
+ const seg = segments[i];
1331
+ if (frame.timestamp >= seg.startTime && frame.timestamp <= seg.endTime) {
1332
+ bestIndex = i;
1333
+ bestDistance = 0;
1334
+ break;
1335
+ }
1336
+ const distance = Math.abs(frame.timestamp - seg.startTime);
1337
+ if (distance < bestDistance) {
1338
+ bestDistance = distance;
1339
+ bestIndex = i;
1340
+ }
1341
+ }
1342
+ const existing = map.get(bestIndex) || [];
1343
+ existing.push(frame);
1344
+ map.set(bestIndex, existing);
1345
+ }
1346
+ for (const [, frameList] of map) {
1347
+ frameList.sort((a, b) => a.timestamp - b.timestamp);
1348
+ }
1349
+ return map;
1350
+ }
1351
+ /**
1352
+ * Compute a relative path for a frame image from the session directory.
1353
+ * If the frame path is already relative, return it as-is.
1354
+ * If absolute, compute the relative path from sessionDir.
1355
+ */
1356
+ computeRelativeFramePath(framePath, sessionDir) {
1357
+ if (!path.isAbsolute(framePath)) {
1358
+ return framePath;
1359
+ }
1360
+ return path.relative(sessionDir, framePath);
1361
+ }
1362
+ /**
1363
+ * Format a timestamp in seconds to M:SS format for post-process output.
1364
+ * Examples: 0 -> "0:00", 15.3 -> "0:15", 125 -> "2:05"
1365
+ */
1366
+ formatPostProcessTimestamp(seconds) {
1367
+ const totalSeconds = Math.max(0, Math.floor(seconds));
1368
+ const mins = Math.floor(totalSeconds / 60);
1369
+ const secs = totalSeconds % 60;
1370
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
1371
+ }
1372
+ /**
1373
+ * Generate a short title from transcript text (first sentence, max 60 chars).
1374
+ */
1375
+ generateSegmentTitle(text) {
1376
+ const firstSentence = text.split(/[.!?]/)[0].trim();
1377
+ if (firstSentence.length <= 60) return firstSentence;
1378
+ return firstSentence.slice(0, 57) + "...";
1379
+ }
1380
+ /**
1381
+ * Generate a clipboard-friendly summary (<1500 chars).
1382
+ * Includes priority items and a reference to the full report.
1383
+ *
1384
+ * @param session - Session data
1385
+ * @param projectName - Optional project name for the header
1386
+ * @param reportPath - Optional absolute or relative path to the full report file.
1387
+ * When provided, the summary links to this path instead of the
1388
+ * generic ./feedback-report.md placeholder.
1389
+ */
1390
+ generateClipboardSummary(session, projectName, reportPath) {
1391
+ const name = projectName || session.metadata?.sourceName || "Project";
1392
+ const items = session.feedbackItems;
1393
+ let summary = `# Feedback: ${name} - ${items.length} items
1394
+
1395
+ `;
1396
+ const maxPriorityItems = 3;
1397
+ summary += `## Priority Items
1398
+ `;
1399
+ items.slice(0, maxPriorityItems).forEach((item, index) => {
1400
+ const id = this.generateFeedbackItemId(index);
1401
+ const title = this.generateTitle(item.transcription);
1402
+ const oneLineSummary = this.truncateText(item.transcription, 60);
1403
+ summary += `- **${id}:** ${title} - ${oneLineSummary}
1404
+ `;
1405
+ });
1406
+ if (items.length > maxPriorityItems) {
1407
+ const remainingIds = items.slice(maxPriorityItems).map((_, i) => this.generateFeedbackItemId(i + maxPriorityItems)).join(", ");
1408
+ summary += `
1409
+ ## Other
1410
+ - ${remainingIds} (see full report)
1411
+ `;
1412
+ }
1413
+ summary += `
1414
+ **Full report:** ${reportPath || "./feedback-report.md"}`;
1415
+ if (summary.length > 1500) {
1416
+ summary = summary.slice(0, 1497) + "...";
1417
+ }
1418
+ return summary;
1419
+ }
1420
+ /**
1421
+ * Generate a feedback item ID (FB-001, FB-002, etc.)
1422
+ */
1423
+ generateFeedbackItemId(index) {
1424
+ return `FB-${(index + 1).toString().padStart(3, "0")}`;
1425
+ }
1426
+ // ==========================================================================
1427
+ // Private Helper Methods
1428
+ // ==========================================================================
1429
+ /**
1430
+ * Generate a title from the transcription (first sentence or 50 chars)
1431
+ */
1432
+ generateTitle(transcription) {
1433
+ const firstSentence = transcription.split(/[.!?]/)[0].trim();
1434
+ if (firstSentence.length <= 50) return firstSentence;
1435
+ return firstSentence.slice(0, 47) + "...";
1436
+ }
1437
+ /**
1438
+ * Truncate text to specified length
1439
+ */
1440
+ truncateText(text, maxLength) {
1441
+ if (text.length <= maxLength) return text;
1442
+ return text.slice(0, maxLength - 3) + "...";
1443
+ }
1444
+ /**
1445
+ * Wrap transcription for markdown blockquote (handle multi-line).
1446
+ * Splits on sentence-ending punctuation followed by whitespace so that
1447
+ * all multi-sentence inputs (including 2-sentence ones) get proper
1448
+ * blockquote continuation lines.
1449
+ */
1450
+ wrapTranscription(transcription) {
1451
+ if (!transcription.includes(".") && !transcription.includes("!") && !transcription.includes("?")) {
1452
+ return transcription;
1453
+ }
1454
+ const sentences = transcription.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
1455
+ if (sentences.length <= 1) return transcription;
1456
+ return sentences.join("\n> ");
1457
+ }
1458
+ /**
1459
+ * Format duration from milliseconds to M:SS
1460
+ */
1461
+ formatDuration(ms) {
1462
+ const totalSeconds = Math.floor(ms / 1e3);
1463
+ const mins = Math.floor(totalSeconds / 60);
1464
+ const secs = totalSeconds % 60;
1465
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
1466
+ }
1467
+ /**
1468
+ * Format timestamp to a deterministic human-readable string.
1469
+ * Uses explicit formatting instead of toLocaleString to produce
1470
+ * consistent output across platforms and Node.js versions.
1471
+ */
1472
+ formatTimestamp(ms) {
1473
+ return this.formatDateDeterministic(new Date(ms));
1474
+ }
1475
+ /**
1476
+ * Produce a deterministic date string: "Feb 14, 2026 at 10:30 AM".
1477
+ * Avoids toLocaleString which can vary across OS versions.
1478
+ */
1479
+ formatDateDeterministic(date) {
1480
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
1481
+ const month = months[date.getMonth()];
1482
+ const day = date.getDate();
1483
+ const year = date.getFullYear();
1484
+ const rawHours = date.getHours();
1485
+ const ampm = rawHours >= 12 ? "PM" : "AM";
1486
+ const hours = rawHours % 12 || 12;
1487
+ const minutes = date.getMinutes().toString().padStart(2, "0");
1488
+ return `${month} ${day}, ${year} at ${hours}:${minutes} ${ampm}`;
1489
+ }
1490
+ /**
1491
+ * Format item timestamp as MM:SS from session start
1492
+ */
1493
+ formatItemTimestamp(ms) {
1494
+ const totalSeconds = Math.max(0, Math.floor(ms / 1e3));
1495
+ const mins = Math.floor(totalSeconds / 60);
1496
+ const secs = totalSeconds % 60;
1497
+ return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
1498
+ }
1499
+ /**
1500
+ * Generate filename following pattern: {project}-feedback-{YYYYMMDD-HHmmss}.md
1501
+ */
1502
+ generateFilename(projectName, startTime) {
1503
+ const date = new Date(startTime);
1504
+ const year = date.getFullYear();
1505
+ const month = (date.getMonth() + 1).toString().padStart(2, "0");
1506
+ const day = date.getDate().toString().padStart(2, "0");
1507
+ const hours = date.getHours().toString().padStart(2, "0");
1508
+ const minutes = date.getMinutes().toString().padStart(2, "0");
1509
+ const seconds = date.getSeconds().toString().padStart(2, "0");
1510
+ const dateStr = `${year}${month}${day}`;
1511
+ const timeStr = `${hours}${minutes}${seconds}`;
1512
+ const safeName = projectName.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-");
1513
+ return `${safeName}-feedback-${dateStr}-${timeStr}.md`;
1514
+ }
1515
+ /**
1516
+ * Generate screenshot filename for a feedback item.
1517
+ * Uses the item's position index to produce `fb-{NNN}.png`, matching the
1518
+ * naming convention in FileManager.saveSession.
1519
+ */
1520
+ generateScreenshotFilename(itemIndex, screenshotIndex, total) {
1521
+ const num = (itemIndex + 1).toString().padStart(3, "0");
1522
+ const suffix = total > 1 ? `-${screenshotIndex + 1}` : "";
1523
+ return `fb-${num}${suffix}.png`;
1524
+ }
1525
+ /**
1526
+ * Provide a severity fallback when upstream analysis is unavailable.
1527
+ */
1528
+ defaultSeverityForCategory(category) {
1529
+ switch (category) {
1530
+ case "Bug":
1531
+ return "High";
1532
+ case "Performance":
1533
+ return "High";
1534
+ case "UX Issue":
1535
+ return "Medium";
1536
+ case "Suggestion":
1537
+ return "Low";
1538
+ case "Question":
1539
+ return "Low";
1540
+ default:
1541
+ return "Medium";
1542
+ }
1543
+ }
1544
+ countSeverities(items) {
1545
+ return items.reduce((acc, item) => {
1546
+ const severity = item.severity || this.defaultSeverityForCategory(item.category || "General");
1547
+ acc[severity] = (acc[severity] || 0) + 1;
1548
+ return acc;
1549
+ }, {});
1550
+ }
1551
+ extractTopThemes(items) {
1552
+ const counts = /* @__PURE__ */ new Map();
1553
+ items.forEach((item) => {
1554
+ (item.keywords || []).forEach((keyword) => {
1555
+ const normalized = keyword.toLowerCase();
1556
+ counts.set(normalized, (counts.get(normalized) || 0) + 1);
1557
+ });
1558
+ });
1559
+ return Array.from(counts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([keyword]) => keyword);
1560
+ }
1561
+ suggestAction(category, severity, transcription) {
1562
+ const excerpt = this.truncateText(transcription, 120);
1563
+ switch (category) {
1564
+ case "Bug":
1565
+ return `Reproduce and patch this defect, then add a regression test that validates: "${excerpt}".`;
1566
+ case "Performance":
1567
+ return `Profile this flow, target the slow step first, and validate before/after metrics for: "${excerpt}".`;
1568
+ case "UX Issue":
1569
+ return `Revise the UI interaction and run a quick usability check focused on: "${excerpt}".`;
1570
+ case "Suggestion":
1571
+ return severity === "High" || severity === "Critical" ? `Treat this suggestion as near-term roadmap work and define implementation scope for: "${excerpt}".` : `Track this as an enhancement request and prioritize against current sprint goals: "${excerpt}".`;
1572
+ case "Question":
1573
+ return `Answer this explicitly in product/docs so future reviews don't block on: "${excerpt}".`;
1574
+ default:
1575
+ return `Investigate this item and convert it into a concrete engineering task: "${excerpt}".`;
1576
+ }
1577
+ }
1578
+ /**
1579
+ * Count feedback items by type/category
1580
+ */
1581
+ countTypes(items) {
1582
+ return items.reduce((acc, item) => {
1583
+ const type = item.category || "General";
1584
+ acc[type] = (acc[type] || 0) + 1;
1585
+ return acc;
1586
+ }, {});
1587
+ }
1588
+ /**
1589
+ * Count total screenshots across all items
1590
+ */
1591
+ countScreenshots(items) {
1592
+ return items.reduce((sum, item) => sum + item.screenshots.length, 0);
1593
+ }
1594
+ };
1595
+ var markdownGenerator = new MarkdownGeneratorImpl();
1596
+
1597
+ // src/main/transcription/WhisperService.ts
1598
+ import { EventEmitter } from "events";
1599
+ import { basename, join as join2 } from "path";
1600
+ import { existsSync as existsSync2 } from "fs";
1601
+ import { readFile, unlink, chmod } from "fs/promises";
1602
+ import { execFile as execFile2 } from "child_process";
1603
+ import { promisify as promisify2 } from "util";
1604
+ import { tmpdir } from "os";
1605
+ import { randomUUID } from "crypto";
1606
+ import * as os from "os";
1607
+ var execFileAsync = promisify2(execFile2);
1608
+ var DEFAULT_CONFIG = {
1609
+ modelPath: "",
1610
+ // Set dynamically
1611
+ language: "en",
1612
+ threads: Math.max(1, Math.floor(os.cpus().length / 2)),
1613
+ // Half CPU cores
1614
+ translateToEnglish: false
1615
+ };
1616
+ var CHUNK_DURATION_MS = 3e3;
1617
+ var MAX_BUFFER_DURATION_MS = 3e4;
1618
+ var MAX_BUFFER_SIZE_BYTES = 500 * 1024;
1619
+ var SAMPLE_RATE = 16e3;
1620
+ var FILE_CHUNK_DURATION_SEC = 30;
1621
+ var FILE_CHUNK_SAMPLES = FILE_CHUNK_DURATION_SEC * SAMPLE_RATE;
1622
+ var MODEL_MEMORY_REQUIREMENTS_BYTES = {
1623
+ "ggml-tiny.bin": 450 * 1024 * 1024,
1624
+ "ggml-base.bin": 800 * 1024 * 1024,
1625
+ "ggml-small.bin": 1400 * 1024 * 1024,
1626
+ "ggml-medium.bin": 2800 * 1024 * 1024,
1627
+ "ggml-large-v3.bin": 5200 * 1024 * 1024
1628
+ };
1629
+ var WhisperService = class extends EventEmitter {
1630
+ config;
1631
+ isInitialized = false;
1632
+ isProcessing = false;
1633
+ whisperModule = null;
1634
+ // Audio buffering for batch processing
1635
+ audioBuffer = [];
1636
+ bufferStartTime = 0;
1637
+ totalBufferDuration = 0;
1638
+ totalBufferBytes = 0;
1639
+ // Processing state
1640
+ processingInterval = null;
1641
+ // Callbacks
1642
+ transcriptCallbacks = [];
1643
+ errorCallbacks = [];
1644
+ constructor(config) {
1645
+ super();
1646
+ this.config = { ...DEFAULT_CONFIG, ...config };
1647
+ if (!this.config.modelPath) {
1648
+ this.config.modelPath = this.getDefaultModelPath();
1649
+ }
1650
+ }
1651
+ // ============================================================================
1652
+ // Public API
1653
+ // ============================================================================
1654
+ /**
1655
+ * Check if Whisper model is available
1656
+ */
1657
+ isModelAvailable() {
1658
+ return existsSync2(this.config.modelPath);
1659
+ }
1660
+ /**
1661
+ * Get the path where models should be stored
1662
+ */
1663
+ getModelsDirectory() {
1664
+ try {
1665
+ const { app } = __require("electron");
1666
+ return join2(app.getPath("userData"), "whisper-models");
1667
+ } catch {
1668
+ const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir();
1669
+ return join2(homeDir, ".markupr", "whisper-models");
1670
+ }
1671
+ }
1672
+ /**
1673
+ * Get the default model path (whisper-medium)
1674
+ */
1675
+ getDefaultModelPath() {
1676
+ return join2(this.getModelsDirectory(), "ggml-medium.bin");
1677
+ }
1678
+ /**
1679
+ * Set the model path
1680
+ */
1681
+ setModelPath(modelPath) {
1682
+ this.config.modelPath = modelPath;
1683
+ this.isInitialized = false;
1684
+ }
1685
+ /**
1686
+ * Check if system has enough memory for Whisper
1687
+ * Requirement is model-aware (tiny/base/small/medium/large).
1688
+ */
1689
+ hasEnoughMemory() {
1690
+ const freeMemory = os.freemem();
1691
+ const requiredMemory = this.getRequiredMemoryBytes();
1692
+ return freeMemory >= requiredMemory;
1693
+ }
1694
+ /**
1695
+ * Get current memory info
1696
+ */
1697
+ getMemoryInfo() {
1698
+ const freeMemory = os.freemem();
1699
+ const requiredMemory = this.getRequiredMemoryBytes();
1700
+ return {
1701
+ freeMemoryMB: Math.round(freeMemory / 1024 / 1024),
1702
+ requiredMemoryMB: Math.round(requiredMemory / 1024 / 1024),
1703
+ sufficient: freeMemory >= requiredMemory
1704
+ };
1705
+ }
1706
+ /**
1707
+ * Initialize the Whisper model
1708
+ * Call this once before starting transcription
1709
+ */
1710
+ async initialize() {
1711
+ if (this.isInitialized) {
1712
+ return;
1713
+ }
1714
+ if (!this.isModelAvailable()) {
1715
+ throw new Error(`Whisper model not found at ${this.config.modelPath}. Please download the model first.`);
1716
+ }
1717
+ if (!this.hasEnoughMemory()) {
1718
+ const memInfo = this.getMemoryInfo();
1719
+ throw new Error(
1720
+ `Insufficient memory for Whisper. Need ~${memInfo.requiredMemoryMB}MB free, only ${memInfo.freeMemoryMB}MB available.`
1721
+ );
1722
+ }
1723
+ this.log("Initializing Whisper model...");
1724
+ try {
1725
+ this.whisperModule = await import("whisper-node");
1726
+ if (!this.whisperModule || typeof this.whisperModule.whisper !== "function") {
1727
+ throw new Error("whisper-node module loaded but whisper function not found");
1728
+ }
1729
+ this.log("Pre-loading model with test transcription...");
1730
+ const testBuffer = new Float32Array(1600);
1731
+ await this.whisperModule.whisper(testBuffer, {
1732
+ modelPath: this.config.modelPath,
1733
+ language: this.config.language,
1734
+ threads: this.config.threads
1735
+ });
1736
+ this.isInitialized = true;
1737
+ this.log("Whisper model initialized successfully");
1738
+ } catch (error) {
1739
+ const initError = new Error(`Failed to initialize Whisper: ${error.message}`);
1740
+ this.errorCallbacks.forEach((cb) => cb(initError));
1741
+ throw initError;
1742
+ }
1743
+ }
1744
+ /**
1745
+ * Check if service is initialized and ready
1746
+ */
1747
+ isReady() {
1748
+ return this.isInitialized && this.whisperModule !== null;
1749
+ }
1750
+ /**
1751
+ * Start accepting audio for transcription
1752
+ */
1753
+ async start() {
1754
+ if (!this.isInitialized) {
1755
+ await this.initialize();
1756
+ }
1757
+ this.audioBuffer = [];
1758
+ this.bufferStartTime = Date.now();
1759
+ this.totalBufferDuration = 0;
1760
+ this.totalBufferBytes = 0;
1761
+ this.processingInterval = setInterval(() => {
1762
+ this.processBufferedAudio();
1763
+ }, CHUNK_DURATION_MS);
1764
+ this.log("Whisper transcription started");
1765
+ }
1766
+ /**
1767
+ * Stop transcription and process remaining audio
1768
+ */
1769
+ async stop() {
1770
+ if (this.processingInterval) {
1771
+ clearInterval(this.processingInterval);
1772
+ this.processingInterval = null;
1773
+ }
1774
+ await this.processBufferedAudio(true);
1775
+ this.audioBuffer = [];
1776
+ this.totalBufferDuration = 0;
1777
+ this.totalBufferBytes = 0;
1778
+ this.log("Whisper transcription stopped");
1779
+ }
1780
+ /**
1781
+ * Add audio data to the buffer
1782
+ * @param samples Float32Array of audio samples at 16kHz mono
1783
+ * @param durationMs Duration of this chunk in milliseconds
1784
+ */
1785
+ addAudio(samples, durationMs) {
1786
+ const chunkBytes = samples.byteLength;
1787
+ if (this.totalBufferBytes + chunkBytes > MAX_BUFFER_SIZE_BYTES) {
1788
+ this.log("Audio buffer full, force-processing before adding new audio");
1789
+ this.processBufferedAudio(true);
1790
+ }
1791
+ this.audioBuffer.push(samples);
1792
+ this.totalBufferDuration += durationMs;
1793
+ this.totalBufferBytes += chunkBytes;
1794
+ if (this.totalBufferDuration >= MAX_BUFFER_DURATION_MS) {
1795
+ this.processBufferedAudio(true);
1796
+ }
1797
+ }
1798
+ /**
1799
+ * Register callback for transcript results
1800
+ */
1801
+ onTranscript(callback) {
1802
+ this.transcriptCallbacks.push(callback);
1803
+ return () => {
1804
+ this.transcriptCallbacks = this.transcriptCallbacks.filter((cb) => cb !== callback);
1805
+ };
1806
+ }
1807
+ /**
1808
+ * Register callback for errors
1809
+ */
1810
+ onError(callback) {
1811
+ this.errorCallbacks.push(callback);
1812
+ return () => {
1813
+ this.errorCallbacks = this.errorCallbacks.filter((cb) => cb !== callback);
1814
+ };
1815
+ }
1816
+ /**
1817
+ * Get current configuration
1818
+ */
1819
+ getConfig() {
1820
+ return { ...this.config };
1821
+ }
1822
+ /**
1823
+ * Transcribe a complete Float32 buffer in one pass.
1824
+ * Useful for post-session retry workflows when live streaming failed.
1825
+ */
1826
+ async transcribeSamples(samples, startTimeSec) {
1827
+ if (!this.isInitialized) {
1828
+ await this.initialize();
1829
+ }
1830
+ if (!this.whisperModule) {
1831
+ throw new Error("Whisper module not loaded");
1832
+ }
1833
+ const CHUNK_TIMEOUT_MS = 6e4;
1834
+ let timeoutId;
1835
+ const timeoutPromise = new Promise((_, reject) => {
1836
+ timeoutId = setTimeout(
1837
+ () => reject(new Error("Whisper transcription timed out after 60s")),
1838
+ CHUNK_TIMEOUT_MS
1839
+ );
1840
+ });
1841
+ let result;
1842
+ try {
1843
+ result = await Promise.race([
1844
+ this.whisperModule.whisper(samples, {
1845
+ modelPath: this.config.modelPath,
1846
+ language: this.config.language,
1847
+ threads: this.config.threads,
1848
+ translate: this.config.translateToEnglish
1849
+ }),
1850
+ timeoutPromise
1851
+ ]);
1852
+ } finally {
1853
+ if (timeoutId) clearTimeout(timeoutId);
1854
+ }
1855
+ if (!result || result.length === 0) {
1856
+ return [];
1857
+ }
1858
+ return result.map((segment) => ({
1859
+ text: segment.text.trim(),
1860
+ startTime: startTimeSec + segment.start,
1861
+ endTime: startTimeSec + segment.end,
1862
+ confidence: 0.9
1863
+ })).filter((segment) => segment.text.length > 0);
1864
+ }
1865
+ /**
1866
+ * Transcribe an audio file from disk.
1867
+ * Loads the file, converts to Float32Array at 16kHz mono, and transcribes.
1868
+ * For large files, processes in chunks to manage memory.
1869
+ *
1870
+ * @param audioPath - Path to the audio file (webm, wav, ogg, m4a)
1871
+ * @param onProgress - Optional progress callback (0-100)
1872
+ * @returns Array of transcript results with timestamps
1873
+ */
1874
+ async transcribeFile(audioPath, onProgress) {
1875
+ if (!this.isInitialized) {
1876
+ await this.initialize();
1877
+ }
1878
+ if (!existsSync2(audioPath)) {
1879
+ throw new Error(`Audio file not found: ${audioPath}`);
1880
+ }
1881
+ this.log(`Transcribing file: ${audioPath}`);
1882
+ onProgress?.(0);
1883
+ const samples = await this.loadAudioAsSamples(audioPath);
1884
+ if (samples.length === 0) {
1885
+ this.log("Audio file produced no samples");
1886
+ onProgress?.(100);
1887
+ return [];
1888
+ }
1889
+ const totalChunks = Math.ceil(samples.length / FILE_CHUNK_SAMPLES);
1890
+ const results = [];
1891
+ this.log(`Processing ${totalChunks} chunk(s) (${(samples.length / SAMPLE_RATE).toFixed(1)}s total)`);
1892
+ for (let i = 0; i < totalChunks; i++) {
1893
+ const chunkStart = i * FILE_CHUNK_SAMPLES;
1894
+ const chunkEnd = Math.min(chunkStart + FILE_CHUNK_SAMPLES, samples.length);
1895
+ const chunk = samples.subarray(chunkStart, chunkEnd);
1896
+ const startTimeSec = chunkStart / SAMPLE_RATE;
1897
+ const chunkResults = await this.transcribeSamples(chunk, startTimeSec);
1898
+ results.push(...chunkResults);
1899
+ const percent = Math.round((i + 1) / totalChunks * 100);
1900
+ onProgress?.(percent);
1901
+ if (i < totalChunks - 1) {
1902
+ await new Promise((resolve3) => setTimeout(resolve3, 0));
1903
+ }
1904
+ }
1905
+ this.log(`Transcription complete: ${results.length} segment(s)`);
1906
+ return results;
1907
+ }
1908
+ /**
1909
+ * Check if ffmpeg is available on the system
1910
+ */
1911
+ async isFfmpegAvailable() {
1912
+ try {
1913
+ await execFileAsync("ffmpeg", ["-version"]);
1914
+ return true;
1915
+ } catch {
1916
+ return false;
1917
+ }
1918
+ }
1919
+ // ============================================================================
1920
+ // Private Methods
1921
+ // ============================================================================
1922
+ /**
1923
+ * Load an audio file and return Float32Array samples at 16kHz mono.
1924
+ * WAV files are parsed directly; other formats are converted via ffmpeg.
1925
+ */
1926
+ async loadAudioAsSamples(audioPath) {
1927
+ const ext = audioPath.toLowerCase().split(".").pop() ?? "";
1928
+ if (ext === "wav") {
1929
+ return this.parseWavFile(audioPath);
1930
+ }
1931
+ return this.convertWithFfmpeg(audioPath);
1932
+ }
1933
+ /**
1934
+ * Parse a WAV file and extract PCM data as Float32Array at 16kHz mono.
1935
+ * Handles PCM float32 and PCM int16 formats.
1936
+ */
1937
+ async parseWavFile(wavPath) {
1938
+ const buffer = await readFile(wavPath);
1939
+ const riff = buffer.toString("ascii", 0, 4);
1940
+ const wave = buffer.toString("ascii", 8, 12);
1941
+ if (riff !== "RIFF" || wave !== "WAVE") {
1942
+ throw new Error(`Invalid WAV file: missing RIFF/WAVE header in ${wavPath}`);
1943
+ }
1944
+ let offset = 12;
1945
+ let audioFormat = 0;
1946
+ let numChannels = 0;
1947
+ let sampleRate = 0;
1948
+ let bitsPerSample = 0;
1949
+ let fmtFound = false;
1950
+ while (offset < buffer.length - 8) {
1951
+ const chunkId = buffer.toString("ascii", offset, offset + 4);
1952
+ const chunkSize = buffer.readUInt32LE(offset + 4);
1953
+ if (chunkId === "fmt ") {
1954
+ audioFormat = buffer.readUInt16LE(offset + 8);
1955
+ numChannels = buffer.readUInt16LE(offset + 10);
1956
+ sampleRate = buffer.readUInt32LE(offset + 12);
1957
+ bitsPerSample = buffer.readUInt16LE(offset + 22);
1958
+ fmtFound = true;
1959
+ }
1960
+ if (chunkId === "data") {
1961
+ if (!fmtFound) {
1962
+ throw new Error("WAV file has data chunk before fmt chunk");
1963
+ }
1964
+ const dataStart = offset + 8;
1965
+ const dataEnd = dataStart + chunkSize;
1966
+ const dataSlice = buffer.subarray(dataStart, Math.min(dataEnd, buffer.length));
1967
+ return this.extractWavSamples(dataSlice, audioFormat, numChannels, sampleRate, bitsPerSample);
1968
+ }
1969
+ offset += 8 + chunkSize;
1970
+ if (chunkSize % 2 !== 0) {
1971
+ offset += 1;
1972
+ }
1973
+ }
1974
+ throw new Error(`Invalid WAV file: no data chunk found in ${wavPath}`);
1975
+ }
1976
+ /**
1977
+ * Extract samples from WAV data chunk, converting to Float32Array at 16kHz mono.
1978
+ */
1979
+ extractWavSamples(data, audioFormat, numChannels, sampleRate, bitsPerSample) {
1980
+ let monoFloat32;
1981
+ if (audioFormat === 3 && bitsPerSample === 32) {
1982
+ const totalSamples = Math.floor(data.length / 4);
1983
+ const allSamples = new Float32Array(totalSamples);
1984
+ for (let i = 0; i < totalSamples; i++) {
1985
+ allSamples[i] = data.readFloatLE(i * 4);
1986
+ }
1987
+ monoFloat32 = this.mixToMono(allSamples, numChannels);
1988
+ } else if (audioFormat === 1 && bitsPerSample === 16) {
1989
+ const totalSamples = Math.floor(data.length / 2);
1990
+ const allSamples = new Float32Array(totalSamples);
1991
+ for (let i = 0; i < totalSamples; i++) {
1992
+ allSamples[i] = data.readInt16LE(i * 2) / 32768;
1993
+ }
1994
+ monoFloat32 = this.mixToMono(allSamples, numChannels);
1995
+ } else {
1996
+ throw new Error(
1997
+ `Unsupported WAV format: audioFormat=${audioFormat}, bitsPerSample=${bitsPerSample}. Expected PCM float32 (format=3, bits=32) or PCM int16 (format=1, bits=16).`
1998
+ );
1999
+ }
2000
+ if (sampleRate !== SAMPLE_RATE) {
2001
+ return this.resample(monoFloat32, sampleRate, SAMPLE_RATE);
2002
+ }
2003
+ return monoFloat32;
2004
+ }
2005
+ /**
2006
+ * Mix multi-channel audio down to mono by averaging channels.
2007
+ */
2008
+ mixToMono(samples, numChannels) {
2009
+ if (numChannels === 1) {
2010
+ return samples;
2011
+ }
2012
+ const monoLength = Math.floor(samples.length / numChannels);
2013
+ const mono = new Float32Array(monoLength);
2014
+ for (let i = 0; i < monoLength; i++) {
2015
+ let sum = 0;
2016
+ for (let ch = 0; ch < numChannels; ch++) {
2017
+ sum += samples[i * numChannels + ch];
2018
+ }
2019
+ mono[i] = sum / numChannels;
2020
+ }
2021
+ return mono;
2022
+ }
2023
+ /**
2024
+ * Simple linear resampling from one sample rate to another.
2025
+ */
2026
+ resample(samples, fromRate, toRate) {
2027
+ if (fromRate === toRate) {
2028
+ return samples;
2029
+ }
2030
+ const ratio = fromRate / toRate;
2031
+ const outputLength = Math.floor(samples.length / ratio);
2032
+ const output = new Float32Array(outputLength);
2033
+ for (let i = 0; i < outputLength; i++) {
2034
+ const srcIndex = i * ratio;
2035
+ const srcIndexFloor = Math.floor(srcIndex);
2036
+ const srcIndexCeil = Math.min(srcIndexFloor + 1, samples.length - 1);
2037
+ const frac = srcIndex - srcIndexFloor;
2038
+ output[i] = samples[srcIndexFloor] * (1 - frac) + samples[srcIndexCeil] * frac;
2039
+ }
2040
+ return output;
2041
+ }
2042
+ /**
2043
+ * Convert a non-WAV audio file to 16kHz mono Float32 WAV using ffmpeg,
2044
+ * then parse the resulting WAV.
2045
+ */
2046
+ async convertWithFfmpeg(audioPath) {
2047
+ const ffmpegAvailable = await this.isFfmpegAvailable();
2048
+ if (!ffmpegAvailable) {
2049
+ const installHint = process.platform === "darwin" ? "brew install ffmpeg" : process.platform === "win32" ? "winget install ffmpeg or download from https://ffmpeg.org" : "apt install ffmpeg (Debian/Ubuntu) or dnf install ffmpeg (Fedora)";
2050
+ throw new Error(
2051
+ `ffmpeg is not available on this system. ffmpeg is required to transcribe non-WAV audio files (webm, ogg, m4a). Install ffmpeg via: ${installHint}.`
2052
+ );
2053
+ }
2054
+ const tempFileName = `markupr-transcode-${randomUUID()}.wav`;
2055
+ const tempPath = join2(tmpdir(), tempFileName);
2056
+ try {
2057
+ this.log(`Converting ${audioPath} to WAV via ffmpeg...`);
2058
+ await execFileAsync("ffmpeg", [
2059
+ "-i",
2060
+ audioPath,
2061
+ "-ar",
2062
+ String(SAMPLE_RATE),
2063
+ "-ac",
2064
+ "1",
2065
+ "-f",
2066
+ "wav",
2067
+ "-acodec",
2068
+ "pcm_f32le",
2069
+ "-y",
2070
+ tempPath
2071
+ ], {
2072
+ env: { PATH: process.env.PATH, HOME: process.env.HOME, LANG: process.env.LANG, TMPDIR: process.env.TMPDIR }
2073
+ });
2074
+ await chmod(tempPath, 384).catch(() => {
2075
+ });
2076
+ this.log("ffmpeg conversion complete, parsing WAV...");
2077
+ return await this.parseWavFile(tempPath);
2078
+ } catch (error) {
2079
+ const msg = error instanceof Error ? error.message : String(error);
2080
+ throw new Error(`Failed to convert audio file with ffmpeg: ${msg}`);
2081
+ } finally {
2082
+ try {
2083
+ await unlink(tempPath);
2084
+ } catch {
2085
+ }
2086
+ }
2087
+ }
2088
+ /**
2089
+ * Process buffered audio through Whisper
2090
+ */
2091
+ async processBufferedAudio(force = false) {
2092
+ if (this.isProcessing) {
2093
+ return;
2094
+ }
2095
+ if (!force && this.totalBufferDuration < CHUNK_DURATION_MS) {
2096
+ return;
2097
+ }
2098
+ if (this.audioBuffer.length === 0) {
2099
+ return;
2100
+ }
2101
+ if (!this.whisperModule) {
2102
+ this.logError("Cannot process: Whisper module not loaded");
2103
+ return;
2104
+ }
2105
+ this.isProcessing = true;
2106
+ const processStartTime = this.bufferStartTime;
2107
+ try {
2108
+ const totalSamples = this.audioBuffer.reduce((sum, arr) => sum + arr.length, 0);
2109
+ const combinedAudio = new Float32Array(totalSamples);
2110
+ let offset = 0;
2111
+ for (const chunk of this.audioBuffer) {
2112
+ combinedAudio.set(chunk, offset);
2113
+ offset += chunk.length;
2114
+ }
2115
+ const processedDuration = this.totalBufferDuration;
2116
+ this.audioBuffer = [];
2117
+ this.totalBufferDuration = 0;
2118
+ this.totalBufferBytes = 0;
2119
+ this.bufferStartTime = Date.now();
2120
+ this.log(`Processing ${Math.round(processedDuration)}ms of audio...`);
2121
+ const result = await this.whisperModule.whisper(combinedAudio, {
2122
+ modelPath: this.config.modelPath,
2123
+ language: this.config.language,
2124
+ threads: this.config.threads,
2125
+ translate: this.config.translateToEnglish
2126
+ });
2127
+ if (result && result.length > 0) {
2128
+ for (const segment of result) {
2129
+ const transcriptResult = {
2130
+ text: segment.text.trim(),
2131
+ startTime: processStartTime / 1e3 + segment.start,
2132
+ endTime: processStartTime / 1e3 + segment.end,
2133
+ confidence: 0.9
2134
+ // Whisper doesn't provide confidence, use default
2135
+ };
2136
+ if (transcriptResult.text) {
2137
+ this.transcriptCallbacks.forEach((cb) => cb(transcriptResult));
2138
+ this.emit("transcript", transcriptResult);
2139
+ const preview = transcriptResult.text.length > 50 ? `${transcriptResult.text.substring(0, 50)}...` : transcriptResult.text;
2140
+ this.log(`Transcript: "${preview}"`);
2141
+ }
2142
+ }
2143
+ }
2144
+ } catch (error) {
2145
+ const transcriptionError = new Error(`Whisper transcription failed: ${error.message}`);
2146
+ this.errorCallbacks.forEach((cb) => cb(transcriptionError));
2147
+ this.emit("error", transcriptionError);
2148
+ this.logError("Transcription error", error);
2149
+ } finally {
2150
+ this.isProcessing = false;
2151
+ }
2152
+ }
2153
+ /**
2154
+ * Log helper
2155
+ */
2156
+ log(message) {
2157
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2158
+ console.log(`[WhisperService ${timestamp}] ${message}`);
2159
+ }
2160
+ /**
2161
+ * Error log helper
2162
+ */
2163
+ logError(message, error) {
2164
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2165
+ const errorStr = error instanceof Error ? error.message : String(error);
2166
+ console.error(`[WhisperService ${timestamp}] ERROR: ${message} - ${errorStr}`);
2167
+ }
2168
+ getRequiredMemoryBytes() {
2169
+ const modelName = basename(this.config.modelPath);
2170
+ return MODEL_MEMORY_REQUIREMENTS_BYTES[modelName] ?? MODEL_MEMORY_REQUIREMENTS_BYTES["ggml-small.bin"];
2171
+ }
2172
+ };
2173
+ var whisperService = new WhisperService();
2174
+
2175
+ // src/main/output/templates/registry.ts
2176
+ var TemplateRegistryImpl = class {
2177
+ templates = /* @__PURE__ */ new Map();
2178
+ /**
2179
+ * Register a template. Overwrites any existing template with the same name.
2180
+ */
2181
+ register(template) {
2182
+ this.templates.set(template.name, template);
2183
+ }
2184
+ /**
2185
+ * Get a template by name. Returns undefined if not found.
2186
+ */
2187
+ get(name) {
2188
+ return this.templates.get(name);
2189
+ }
2190
+ /**
2191
+ * Check if a template with the given name exists.
2192
+ */
2193
+ has(name) {
2194
+ return this.templates.has(name);
2195
+ }
2196
+ /**
2197
+ * List all registered template names.
2198
+ */
2199
+ list() {
2200
+ return Array.from(this.templates.keys());
2201
+ }
2202
+ /**
2203
+ * List all registered templates with their descriptions.
2204
+ */
2205
+ listWithDescriptions() {
2206
+ return Array.from(this.templates.values()).map((t) => ({
2207
+ name: t.name,
2208
+ description: t.description,
2209
+ fileExtension: t.fileExtension
2210
+ }));
2211
+ }
2212
+ /**
2213
+ * Get the default template name.
2214
+ */
2215
+ getDefault() {
2216
+ return "markdown";
2217
+ }
2218
+ };
2219
+ var templateRegistry = new TemplateRegistryImpl();
2220
+
2221
+ // src/main/output/templates/helpers.ts
2222
+ import * as path2 from "path";
2223
+ function formatTimestamp(seconds) {
2224
+ const totalSeconds = Math.max(0, Math.floor(seconds));
2225
+ const mins = Math.floor(totalSeconds / 60);
2226
+ const secs = totalSeconds % 60;
2227
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
2228
+ }
2229
+ function formatDuration(ms) {
2230
+ const totalSeconds = Math.floor(ms / 1e3);
2231
+ const mins = Math.floor(totalSeconds / 60);
2232
+ const secs = totalSeconds % 60;
2233
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
2234
+ }
2235
+ function formatDate(date) {
2236
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
2237
+ const month = months[date.getMonth()];
2238
+ const day = date.getDate();
2239
+ const year = date.getFullYear();
2240
+ const rawHours = date.getHours();
2241
+ const ampm = rawHours >= 12 ? "PM" : "AM";
2242
+ const hours = rawHours % 12 || 12;
2243
+ const minutes = date.getMinutes().toString().padStart(2, "0");
2244
+ return `${month} ${day}, ${year} at ${hours}:${minutes} ${ampm}`;
2245
+ }
2246
+ function generateSegmentTitle(text) {
2247
+ const firstSentence = text.split(/[.!?]/)[0].trim();
2248
+ if (firstSentence.length <= 60) return firstSentence;
2249
+ return firstSentence.slice(0, 57) + "...";
2250
+ }
2251
+ function wrapTranscription(transcription) {
2252
+ if (!transcription.includes(".") && !transcription.includes("!") && !transcription.includes("?")) {
2253
+ return transcription;
2254
+ }
2255
+ const sentences = transcription.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
2256
+ if (sentences.length <= 1) return transcription;
2257
+ return sentences.join("\n> ");
2258
+ }
2259
+ function computeRelativeFramePath(framePath, sessionDir) {
2260
+ if (!path2.isAbsolute(framePath)) {
2261
+ return framePath;
2262
+ }
2263
+ return path2.relative(sessionDir, framePath);
2264
+ }
2265
+ function computeSessionDuration(segments) {
2266
+ if (segments.length === 0) return "0:00";
2267
+ return formatDuration(
2268
+ (segments[segments.length - 1].endTime - segments[0].startTime) * 1e3
2269
+ );
2270
+ }
2271
+ function mapFramesToSegments(segments, frames) {
2272
+ const map = /* @__PURE__ */ new Map();
2273
+ for (const frame of frames) {
2274
+ let bestIndex = 0;
2275
+ let bestDistance = Infinity;
2276
+ for (let i = 0; i < segments.length; i++) {
2277
+ const seg = segments[i];
2278
+ if (frame.timestamp >= seg.startTime && frame.timestamp <= seg.endTime) {
2279
+ bestIndex = i;
2280
+ bestDistance = 0;
2281
+ break;
2282
+ }
2283
+ const distance = Math.abs(frame.timestamp - seg.startTime);
2284
+ if (distance < bestDistance) {
2285
+ bestDistance = distance;
2286
+ bestIndex = i;
2287
+ }
2288
+ }
2289
+ const existing = map.get(bestIndex) || [];
2290
+ existing.push(frame);
2291
+ map.set(bestIndex, existing);
2292
+ }
2293
+ for (const [, frameList] of map) {
2294
+ frameList.sort((a, b) => a.timestamp - b.timestamp);
2295
+ }
2296
+ return map;
2297
+ }
2298
+
2299
+ // src/main/output/templates/markdown.ts
2300
+ var REPORT_SUPPORT_LINE2 = "*If this report saved you time, support development: [Ko-fi](https://ko-fi.com/eddiesanjuan)*";
2301
+ var markdownTemplate = {
2302
+ name: "markdown",
2303
+ description: "Default Markdown format \u2014 AI-ready, llms.txt-inspired structured document",
2304
+ fileExtension: ".md",
2305
+ render(context) {
2306
+ const { result, sessionDir, timestamp } = context;
2307
+ const { transcriptSegments, extractedFrames } = result;
2308
+ const sessionTimestamp = formatDate(new Date(timestamp ?? Date.now()));
2309
+ const sessionDuration = computeSessionDuration(transcriptSegments);
2310
+ let md = `# markupr Session \u2014 ${sessionTimestamp}
2311
+ `;
2312
+ md += `> Segments: ${transcriptSegments.length} | Frames: ${extractedFrames.length} | Duration: ${sessionDuration}
2313
+
2314
+ `;
2315
+ if (transcriptSegments.length === 0) {
2316
+ md += `_No speech was detected during this recording._
2317
+ `;
2318
+ return { content: md, fileExtension: ".md" };
2319
+ }
2320
+ md += `## Transcript
2321
+
2322
+ `;
2323
+ const segmentFrameMap = mapFramesToSegments(transcriptSegments, extractedFrames);
2324
+ for (let i = 0; i < transcriptSegments.length; i++) {
2325
+ const segment = transcriptSegments[i];
2326
+ const formattedTime = formatTimestamp(segment.startTime);
2327
+ const title = generateSegmentTitle(segment.text);
2328
+ md += `### [${formattedTime}] ${title}
2329
+ `;
2330
+ md += `> ${wrapTranscription(segment.text)}
2331
+
2332
+ `;
2333
+ const frames = segmentFrameMap.get(i);
2334
+ if (frames && frames.length > 0) {
2335
+ for (const frame of frames) {
2336
+ const frameTimestamp = formatTimestamp(frame.timestamp);
2337
+ const relativePath = computeRelativeFramePath(frame.path, sessionDir);
2338
+ md += `![Frame at ${frameTimestamp}](${relativePath})
2339
+
2340
+ `;
2341
+ }
2342
+ }
2343
+ }
2344
+ md += `---
2345
+ *Generated by [markupr](https://markupr.com)*
2346
+ ${REPORT_SUPPORT_LINE2}
2347
+ `;
2348
+ return { content: md, fileExtension: ".md" };
2349
+ }
2350
+ };
2351
+
2352
+ // src/main/output/templates/json.ts
2353
+ var jsonTemplate = {
2354
+ name: "json",
2355
+ description: "Structured JSON output for programmatic consumption",
2356
+ fileExtension: ".json",
2357
+ render(context) {
2358
+ const { result, sessionDir, timestamp } = context;
2359
+ const { transcriptSegments, extractedFrames } = result;
2360
+ const segmentFrameMap = mapFramesToSegments(transcriptSegments, extractedFrames);
2361
+ const output = {
2362
+ version: "1.0",
2363
+ generator: "markupr",
2364
+ timestamp: new Date(timestamp ?? Date.now()).toISOString(),
2365
+ summary: {
2366
+ segments: transcriptSegments.length,
2367
+ frames: extractedFrames.length,
2368
+ duration: computeSessionDuration(transcriptSegments)
2369
+ },
2370
+ segments: transcriptSegments.map((segment, i) => {
2371
+ const frames = segmentFrameMap.get(i) || [];
2372
+ return {
2373
+ text: segment.text,
2374
+ startTime: segment.startTime,
2375
+ endTime: segment.endTime,
2376
+ confidence: segment.confidence,
2377
+ frames: frames.map((f) => ({
2378
+ path: computeRelativeFramePath(f.path, sessionDir),
2379
+ timestamp: f.timestamp,
2380
+ reason: f.reason
2381
+ }))
2382
+ };
2383
+ })
2384
+ };
2385
+ return {
2386
+ content: JSON.stringify(output, null, 2),
2387
+ fileExtension: ".json"
2388
+ };
2389
+ }
2390
+ };
2391
+
2392
+ // src/main/output/templates/github-issue.ts
2393
+ var githubIssueTemplate = {
2394
+ name: "github-issue",
2395
+ description: "GitHub-flavored Markdown optimized for issue bodies with task lists and collapsible details",
2396
+ fileExtension: ".md",
2397
+ render(context) {
2398
+ const { result, sessionDir, timestamp } = context;
2399
+ const { transcriptSegments, extractedFrames } = result;
2400
+ const sessionTimestamp = formatDate(new Date(timestamp ?? Date.now()));
2401
+ const duration = computeSessionDuration(transcriptSegments);
2402
+ let md = `## Feedback Report
2403
+
2404
+ `;
2405
+ md += `> Captured by [markupr](https://markupr.com) on ${sessionTimestamp}
2406
+ `;
2407
+ md += `> ${transcriptSegments.length} segments | ${extractedFrames.length} frames | Duration: ${duration}
2408
+
2409
+ `;
2410
+ if (transcriptSegments.length === 0) {
2411
+ md += `_No feedback was captured during this recording._
2412
+ `;
2413
+ return { content: md, fileExtension: ".md" };
2414
+ }
2415
+ md += `### Action Items
2416
+
2417
+ `;
2418
+ for (const segment of transcriptSegments) {
2419
+ const title = generateSegmentTitle(segment.text);
2420
+ md += `- [ ] ${title}
2421
+ `;
2422
+ }
2423
+ md += `
2424
+ `;
2425
+ md += `### Details
2426
+
2427
+ `;
2428
+ const segmentFrameMap = mapFramesToSegments(transcriptSegments, extractedFrames);
2429
+ for (let i = 0; i < transcriptSegments.length; i++) {
2430
+ const segment = transcriptSegments[i];
2431
+ const formattedTime = formatTimestamp(segment.startTime);
2432
+ const title = generateSegmentTitle(segment.text);
2433
+ md += `<details>
2434
+ `;
2435
+ md += `<summary><strong>[${formattedTime}] ${title}</strong></summary>
2436
+
2437
+ `;
2438
+ md += `${segment.text}
2439
+
2440
+ `;
2441
+ const frames = segmentFrameMap.get(i);
2442
+ if (frames && frames.length > 0) {
2443
+ for (const frame of frames) {
2444
+ const relativePath = computeRelativeFramePath(frame.path, sessionDir);
2445
+ md += `![Screenshot](${relativePath})
2446
+
2447
+ `;
2448
+ }
2449
+ }
2450
+ md += `</details>
2451
+
2452
+ `;
2453
+ }
2454
+ md += `---
2455
+ _Generated by [markupr](https://markupr.com)_
2456
+ `;
2457
+ return { content: md, fileExtension: ".md" };
2458
+ }
2459
+ };
2460
+
2461
+ // src/main/output/templates/linear.ts
2462
+ var linearTemplate = {
2463
+ name: "linear",
2464
+ description: "Linear-compatible Markdown for issue descriptions",
2465
+ fileExtension: ".md",
2466
+ render(context) {
2467
+ const { result, sessionDir, timestamp } = context;
2468
+ const { transcriptSegments, extractedFrames } = result;
2469
+ const sessionTimestamp = formatDate(new Date(timestamp ?? Date.now()));
2470
+ const duration = computeSessionDuration(transcriptSegments);
2471
+ let md = `**Feedback Report** \u2014 ${sessionTimestamp}
2472
+ `;
2473
+ md += `${transcriptSegments.length} segments | ${extractedFrames.length} frames | Duration: ${duration}
2474
+
2475
+ `;
2476
+ if (transcriptSegments.length === 0) {
2477
+ md += `_No feedback was captured during this recording._
2478
+ `;
2479
+ return { content: md, fileExtension: ".md" };
2480
+ }
2481
+ md += `**Action Items**
2482
+
2483
+ `;
2484
+ for (const segment of transcriptSegments) {
2485
+ const title = generateSegmentTitle(segment.text);
2486
+ md += `- [ ] ${title}
2487
+ `;
2488
+ }
2489
+ md += `
2490
+ ---
2491
+
2492
+ `;
2493
+ const segmentFrameMap = mapFramesToSegments(transcriptSegments, extractedFrames);
2494
+ for (let i = 0; i < transcriptSegments.length; i++) {
2495
+ const segment = transcriptSegments[i];
2496
+ const formattedTime = formatTimestamp(segment.startTime);
2497
+ const title = generateSegmentTitle(segment.text);
2498
+ md += `### [${formattedTime}] ${title}
2499
+
2500
+ `;
2501
+ md += `> ${segment.text}
2502
+
2503
+ `;
2504
+ const frames = segmentFrameMap.get(i);
2505
+ if (frames && frames.length > 0) {
2506
+ for (const frame of frames) {
2507
+ const relativePath = computeRelativeFramePath(frame.path, sessionDir);
2508
+ md += `![Screenshot](${relativePath})
2509
+
2510
+ `;
2511
+ }
2512
+ }
2513
+ }
2514
+ md += `---
2515
+ _Captured by [markupr](https://markupr.com)_
2516
+ `;
2517
+ return { content: md, fileExtension: ".md" };
2518
+ }
2519
+ };
2520
+
2521
+ // src/main/output/templates/jira.ts
2522
+ var jiraTemplate = {
2523
+ name: "jira",
2524
+ description: "Jira wiki markup with panels, tables, and {code} blocks",
2525
+ fileExtension: ".jira",
2526
+ render(context) {
2527
+ const { result, sessionDir, timestamp } = context;
2528
+ const { transcriptSegments, extractedFrames } = result;
2529
+ const sessionTimestamp = formatDate(new Date(timestamp ?? Date.now()));
2530
+ const duration = computeSessionDuration(transcriptSegments);
2531
+ let content = `h1. Feedback Report
2532
+
2533
+ `;
2534
+ content += `{panel:title=Session Info|borderStyle=solid|borderColor=#ccc}
2535
+ `;
2536
+ content += `*Captured:* ${sessionTimestamp}
2537
+ `;
2538
+ content += `*Segments:* ${transcriptSegments.length} | *Frames:* ${extractedFrames.length} | *Duration:* ${duration}
2539
+ `;
2540
+ content += `{panel}
2541
+
2542
+ `;
2543
+ if (transcriptSegments.length === 0) {
2544
+ content += `_No feedback was captured during this recording._
2545
+ `;
2546
+ return { content, fileExtension: ".jira" };
2547
+ }
2548
+ content += `h2. Summary
2549
+
2550
+ `;
2551
+ content += `||#||Timestamp||Feedback||
2552
+ `;
2553
+ for (let i = 0; i < transcriptSegments.length; i++) {
2554
+ const segment = transcriptSegments[i];
2555
+ const formattedTime = formatTimestamp(segment.startTime);
2556
+ const title = generateSegmentTitle(segment.text);
2557
+ content += `|${i + 1}|${formattedTime}|${title}|
2558
+ `;
2559
+ }
2560
+ content += `
2561
+ `;
2562
+ content += `h2. Details
2563
+
2564
+ `;
2565
+ const segmentFrameMap = mapFramesToSegments(transcriptSegments, extractedFrames);
2566
+ for (let i = 0; i < transcriptSegments.length; i++) {
2567
+ const segment = transcriptSegments[i];
2568
+ const formattedTime = formatTimestamp(segment.startTime);
2569
+ const title = generateSegmentTitle(segment.text);
2570
+ content += `h3. \\[${formattedTime}\\] ${title}
2571
+
2572
+ `;
2573
+ content += `{quote}
2574
+ ${segment.text}
2575
+ {quote}
2576
+
2577
+ `;
2578
+ const frames = segmentFrameMap.get(i);
2579
+ if (frames && frames.length > 0) {
2580
+ for (const frame of frames) {
2581
+ const relativePath = computeRelativeFramePath(frame.path, sessionDir);
2582
+ content += `!${relativePath}|thumbnail!
2583
+
2584
+ `;
2585
+ }
2586
+ }
2587
+ }
2588
+ content += `----
2589
+ _Generated by [markupr|https://markupr.com]_
2590
+ `;
2591
+ return { content, fileExtension: ".jira" };
2592
+ }
2593
+ };
2594
+
2595
+ // src/main/output/templates/index.ts
2596
+ templateRegistry.register(markdownTemplate);
2597
+ templateRegistry.register(jsonTemplate);
2598
+ templateRegistry.register(githubIssueTemplate);
2599
+ templateRegistry.register(linearTemplate);
2600
+ templateRegistry.register(jiraTemplate);
2601
+
2602
+ // src/cli/CLIPipeline.ts
2603
+ var EXIT_SUCCESS = 0;
2604
+ var EXIT_USER_ERROR = 1;
2605
+ var EXIT_SYSTEM_ERROR = 2;
2606
+ var EXIT_SIGINT = 130;
2607
+ var CLIPipeline = class _CLIPipeline {
2608
+ options;
2609
+ log;
2610
+ progress;
2611
+ tempFiles = [];
2612
+ activeProcesses = /* @__PURE__ */ new Set();
2613
+ constructor(options, log, progress) {
2614
+ this.options = options;
2615
+ this.log = log;
2616
+ this.progress = progress ?? (() => {
2617
+ });
2618
+ }
2619
+ /**
2620
+ * Run the full pipeline: audio extraction -> transcription -> analysis ->
2621
+ * frame extraction -> markdown generation.
2622
+ */
2623
+ async run() {
2624
+ try {
2625
+ return await this.runPipeline();
2626
+ } finally {
2627
+ await this.cleanup();
2628
+ }
2629
+ }
2630
+ async runPipeline() {
2631
+ const startTime = Date.now();
2632
+ await this.validateVideoFile();
2633
+ if (!(this.options.audioPath && this.options.skipFrames)) {
2634
+ await this.checkFfmpegAvailable();
2635
+ }
2636
+ try {
2637
+ if (!existsSync3(this.options.outputDir)) {
2638
+ mkdirSync2(this.options.outputDir, { recursive: true });
2639
+ }
2640
+ } catch (error) {
2641
+ const code = error.code;
2642
+ if (code === "EACCES") {
2643
+ throw new CLIPipelineError(
2644
+ `Permission denied: cannot create output directory: ${this.options.outputDir}`,
2645
+ "user"
2646
+ );
2647
+ }
2648
+ throw new CLIPipelineError(
2649
+ `Cannot create output directory: ${this.options.outputDir} (${code})`,
2650
+ "system"
2651
+ );
2652
+ }
2653
+ this.progress("Extracting audio...");
2654
+ const audioPath = await this.resolveAudioPath();
2655
+ this.progress("Transcribing (this may take a while)...");
2656
+ const segments = await this.transcribe(audioPath);
2657
+ const analyzer = new TranscriptAnalyzer();
2658
+ const keyMoments = analyzer.analyze(segments);
2659
+ this.log(` Found ${keyMoments.length} key moment(s)`);
2660
+ let extractedFrames = [];
2661
+ if (!this.options.skipFrames) {
2662
+ this.progress("Extracting frames...");
2663
+ extractedFrames = await this.extractFrames(keyMoments, segments);
2664
+ } else {
2665
+ this.log(" Frame extraction skipped (--no-frames)");
2666
+ }
2667
+ this.progress("Generating report...");
2668
+ const result = {
2669
+ transcriptSegments: segments,
2670
+ extractedFrames,
2671
+ reportPath: this.options.outputDir
2672
+ };
2673
+ let reportContent;
2674
+ let reportExtension = ".md";
2675
+ const templateName = this.options.template;
2676
+ if (templateName && templateName !== "markdown") {
2677
+ const template = templateRegistry.get(templateName);
2678
+ if (!template) {
2679
+ const available = templateRegistry.list().join(", ");
2680
+ throw new CLIPipelineError(
2681
+ `Unknown template "${templateName}". Available: ${available}`,
2682
+ "user"
2683
+ );
2684
+ }
2685
+ const output = template.render({ result, sessionDir: this.options.outputDir });
2686
+ reportContent = output.content;
2687
+ reportExtension = output.fileExtension;
2688
+ } else {
2689
+ const generator = new MarkdownGeneratorImpl();
2690
+ reportContent = generator.generateFromPostProcess(result, this.options.outputDir);
2691
+ }
2692
+ const outputFilename = this.generateOutputFilename(reportExtension);
2693
+ const outputPath = join3(this.options.outputDir, outputFilename);
2694
+ try {
2695
+ await writeFile(outputPath, reportContent, "utf-8");
2696
+ } catch (error) {
2697
+ const code = error.code;
2698
+ throw new CLIPipelineError(
2699
+ `Failed to write output file: ${outputPath}
2700
+ Reason: ${code === "ENOSPC" ? "Disk is full" : error.message}`,
2701
+ "system"
2702
+ );
2703
+ }
2704
+ const durationSeconds = (Date.now() - startTime) / 1e3;
2705
+ return {
2706
+ outputPath,
2707
+ transcriptSegments: segments.length,
2708
+ extractedFrames: extractedFrames.length,
2709
+ durationSeconds
2710
+ };
2711
+ }
2712
+ /**
2713
+ * Abort the pipeline: kill active child processes and clean up temp files.
2714
+ */
2715
+ async abort() {
2716
+ for (const proc of this.activeProcesses) {
2717
+ proc.kill("SIGTERM");
2718
+ }
2719
+ this.activeProcesses.clear();
2720
+ await this.cleanup();
2721
+ }
2722
+ /**
2723
+ * Clean up temp files created during the pipeline run.
2724
+ */
2725
+ async cleanup() {
2726
+ for (const file of this.tempFiles) {
2727
+ try {
2728
+ await unlink2(file);
2729
+ } catch {
2730
+ }
2731
+ }
2732
+ this.tempFiles = [];
2733
+ }
2734
+ // ==========================================================================
2735
+ // Private Methods
2736
+ // ==========================================================================
2737
+ /**
2738
+ * Execute a child process while tracking it for cleanup on abort.
2739
+ */
2740
+ static SAFE_CHILD_ENV = {
2741
+ PATH: process.env.PATH,
2742
+ HOME: process.env.HOME || process.env.USERPROFILE,
2743
+ USERPROFILE: process.env.USERPROFILE,
2744
+ LANG: process.env.LANG,
2745
+ TMPDIR: process.env.TMPDIR || process.env.TEMP,
2746
+ TEMP: process.env.TEMP
2747
+ };
2748
+ execFileTracked(command, args) {
2749
+ return new Promise((resolve3, reject) => {
2750
+ const child = execFileCb2(command, args, { env: _CLIPipeline.SAFE_CHILD_ENV }, (error, stdout, stderr) => {
2751
+ this.activeProcesses.delete(child);
2752
+ if (error) reject(error);
2753
+ else resolve3({ stdout: stdout?.toString() ?? "", stderr: stderr?.toString() ?? "" });
2754
+ });
2755
+ this.activeProcesses.add(child);
2756
+ });
2757
+ }
2758
+ /**
2759
+ * Validate the video file is a real, non-empty file with a video stream.
2760
+ */
2761
+ async validateVideoFile() {
2762
+ const { videoPath } = this.options;
2763
+ let stats;
2764
+ try {
2765
+ stats = await stat(videoPath);
2766
+ } catch {
2767
+ throw new CLIPipelineError(`Video file not found: ${videoPath}`, "user");
2768
+ }
2769
+ if (!stats.isFile()) {
2770
+ throw new CLIPipelineError(`Not a regular file: ${videoPath}`, "user");
2771
+ }
2772
+ if (stats.size === 0) {
2773
+ throw new CLIPipelineError(`Video file is empty (0 bytes): ${videoPath}`, "user");
2774
+ }
2775
+ try {
2776
+ const { stdout } = await this.execFileTracked("ffprobe", [
2777
+ "-v",
2778
+ "error",
2779
+ "-select_streams",
2780
+ "v",
2781
+ "-show_entries",
2782
+ "stream=codec_type",
2783
+ "-of",
2784
+ "csv=p=0",
2785
+ videoPath
2786
+ ]);
2787
+ if (!stdout.trim().includes("video")) {
2788
+ throw new CLIPipelineError(
2789
+ `No video stream found in file: ${videoPath}`,
2790
+ "user"
2791
+ );
2792
+ }
2793
+ } catch (error) {
2794
+ if (error instanceof CLIPipelineError) throw error;
2795
+ throw new CLIPipelineError(
2796
+ `Cannot read video file (is ffprobe installed?): ${videoPath}`,
2797
+ "system"
2798
+ );
2799
+ }
2800
+ }
2801
+ /**
2802
+ * Check that ffmpeg is available on PATH.
2803
+ */
2804
+ async checkFfmpegAvailable() {
2805
+ try {
2806
+ await this.execFileTracked("ffmpeg", ["-version"]);
2807
+ } catch {
2808
+ const platform = process.platform;
2809
+ const installHint = platform === "darwin" ? "brew install ffmpeg" : platform === "win32" ? "winget install ffmpeg (or download from https://ffmpeg.org)" : "apt install ffmpeg (or your package manager)";
2810
+ throw new CLIPipelineError(
2811
+ `ffmpeg is required but not found on your system.
2812
+ Install via: ${installHint}
2813
+ Or provide a separate audio file with --audio <file> and --no-frames`,
2814
+ "system"
2815
+ );
2816
+ }
2817
+ }
2818
+ /**
2819
+ * Resolve the audio path. If no separate audio file was provided, probe
2820
+ * the video for an audio track and extract it to a temp WAV file.
2821
+ */
2822
+ async resolveAudioPath() {
2823
+ if (this.options.audioPath) {
2824
+ if (!existsSync3(this.options.audioPath)) {
2825
+ throw new CLIPipelineError(
2826
+ `Audio file not found: ${this.options.audioPath}`,
2827
+ "user"
2828
+ );
2829
+ }
2830
+ this.log(` Using provided audio: ${this.options.audioPath}`);
2831
+ return this.options.audioPath;
2832
+ }
2833
+ const hasAudio = await this.videoHasAudioTrack(this.options.videoPath);
2834
+ if (!hasAudio) {
2835
+ this.log(" No audio track found in video - transcription will be skipped");
2836
+ return null;
2837
+ }
2838
+ this.log(" Extracting audio from video...");
2839
+ const tempAudioPath = join3(tmpdir2(), `markupr-cli-audio-${randomUUID2()}.wav`);
2840
+ this.tempFiles.push(tempAudioPath);
2841
+ try {
2842
+ await this.execFileTracked("ffmpeg", [
2843
+ "-i",
2844
+ this.options.videoPath,
2845
+ "-vn",
2846
+ "-ar",
2847
+ "16000",
2848
+ "-ac",
2849
+ "1",
2850
+ "-f",
2851
+ "wav",
2852
+ "-acodec",
2853
+ "pcm_f32le",
2854
+ "-y",
2855
+ tempAudioPath
2856
+ ]);
2857
+ await chmod2(tempAudioPath, 384).catch(() => {
2858
+ });
2859
+ this.log(" Audio extraction complete");
2860
+ return tempAudioPath;
2861
+ } catch (error) {
2862
+ const msg = error instanceof Error ? error.message : String(error);
2863
+ this.log(` WARNING: Audio extraction failed: ${msg}`);
2864
+ return null;
2865
+ }
2866
+ }
2867
+ /**
2868
+ * Use ffprobe to check whether the video file contains an audio stream.
2869
+ */
2870
+ async videoHasAudioTrack(videoPath) {
2871
+ try {
2872
+ const { stdout } = await this.execFileTracked("ffprobe", [
2873
+ "-v",
2874
+ "error",
2875
+ "-select_streams",
2876
+ "a",
2877
+ "-show_entries",
2878
+ "stream=codec_type",
2879
+ "-of",
2880
+ "csv=p=0",
2881
+ videoPath
2882
+ ]);
2883
+ return stdout.trim().length > 0;
2884
+ } catch {
2885
+ return false;
2886
+ }
2887
+ }
2888
+ /**
2889
+ * Transcribe audio using WhisperService. Falls back gracefully if the
2890
+ * model is not available.
2891
+ */
2892
+ async transcribe(audioPath) {
2893
+ if (!audioPath) {
2894
+ this.log(" No audio available - skipping transcription");
2895
+ return [];
2896
+ }
2897
+ const whisper = new WhisperService(
2898
+ this.options.whisperModelPath ? { modelPath: this.options.whisperModelPath } : void 0
2899
+ );
2900
+ if (!whisper.isModelAvailable()) {
2901
+ const modelsDir = whisper.getModelsDirectory();
2902
+ this.log(` Whisper model not found at: ${whisper.getConfig().modelPath}`);
2903
+ this.log(` Models directory: ${modelsDir}`);
2904
+ this.log(" Transcription will be skipped. Download a model to enable transcription.");
2905
+ return [];
2906
+ }
2907
+ this.log(` Transcribing with Whisper (model: ${basename2(whisper.getConfig().modelPath)})...`);
2908
+ try {
2909
+ const results = await whisper.transcribeFile(audioPath, (percent) => {
2910
+ if (this.options.verbose) {
2911
+ process.stdout.write(`\r Transcription progress: ${percent}%`);
2912
+ }
2913
+ });
2914
+ if (this.options.verbose && results.length > 0) {
2915
+ process.stdout.write("\n");
2916
+ }
2917
+ const segments = results.map((r) => ({
2918
+ text: r.text,
2919
+ startTime: r.startTime,
2920
+ endTime: r.endTime,
2921
+ confidence: r.confidence
2922
+ }));
2923
+ this.log(` Transcription complete: ${segments.length} segment(s)`);
2924
+ return segments;
2925
+ } catch (error) {
2926
+ const msg = error instanceof Error ? error.message : String(error);
2927
+ this.log(` WARNING: Transcription failed: ${msg}`);
2928
+ return [];
2929
+ }
2930
+ }
2931
+ /**
2932
+ * Extract video frames at key moment timestamps.
2933
+ */
2934
+ async extractFrames(keyMoments, segments) {
2935
+ if (keyMoments.length === 0) {
2936
+ this.log(" No key moments found - skipping frame extraction");
2937
+ return [];
2938
+ }
2939
+ const extractor = new FrameExtractor();
2940
+ const available = await extractor.checkFfmpeg();
2941
+ if (!available) {
2942
+ this.log(" WARNING: ffmpeg not found - frame extraction skipped");
2943
+ this.log(" Install ffmpeg: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)");
2944
+ return [];
2945
+ }
2946
+ this.log(` Extracting ${keyMoments.length} frame(s)...`);
2947
+ const timestamps = keyMoments.map((m) => m.timestamp);
2948
+ const extractionResult = await extractor.extract({
2949
+ videoPath: this.options.videoPath,
2950
+ timestamps,
2951
+ outputDir: this.options.outputDir
2952
+ });
2953
+ const extractedFrames = extractionResult.frames.filter((f) => f.success).map((frame) => {
2954
+ const moment = keyMoments.find(
2955
+ (m) => Math.abs(m.timestamp - frame.timestamp) < 0.5
2956
+ );
2957
+ const closestSegment = this.findClosestSegment(frame.timestamp, segments);
2958
+ return {
2959
+ path: frame.path,
2960
+ timestamp: frame.timestamp,
2961
+ reason: moment?.reason ?? "Extracted frame",
2962
+ transcriptSegment: closestSegment
2963
+ };
2964
+ });
2965
+ this.log(` Extracted ${extractedFrames.length} frame(s)`);
2966
+ return extractedFrames;
2967
+ }
2968
+ /**
2969
+ * Find the transcript segment closest to a given timestamp.
2970
+ */
2971
+ findClosestSegment(timestamp, segments) {
2972
+ if (segments.length === 0) return void 0;
2973
+ for (const segment of segments) {
2974
+ if (timestamp >= segment.startTime && timestamp <= segment.endTime) {
2975
+ return segment;
2976
+ }
2977
+ }
2978
+ let closest = segments[0];
2979
+ let minDistance = Math.abs(timestamp - closest.startTime);
2980
+ for (let i = 1; i < segments.length; i++) {
2981
+ const distance = Math.abs(timestamp - segments[i].startTime);
2982
+ if (distance < minDistance) {
2983
+ minDistance = distance;
2984
+ closest = segments[i];
2985
+ }
2986
+ }
2987
+ return closest;
2988
+ }
2989
+ /**
2990
+ * Generate the output filename based on the video filename and current date (UTC).
2991
+ */
2992
+ generateOutputFilename(extension = ".md") {
2993
+ const videoName = basename2(this.options.videoPath).replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-");
2994
+ const now = /* @__PURE__ */ new Date();
2995
+ const dateStr = [
2996
+ now.getUTCFullYear(),
2997
+ String(now.getUTCMonth() + 1).padStart(2, "0"),
2998
+ String(now.getUTCDate()).padStart(2, "0")
2999
+ ].join("");
3000
+ const timeStr = [
3001
+ String(now.getUTCHours()).padStart(2, "0"),
3002
+ String(now.getUTCMinutes()).padStart(2, "0"),
3003
+ String(now.getUTCSeconds()).padStart(2, "0")
3004
+ ].join("");
3005
+ const ext = extension.startsWith(".") ? extension : `.${extension}`;
3006
+ return `${videoName}-feedback-${dateStr}-${timeStr}${ext}`;
3007
+ }
3008
+ };
3009
+ var CLIPipelineError = class extends Error {
3010
+ severity;
3011
+ constructor(message, severity) {
3012
+ super(message);
3013
+ this.name = "CLIPipelineError";
3014
+ this.severity = severity;
3015
+ }
3016
+ };
3017
+
3018
+ // src/cli/WatchMode.ts
3019
+ import { watch, existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync } from "fs";
3020
+ import { stat as stat2, readdir, appendFile } from "fs/promises";
3021
+ import { join as join4, resolve, extname, basename as basename3 } from "path";
3022
+ var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mov", ".mp4", ".webm"]);
3023
+ var WATCH_LOG_FILENAME = ".markupr-watch.log";
3024
+ var WatchMode = class {
3025
+ options;
3026
+ callbacks;
3027
+ watcher = null;
3028
+ processing = /* @__PURE__ */ new Set();
3029
+ processed = /* @__PURE__ */ new Set();
3030
+ pendingStabilityChecks = /* @__PURE__ */ new Map();
3031
+ stopped = false;
3032
+ stopResolve = null;
3033
+ resolvedOutputDir;
3034
+ constructor(options, callbacks) {
3035
+ this.options = options;
3036
+ this.callbacks = callbacks;
3037
+ this.resolvedOutputDir = options.outputDir ? resolve(options.outputDir) : join4(resolve(options.watchDir), "markupr-output");
3038
+ }
3039
+ /**
3040
+ * Start watching the directory. Returns a promise that resolves when
3041
+ * the watcher is stopped (via stop() or SIGINT).
3042
+ */
3043
+ async start() {
3044
+ const watchDir = resolve(this.options.watchDir);
3045
+ if (!existsSync4(watchDir)) {
3046
+ throw new Error(`Watch directory does not exist: ${watchDir}`);
3047
+ }
3048
+ if (!existsSync4(this.resolvedOutputDir)) {
3049
+ mkdirSync3(this.resolvedOutputDir, { recursive: true });
3050
+ }
3051
+ await this.scanExistingFiles(watchDir);
3052
+ this.callbacks.onLog(`Watching: ${watchDir}`);
3053
+ this.callbacks.onLog(`Output: ${this.resolvedOutputDir}`);
3054
+ this.callbacks.onLog(`Watching for: ${[...VIDEO_EXTENSIONS].join(", ")}`);
3055
+ this.watcher = watch(watchDir, (eventType, filename) => {
3056
+ if (this.stopped || !filename) return;
3057
+ this.handleFileEvent(watchDir, filename);
3058
+ });
3059
+ this.watcher.on("error", (err) => {
3060
+ this.callbacks.onLog(`Watcher error: ${err.message}`);
3061
+ });
3062
+ return new Promise((resolvePromise) => {
3063
+ if (this.stopped) {
3064
+ resolvePromise();
3065
+ return;
3066
+ }
3067
+ this.stopResolve = resolvePromise;
3068
+ });
3069
+ }
3070
+ /**
3071
+ * Stop watching and clean up.
3072
+ */
3073
+ stop() {
3074
+ this.stopped = true;
3075
+ for (const [, timeout] of this.pendingStabilityChecks) {
3076
+ clearTimeout(timeout);
3077
+ }
3078
+ this.pendingStabilityChecks.clear();
3079
+ if (this.watcher) {
3080
+ this.watcher.close();
3081
+ this.watcher = null;
3082
+ }
3083
+ if (this.stopResolve) {
3084
+ this.stopResolve();
3085
+ this.stopResolve = null;
3086
+ }
3087
+ }
3088
+ /**
3089
+ * Check if the watcher has been stopped.
3090
+ */
3091
+ isStopped() {
3092
+ return this.stopped;
3093
+ }
3094
+ /**
3095
+ * Get the set of files currently being processed.
3096
+ */
3097
+ getProcessingFiles() {
3098
+ return this.processing;
3099
+ }
3100
+ /**
3101
+ * Get the set of files that have been processed.
3102
+ */
3103
+ getProcessedFiles() {
3104
+ return this.processed;
3105
+ }
3106
+ // ==========================================================================
3107
+ // Private Methods
3108
+ // ==========================================================================
3109
+ /**
3110
+ * Scan existing files in the watch directory and mark ones that already
3111
+ * have corresponding output as processed.
3112
+ */
3113
+ async scanExistingFiles(watchDir) {
3114
+ try {
3115
+ const entries = await readdir(watchDir);
3116
+ for (const entry of entries) {
3117
+ const ext = extname(entry).toLowerCase();
3118
+ if (VIDEO_EXTENSIONS.has(ext)) {
3119
+ const fullPath = join4(watchDir, entry);
3120
+ if (this.hasExistingOutput(entry)) {
3121
+ this.processed.add(fullPath);
3122
+ if (this.options.verbose) {
3123
+ this.callbacks.onLog(`Skipping (already processed): ${entry}`);
3124
+ }
3125
+ }
3126
+ }
3127
+ }
3128
+ } catch {
3129
+ }
3130
+ }
3131
+ /**
3132
+ * Check if a video file already has corresponding output in the output directory.
3133
+ */
3134
+ hasExistingOutput(filename) {
3135
+ const videoName = basename3(filename).replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-");
3136
+ if (!existsSync4(this.resolvedOutputDir)) return false;
3137
+ try {
3138
+ const outputFiles = readdirSync(this.resolvedOutputDir);
3139
+ return outputFiles.some(
3140
+ (f) => f.startsWith(videoName) && f.endsWith(".md")
3141
+ );
3142
+ } catch {
3143
+ return false;
3144
+ }
3145
+ }
3146
+ /**
3147
+ * Handle a file event from fs.watch.
3148
+ */
3149
+ handleFileEvent(watchDir, filename) {
3150
+ const ext = extname(filename).toLowerCase();
3151
+ if (!VIDEO_EXTENSIONS.has(ext)) return;
3152
+ const fullPath = join4(watchDir, filename);
3153
+ if (this.processed.has(fullPath) || this.processing.has(fullPath)) return;
3154
+ if (this.pendingStabilityChecks.has(fullPath)) return;
3155
+ this.callbacks.onFileDetected(fullPath);
3156
+ this.startStabilityCheck(fullPath);
3157
+ }
3158
+ /**
3159
+ * Start a stability check for a file. The file is considered stable when
3160
+ * its size doesn't change over the configured interval (default: 2s).
3161
+ */
3162
+ startStabilityCheck(filePath, previousSize, checks = 0) {
3163
+ const interval = this.options.stabilityInterval ?? 2e3;
3164
+ const maxChecks = this.options.maxStabilityChecks ?? 30;
3165
+ if (this.stopped) return;
3166
+ if (checks >= maxChecks) {
3167
+ this.callbacks.onLog(`Gave up waiting for file to stabilize: ${basename3(filePath)}`);
3168
+ this.pendingStabilityChecks.delete(filePath);
3169
+ return;
3170
+ }
3171
+ const timeout = setTimeout(async () => {
3172
+ if (this.stopped) {
3173
+ this.pendingStabilityChecks.delete(filePath);
3174
+ return;
3175
+ }
3176
+ try {
3177
+ if (!existsSync4(filePath)) {
3178
+ this.pendingStabilityChecks.delete(filePath);
3179
+ return;
3180
+ }
3181
+ const stats = await stat2(filePath);
3182
+ const currentSize = stats.size;
3183
+ if (currentSize === 0) {
3184
+ this.pendingStabilityChecks.delete(filePath);
3185
+ this.startStabilityCheck(filePath, currentSize, checks + 1);
3186
+ return;
3187
+ }
3188
+ if (previousSize !== void 0 && currentSize === previousSize) {
3189
+ this.pendingStabilityChecks.delete(filePath);
3190
+ this.processFile(filePath);
3191
+ } else {
3192
+ if (this.options.verbose) {
3193
+ this.callbacks.onLog(
3194
+ `File size: ${currentSize} bytes (check ${checks + 1}/${maxChecks}): ${basename3(filePath)}`
3195
+ );
3196
+ }
3197
+ this.pendingStabilityChecks.delete(filePath);
3198
+ this.startStabilityCheck(filePath, currentSize, checks + 1);
3199
+ }
3200
+ } catch {
3201
+ this.pendingStabilityChecks.delete(filePath);
3202
+ }
3203
+ }, interval);
3204
+ this.pendingStabilityChecks.set(filePath, timeout);
3205
+ }
3206
+ /**
3207
+ * Process a stable video file through CLIPipeline.
3208
+ */
3209
+ async processFile(filePath) {
3210
+ if (this.stopped || this.processing.has(filePath) || this.processed.has(filePath)) return;
3211
+ this.processing.add(filePath);
3212
+ this.callbacks.onProcessingStart(filePath);
3213
+ const pipelineOptions = {
3214
+ videoPath: filePath,
3215
+ outputDir: this.resolvedOutputDir,
3216
+ whisperModelPath: this.options.whisperModelPath,
3217
+ openaiKey: this.options.openaiKey,
3218
+ skipFrames: this.options.skipFrames,
3219
+ verbose: this.options.verbose
3220
+ };
3221
+ const logFn = this.options.verbose ? this.callbacks.onLog : () => {
3222
+ };
3223
+ const pipeline = new CLIPipeline(pipelineOptions, logFn, this.callbacks.onLog);
3224
+ try {
3225
+ const result = await pipeline.run();
3226
+ this.processed.add(filePath);
3227
+ this.callbacks.onProcessingComplete(filePath, result.outputPath);
3228
+ await this.appendToWatchLog(filePath, result.outputPath);
3229
+ } catch (error) {
3230
+ const err = error instanceof Error ? error : new Error(String(error));
3231
+ this.callbacks.onProcessingError(filePath, err);
3232
+ } finally {
3233
+ this.processing.delete(filePath);
3234
+ }
3235
+ }
3236
+ /**
3237
+ * Append a processed file entry to the watch log.
3238
+ */
3239
+ async appendToWatchLog(inputPath, outputPath) {
3240
+ const logPath = join4(resolve(this.options.watchDir), WATCH_LOG_FILENAME);
3241
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3242
+ const entry = `${timestamp} ${inputPath} ${outputPath}
3243
+ `;
3244
+ try {
3245
+ await appendFile(logPath, entry, "utf-8");
3246
+ } catch {
3247
+ }
3248
+ }
3249
+ };
3250
+
3251
+ // src/cli/index.ts
3252
+ var VERSION = true ? "2.5.0" : "0.0.0-dev";
3253
+ var SYMBOLS = {
3254
+ check: "\u2714",
3255
+ // checkmark
3256
+ cross: "\u2718",
3257
+ // cross
3258
+ arrow: "\u2192",
3259
+ // right arrow
3260
+ bullet: "\u2022",
3261
+ // bullet
3262
+ ellipsis: "\u2026",
3263
+ // ellipsis
3264
+ line: "\u2500"
3265
+ // horizontal line
3266
+ };
3267
+ function banner() {
3268
+ console.log();
3269
+ console.log(` markupr v${VERSION} ${SYMBOLS.bullet} CLI Mode`);
3270
+ console.log(` ${SYMBOLS.line.repeat(40)}`);
3271
+ console.log();
3272
+ }
3273
+ function step(message) {
3274
+ console.log(` ${SYMBOLS.arrow} ${message}`);
3275
+ }
3276
+ function success(message) {
3277
+ console.log(` ${SYMBOLS.check} ${message}`);
3278
+ }
3279
+ function fail(message) {
3280
+ console.log(` ${SYMBOLS.cross} ${message}`);
3281
+ }
3282
+ var activePipeline = null;
3283
+ function setupSignalHandlers() {
3284
+ const handler = async () => {
3285
+ console.log("\n Interrupted \u2014 cleaning up...");
3286
+ if (activePipeline) {
3287
+ await activePipeline.abort();
3288
+ }
3289
+ process.exit(EXIT_SIGINT);
3290
+ };
3291
+ process.on("SIGINT", handler);
3292
+ process.on("SIGTERM", handler);
3293
+ }
3294
+ setupSignalHandlers();
3295
+ var program = new Command();
3296
+ program.name("markupr").description("Analyze screen recordings and generate AI-ready Markdown reports").version(VERSION, "-v, --version").showHelpAfterError("(use --help for available options)");
3297
+ program.command("analyze").description("Analyze a video recording and generate a structured feedback report").argument("<video-file>", "Path to the video file to analyze").option("--audio <file>", "Separate audio file (if not embedded in video)").option("--output <dir>", "Output directory", "./markupr-output").option("--whisper-model <path>", "Path to Whisper model file").option("--openai-key <key>", "OpenAI API key for cloud transcription (prefer OPENAI_API_KEY env var)").option("--no-frames", "Skip frame extraction").option("--template <name>", `Output template (${templateRegistry.list().join(", ")})`, "markdown").option("--verbose", "Verbose output", false).action(async (videoFile, options) => {
3298
+ banner();
3299
+ const videoPath = resolve2(videoFile);
3300
+ const outputDir = resolve2(options.output);
3301
+ const audioPath = options.audio ? resolve2(options.audio) : void 0;
3302
+ const whisperModelPath = options.whisperModel ? resolve2(options.whisperModel) : void 0;
3303
+ let openaiKey;
3304
+ if (options.openaiKey) {
3305
+ console.warn(" WARNING: Passing API keys via CLI args is insecure (visible in ps, shell history).");
3306
+ console.warn(" Use OPENAI_API_KEY env var instead.");
3307
+ console.warn();
3308
+ openaiKey = options.openaiKey;
3309
+ } else if (process.env.OPENAI_API_KEY) {
3310
+ openaiKey = process.env.OPENAI_API_KEY;
3311
+ }
3312
+ if (!existsSync5(videoPath)) {
3313
+ fail(`Video file not found: ${videoPath}`);
3314
+ process.exit(EXIT_USER_ERROR);
3315
+ }
3316
+ if (audioPath && !existsSync5(audioPath)) {
3317
+ fail(`Audio file not found: ${audioPath}`);
3318
+ process.exit(EXIT_USER_ERROR);
3319
+ }
3320
+ if (whisperModelPath && !existsSync5(whisperModelPath)) {
3321
+ fail(`Whisper model not found: ${whisperModelPath}`);
3322
+ process.exit(EXIT_USER_ERROR);
3323
+ }
3324
+ step(`Video: ${videoPath}`);
3325
+ if (audioPath) {
3326
+ step(`Audio: ${audioPath}`);
3327
+ }
3328
+ step(`Output: ${outputDir}`);
3329
+ console.log();
3330
+ if (!templateRegistry.has(options.template)) {
3331
+ fail(`Unknown template "${options.template}". Available: ${templateRegistry.list().join(", ")}`);
3332
+ process.exit(EXIT_USER_ERROR);
3333
+ }
3334
+ if (options.template !== "markdown") {
3335
+ step(`Template: ${options.template}`);
3336
+ }
3337
+ const pipeline = new CLIPipeline(
3338
+ {
3339
+ videoPath,
3340
+ audioPath,
3341
+ outputDir,
3342
+ whisperModelPath,
3343
+ openaiKey,
3344
+ skipFrames: !options.frames,
3345
+ template: options.template,
3346
+ verbose: options.verbose
3347
+ },
3348
+ options.verbose ? step : () => {
3349
+ },
3350
+ step
3351
+ // progress — always visible
3352
+ );
3353
+ activePipeline = pipeline;
3354
+ try {
3355
+ step("Starting analysis pipeline...");
3356
+ console.log();
3357
+ const result = await pipeline.run();
3358
+ if (result.transcriptSegments === 0 && result.extractedFrames === 0) {
3359
+ console.log();
3360
+ fail("Analysis produced no output (no transcript, no frames).");
3361
+ console.log(" Possible causes:");
3362
+ console.log(" - Video has no audio track (provide --audio <file>)");
3363
+ console.log(" - Whisper model not installed (check --whisper-model)");
3364
+ console.log(" - ffmpeg not installed (brew install ffmpeg)");
3365
+ process.exit(EXIT_USER_ERROR);
3366
+ }
3367
+ console.log();
3368
+ success("Analysis complete!");
3369
+ console.log();
3370
+ console.log(` Transcript segments: ${result.transcriptSegments}`);
3371
+ console.log(` Extracted frames: ${result.extractedFrames}`);
3372
+ console.log(` Processing time: ${result.durationSeconds.toFixed(1)}s`);
3373
+ console.log();
3374
+ console.log(` Output: ${result.outputPath}`);
3375
+ console.log(`OUTPUT:${result.outputPath}`);
3376
+ console.log();
3377
+ } catch (error) {
3378
+ console.log();
3379
+ const message = error instanceof Error ? error.message : String(error);
3380
+ fail(`Analysis failed: ${message}`);
3381
+ if (options.verbose && error instanceof Error && error.stack) {
3382
+ console.log();
3383
+ console.log(error.stack);
3384
+ }
3385
+ const exitCode = error instanceof CLIPipelineError && error.severity === "user" ? EXIT_USER_ERROR : EXIT_SYSTEM_ERROR;
3386
+ process.exit(exitCode);
3387
+ } finally {
3388
+ activePipeline = null;
3389
+ }
3390
+ });
3391
+ program.command("watch").description("Watch a directory for new recordings and auto-process them").argument("[directory]", "Directory to watch for recordings", ".").option("--output <dir>", "Output directory (default: <watched-dir>/markupr-output)").option("--whisper-model <path>", "Path to Whisper model file").option("--openai-key <key>", "OpenAI API key for cloud transcription (prefer OPENAI_API_KEY env var)").option("--no-frames", "Skip frame extraction").option("--verbose", "Verbose output", false).action(async (directory, options) => {
3392
+ banner();
3393
+ const watchDir = resolve2(directory);
3394
+ let openaiKey;
3395
+ if (options.openaiKey) {
3396
+ console.warn(" WARNING: Passing API keys via CLI args is insecure (visible in ps, shell history).");
3397
+ console.warn(" Use OPENAI_API_KEY env var instead.");
3398
+ console.warn();
3399
+ openaiKey = options.openaiKey;
3400
+ } else if (process.env.OPENAI_API_KEY) {
3401
+ openaiKey = process.env.OPENAI_API_KEY;
3402
+ }
3403
+ if (!existsSync5(watchDir)) {
3404
+ fail(`Directory not found: ${watchDir}`);
3405
+ process.exit(EXIT_USER_ERROR);
3406
+ }
3407
+ const watchMode = new WatchMode(
3408
+ {
3409
+ watchDir,
3410
+ outputDir: options.output ? resolve2(options.output) : void 0,
3411
+ whisperModelPath: options.whisperModel ? resolve2(options.whisperModel) : void 0,
3412
+ openaiKey,
3413
+ skipFrames: !options.frames,
3414
+ verbose: options.verbose
3415
+ },
3416
+ {
3417
+ onLog: step,
3418
+ onFileDetected: (filePath) => {
3419
+ step(`Detected: ${filePath}`);
3420
+ },
3421
+ onProcessingStart: (filePath) => {
3422
+ console.log();
3423
+ step(`Processing: ${filePath}`);
3424
+ },
3425
+ onProcessingComplete: (filePath, outputPath) => {
3426
+ success(`Done: ${filePath}`);
3427
+ step(`Output: ${outputPath}`);
3428
+ },
3429
+ onProcessingError: (filePath, error) => {
3430
+ fail(`Failed: ${filePath} \u2014 ${error.message}`);
3431
+ }
3432
+ }
3433
+ );
3434
+ const shutdown = () => {
3435
+ console.log("\n Stopping watcher...");
3436
+ watchMode.stop();
3437
+ };
3438
+ process.on("SIGINT", shutdown);
3439
+ process.on("SIGTERM", shutdown);
3440
+ step(`Watching for recordings in: ${watchDir}`);
3441
+ step("Press Ctrl+C to stop");
3442
+ console.log();
3443
+ try {
3444
+ await watchMode.start();
3445
+ success("Watch mode stopped.");
3446
+ } catch (error) {
3447
+ const message = error instanceof Error ? error.message : String(error);
3448
+ fail(`Watch mode error: ${message}`);
3449
+ process.exit(EXIT_SYSTEM_ERROR);
3450
+ }
3451
+ });
3452
+ var pushCmd = program.command("push").description("Push feedback to external services");
3453
+ pushCmd.command("linear").description("Create Linear issues from a markupr feedback report").argument("<report>", "Path to the markupr markdown report").requiredOption("--team <key>", "Linear team key (e.g., ENG, DES)").option("--token <token>", "Linear API key (prefer LINEAR_API_KEY env var)").option("--project <name>", "Linear project name to assign issues to").option("--dry-run", "Show what would be created without creating anything", false).action(async (report, options) => {
3454
+ banner();
3455
+ const reportPath = resolve2(report);
3456
+ if (!existsSync5(reportPath)) {
3457
+ fail(`Report file not found: ${reportPath}`);
3458
+ process.exit(EXIT_USER_ERROR);
3459
+ }
3460
+ if (options.token) {
3461
+ console.warn(" WARNING: Passing tokens via CLI args is insecure (visible in ps, shell history).");
3462
+ console.warn(" Use LINEAR_API_KEY env var instead.");
3463
+ console.warn();
3464
+ }
3465
+ const apiToken = options.token || process.env.LINEAR_API_KEY;
3466
+ if (!apiToken) {
3467
+ fail("No Linear API token found.");
3468
+ console.log(" Provide via --token flag or LINEAR_API_KEY env var.");
3469
+ process.exit(EXIT_USER_ERROR);
3470
+ }
3471
+ const { LinearIssueCreator: LinearIssueCreator2 } = await Promise.resolve().then(() => (init_LinearIssueCreator(), LinearIssueCreator_exports));
3472
+ try {
3473
+ step(`Report: ${reportPath}`);
3474
+ step(`Team: ${options.team}`);
3475
+ if (options.project) {
3476
+ step(`Project: ${options.project}`);
3477
+ }
3478
+ if (options.dryRun) {
3479
+ step("Mode: DRY RUN");
3480
+ }
3481
+ console.log();
3482
+ step("Parsing feedback report...");
3483
+ const creator = new LinearIssueCreator2(apiToken);
3484
+ const result = await creator.pushReport(reportPath, {
3485
+ token: apiToken,
3486
+ teamKey: options.team,
3487
+ projectName: options.project,
3488
+ dryRun: options.dryRun
3489
+ });
3490
+ console.log();
3491
+ if (options.dryRun) {
3492
+ success(`Dry run complete \u2014 ${result.created} issue(s) would be created:`);
3493
+ console.log();
3494
+ for (const issue of result.issues) {
3495
+ if (issue.success) {
3496
+ step(`${issue.identifier}: ${issue.issueUrl}`);
3497
+ }
3498
+ }
3499
+ } else {
3500
+ success(`Created ${result.created} issue(s):`);
3501
+ console.log();
3502
+ for (const issue of result.issues) {
3503
+ if (issue.success) {
3504
+ step(`${issue.identifier}: ${issue.issueUrl}`);
3505
+ }
3506
+ }
3507
+ }
3508
+ if (result.failed > 0) {
3509
+ console.log();
3510
+ fail(`${result.failed} error(s):`);
3511
+ for (const issue of result.issues) {
3512
+ if (!issue.success) {
3513
+ fail(` ${issue.error}`);
3514
+ }
3515
+ }
3516
+ }
3517
+ console.log();
3518
+ } catch (error) {
3519
+ const message = error instanceof Error ? error.message : String(error);
3520
+ fail(message);
3521
+ process.exit(EXIT_USER_ERROR);
3522
+ }
3523
+ });
3524
+ pushCmd.command("github").description("Create GitHub issues from a markupr feedback report").argument("<report>", "Path to the markupr markdown report").requiredOption("--repo <owner/repo>", "Target GitHub repository (e.g., myorg/myapp)").option("--token <token>", "GitHub token (prefer GITHUB_TOKEN env var or gh auth login)").option("--items <ids...>", "Specific FB-XXX item IDs to push (default: all)").option("--dry-run", "Show what would be created without creating anything", false).action(async (report, options) => {
3525
+ banner();
3526
+ const reportPath = resolve2(report);
3527
+ if (!existsSync5(reportPath)) {
3528
+ fail(`Report file not found: ${reportPath}`);
3529
+ process.exit(EXIT_USER_ERROR);
3530
+ }
3531
+ if (options.token) {
3532
+ console.warn(" WARNING: Passing tokens via CLI args is insecure (visible in ps, shell history).");
3533
+ console.warn(" Use GITHUB_TOKEN env var or `gh auth login` instead.");
3534
+ console.warn();
3535
+ }
3536
+ const { resolveAuth: resolveAuth2, parseRepoString: parseRepoString2, pushToGitHub: pushToGitHub2 } = await Promise.resolve().then(() => (init_GitHubIssueCreator(), GitHubIssueCreator_exports));
3537
+ try {
3538
+ const parsedRepo = parseRepoString2(options.repo);
3539
+ const auth = await resolveAuth2(options.token);
3540
+ step(`Report: ${reportPath}`);
3541
+ step(`Repo: ${parsedRepo.owner}/${parsedRepo.repo}`);
3542
+ step(`Auth: ${auth.source}`);
3543
+ if (options.dryRun) {
3544
+ step("Mode: DRY RUN");
3545
+ }
3546
+ console.log();
3547
+ step("Parsing feedback report and creating issues...");
3548
+ const result = await pushToGitHub2({
3549
+ repo: parsedRepo,
3550
+ auth,
3551
+ reportPath,
3552
+ dryRun: options.dryRun,
3553
+ items: options.items
3554
+ });
3555
+ console.log();
3556
+ if (options.dryRun) {
3557
+ success(`Dry run complete \u2014 ${result.created.length} issue(s) would be created:`);
3558
+ console.log();
3559
+ for (const issue of result.created) {
3560
+ step(` ${issue.title}`);
3561
+ }
3562
+ } else {
3563
+ success(`Created ${result.created.length} issue(s):`);
3564
+ console.log();
3565
+ for (const issue of result.created) {
3566
+ step(`#${issue.number}: ${issue.title}`);
3567
+ step(` ${issue.url}`);
3568
+ }
3569
+ }
3570
+ if (result.labelsCreated.length > 0) {
3571
+ console.log();
3572
+ step(`Labels created: ${result.labelsCreated.join(", ")}`);
3573
+ }
3574
+ if (result.errors.length > 0) {
3575
+ console.log();
3576
+ fail(`${result.errors.length} error(s):`);
3577
+ for (const err of result.errors) {
3578
+ fail(` ${err.itemId}: ${err.error}`);
3579
+ }
3580
+ }
3581
+ console.log();
3582
+ } catch (error) {
3583
+ const message = error instanceof Error ? error.message : String(error);
3584
+ fail(message);
3585
+ process.exit(EXIT_USER_ERROR);
3586
+ }
3587
+ });
3588
+ if (process.argv.length <= 2) {
3589
+ banner();
3590
+ program.outputHelp();
3591
+ process.exit(EXIT_SUCCESS);
3592
+ }
3593
+ program.parse();